@node9/proxy 1.29.0 → 1.31.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/index.mjs CHANGED
@@ -51,11 +51,17 @@ __export(audit_exports, {
51
51
  appendHookDebug: () => appendHookDebug,
52
52
  appendLocalAudit: () => appendLocalAudit,
53
53
  appendToLog: () => appendToLog,
54
+ buildArgsPreview: () => buildArgsPreview,
55
+ generateEventId: () => generateEventId,
54
56
  redactSecrets: () => redactSecrets
55
57
  });
56
58
  import fs from "fs";
57
59
  import path from "path";
58
60
  import os from "os";
61
+ import crypto from "crypto";
62
+ function generateEventId() {
63
+ return `${Date.now().toString(36)}-${crypto.randomBytes(6).toString("hex")}`;
64
+ }
59
65
  function isTestCall(toolName, args) {
60
66
  if (toolName !== "Bash" && toolName !== "bash") return false;
61
67
  const cmd = args?.command;
@@ -74,6 +80,17 @@ function redactSecrets(text) {
74
80
  );
75
81
  return redacted;
76
82
  }
83
+ function buildArgsPreview(args) {
84
+ try {
85
+ const o = args && typeof args === "object" ? args : null;
86
+ const primary = o && (o.command ?? o.file_path ?? o.path ?? o.url ?? o.query);
87
+ const text = typeof primary === "string" ? primary : args ? JSON.stringify(args) : "";
88
+ if (!text) return void 0;
89
+ return redactSecrets(text).slice(0, 120);
90
+ } catch {
91
+ return void 0;
92
+ }
93
+ }
77
94
  function appendToLog(logPath, entry) {
78
95
  try {
79
96
  const dir = path.dirname(logPath);
@@ -96,11 +113,18 @@ function appendHookDebug(toolName, args, meta, auditHashArgsEnabled) {
96
113
  });
97
114
  }
98
115
  function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
99
- const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args) } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
116
+ const isDlpRow = checkedBy.toLowerCase().includes("dlp") || Boolean(meta?.dlpPattern);
117
+ const preview = auditHashArgsEnabled && !isDlpRow ? buildArgsPreview(args) : void 0;
118
+ const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args), ...preview ? { argsPreview: preview } : {} } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
100
119
  const testRun = isTestCall(toolName, args) || process.env.NODE9_TESTING === "1" ? { testRun: true } : {};
101
120
  const ruleNameField = meta?.ruleName ? { ruleName: meta.ruleName } : {};
102
121
  const agentToolNameField = meta?.agentToolName ? { agentToolName: meta.agentToolName } : {};
122
+ const dlpFields = meta?.dlpPattern ? { dlpPattern: meta.dlpPattern, dlpSample: meta.dlpSample } : {};
123
+ const cloudLinkField = meta?.cloudRequestId ? { cloudRequestId: meta.cloudRequestId } : {};
103
124
  appendToLog(LOCAL_AUDIT_LOG, {
125
+ // eid first: the outbox shipper dedups on it, and a fixed leading field
126
+ // makes the JSONL easy to eyeball.
127
+ eid: generateEventId(),
104
128
  ts: (/* @__PURE__ */ new Date()).toISOString(),
105
129
  tool: toolName,
106
130
  ...agentToolNameField,
@@ -108,6 +132,8 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
108
132
  decision,
109
133
  checkedBy,
110
134
  ...ruleNameField,
135
+ ...dlpFields,
136
+ ...cloudLinkField,
111
137
  ...testRun,
112
138
  agent: meta?.agent,
113
139
  mcpServer: meta?.mcpServer,
@@ -216,7 +242,13 @@ var ConfigFileSchema = z.object({
216
242
  allowGlobalPause: z.boolean().optional(),
217
243
  auditHashArgs: z.boolean().optional(),
218
244
  agentPolicy: z.enum(["require_approval", "block_on_rules"]).optional(),
219
- cloudSyncIntervalHours: z.number().positive().optional()
245
+ cloudSyncIntervalHours: z.number().positive().optional(),
246
+ // Outbox shipper (audit.log → SaaS batch ingest). enabled defaults
247
+ // to true; set false to fall back to local-only auditing.
248
+ shipper: z.object({
249
+ enabled: z.boolean().optional(),
250
+ intervalSeconds: z.number().min(5).optional()
251
+ }).optional()
220
252
  }).optional(),
221
253
  policy: z.object({
222
254
  sandboxPaths: z.array(z.string()).optional(),
@@ -231,7 +263,15 @@ var ConfigFileSchema = z.object({
231
263
  }).optional(),
232
264
  dlp: z.object({
233
265
  enabled: z.boolean().optional(),
234
- scanIgnoredTools: z.boolean().optional()
266
+ scanIgnoredTools: z.boolean().optional(),
267
+ pii: z.enum(["off", "block"]).optional()
268
+ }).optional(),
269
+ egress: z.object({
270
+ enabled: z.boolean().optional(),
271
+ mode: z.enum(["off", "review", "block"]).optional(),
272
+ allow: z.array(z.string()).optional(),
273
+ deny: z.array(z.string()).optional(),
274
+ allowPrivate: z.boolean().optional()
235
275
  }).optional(),
236
276
  loopDetection: z.object({
237
277
  enabled: z.boolean().optional(),
@@ -284,7 +324,7 @@ import mvdanSh from "mvdan-sh";
284
324
  import pm from "picomatch";
285
325
  import safeRegex2 from "safe-regex2";
286
326
  import safeRegex3 from "safe-regex2";
287
- import crypto from "crypto";
327
+ import crypto2 from "crypto";
288
328
  var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
289
329
  function isAssignmentContext(text) {
290
330
  return ASSIGNMENT_CONTEXT_RE.test(text);
@@ -1213,6 +1253,201 @@ function extractLiteralArgs(callExpr) {
1213
1253
  }
1214
1254
  return { name, flags, paths };
1215
1255
  }
1256
+ var NET_BINARIES = /* @__PURE__ */ new Set(["curl", "wget", "scp", "ssh", "nc", "ncat", "netcat"]);
1257
+ var VALUE_FLAGS = {
1258
+ curl: /* @__PURE__ */ new Set([
1259
+ "-d",
1260
+ "--data",
1261
+ "--data-ascii",
1262
+ "--data-binary",
1263
+ "--data-raw",
1264
+ "--data-urlencode",
1265
+ "-F",
1266
+ "--form",
1267
+ "-H",
1268
+ "--header",
1269
+ "-X",
1270
+ "--request",
1271
+ "-o",
1272
+ "--output",
1273
+ "-T",
1274
+ "--upload-file",
1275
+ "-u",
1276
+ "--user",
1277
+ "-e",
1278
+ "--referer",
1279
+ "-A",
1280
+ "--user-agent",
1281
+ "-b",
1282
+ "--cookie",
1283
+ "-c",
1284
+ "--cookie-jar",
1285
+ "--connect-to",
1286
+ "--resolve",
1287
+ "--cacert",
1288
+ "--cert",
1289
+ "--key",
1290
+ "-x",
1291
+ "--proxy",
1292
+ "-m",
1293
+ "--max-time",
1294
+ "--retry"
1295
+ ]),
1296
+ wget: /* @__PURE__ */ new Set([
1297
+ "-O",
1298
+ "--output-document",
1299
+ "--post-data",
1300
+ "--post-file",
1301
+ "--header",
1302
+ "-U",
1303
+ "--user-agent",
1304
+ "--user",
1305
+ "--password",
1306
+ "-o",
1307
+ "--output-file",
1308
+ "-P",
1309
+ "--directory-prefix",
1310
+ "-t",
1311
+ "--tries",
1312
+ "-T",
1313
+ "--timeout"
1314
+ ]),
1315
+ scp: /* @__PURE__ */ new Set(["-i", "-F", "-l", "-o", "-c", "-S", "-P", "-J", "-D", "-W"]),
1316
+ ssh: /* @__PURE__ */ new Set([
1317
+ "-i",
1318
+ "-p",
1319
+ "-o",
1320
+ "-l",
1321
+ "-F",
1322
+ "-c",
1323
+ "-L",
1324
+ "-R",
1325
+ "-D",
1326
+ "-W",
1327
+ "-b",
1328
+ "-e",
1329
+ "-m",
1330
+ "-O",
1331
+ "-Q",
1332
+ "-S",
1333
+ "-J",
1334
+ "-w",
1335
+ "-B",
1336
+ "-I",
1337
+ "-E"
1338
+ ]),
1339
+ nc: /* @__PURE__ */ new Set(["-p", "-s", "-w", "-X", "-x", "-e", "-g", "-G", "-i", "-O", "-T", "-q", "-m"])
1340
+ };
1341
+ function resolveWordLiteral(w) {
1342
+ const parts = w?.Parts || [];
1343
+ let s = "";
1344
+ for (const p of parts) {
1345
+ const t = syntax.NodeType(p);
1346
+ if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
1347
+ else if (t === "SglQuoted") s += p.Value ?? "";
1348
+ else if (t === "DblQuoted") {
1349
+ const inner = p.Parts || [];
1350
+ if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
1351
+ s += inner.map((ip) => ip.Value ?? "").join("");
1352
+ } else {
1353
+ return null;
1354
+ }
1355
+ }
1356
+ return s;
1357
+ }
1358
+ function parseDestHost(token) {
1359
+ if (!token) return null;
1360
+ let t = token.trim();
1361
+ if (!t || t.startsWith("-")) return null;
1362
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) {
1363
+ try {
1364
+ const h = new URL(t).hostname.toLowerCase();
1365
+ return h || null;
1366
+ } catch {
1367
+ return null;
1368
+ }
1369
+ }
1370
+ const at = t.lastIndexOf("@");
1371
+ if (at >= 0) t = t.slice(at + 1);
1372
+ t = t.split("/")[0];
1373
+ t = t.replace(/:\d+$/, "");
1374
+ t = t.split(":")[0];
1375
+ t = t.toLowerCase();
1376
+ if (t.length > 253) return null;
1377
+ if (t === "localhost") return t;
1378
+ if (/^[a-z0-9.-]+\.[a-z0-9.-]+$/.test(t)) return t;
1379
+ return null;
1380
+ }
1381
+ function destTokensForBinary(binary, args) {
1382
+ const valueFlags = VALUE_FLAGS[binary] ?? /* @__PURE__ */ new Set();
1383
+ const positionals = [];
1384
+ const urlFlagValues = [];
1385
+ for (let i = 0; i < args.length; i++) {
1386
+ const tok = args[i];
1387
+ if (tok === null) continue;
1388
+ if (tok.startsWith("-")) {
1389
+ if (tok.startsWith("--url=")) {
1390
+ urlFlagValues.push(tok.slice("--url=".length));
1391
+ continue;
1392
+ }
1393
+ if (tok === "--url") {
1394
+ const next = args[i + 1];
1395
+ if (typeof next === "string") urlFlagValues.push(next);
1396
+ i++;
1397
+ continue;
1398
+ }
1399
+ if (tok.includes("=")) continue;
1400
+ if (valueFlags.has(tok)) i++;
1401
+ continue;
1402
+ }
1403
+ positionals.push(tok);
1404
+ }
1405
+ switch (binary) {
1406
+ case "curl":
1407
+ case "wget":
1408
+ return [...urlFlagValues, ...positionals];
1409
+ case "ssh":
1410
+ return positionals.slice(0, 1);
1411
+ case "scp":
1412
+ return positionals.filter((p) => p.includes(":") || p.includes("@"));
1413
+ case "nc":
1414
+ case "ncat":
1415
+ case "netcat":
1416
+ return positionals.slice(0, 1);
1417
+ default:
1418
+ return [];
1419
+ }
1420
+ }
1421
+ function extractShellDestinations(command) {
1422
+ const f = parseShared(command);
1423
+ if (f === PARSE_FAIL) return [];
1424
+ const out = [];
1425
+ const seen = /* @__PURE__ */ new Set();
1426
+ try {
1427
+ syntax.Walk(f, (node) => {
1428
+ if (!node) return false;
1429
+ const n = node;
1430
+ if (syntax.NodeType(n) !== "CallExpr") return true;
1431
+ const callArgs = n.Args || [];
1432
+ if (callArgs.length === 0) return true;
1433
+ const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
1434
+ if (!NET_BINARIES.has(name)) return true;
1435
+ const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
1436
+ for (const raw of destTokensForBinary(name, rest)) {
1437
+ const host = parseDestHost(raw);
1438
+ if (!host) continue;
1439
+ const key = `${name}:${host}`;
1440
+ if (seen.has(key)) continue;
1441
+ seen.add(key);
1442
+ out.push({ host, binary: name, raw });
1443
+ }
1444
+ return true;
1445
+ });
1446
+ } catch {
1447
+ return out;
1448
+ }
1449
+ return out;
1450
+ }
1216
1451
  var FS_OP_CACHE_MAX = 5e3;
1217
1452
  var fsOpCache = /* @__PURE__ */ new Map();
1218
1453
  function analyzeFsOperation(command) {
@@ -1342,6 +1577,83 @@ function analyzeShellCommand(command) {
1342
1577
  }
1343
1578
  return { actions, paths, allTokens };
1344
1579
  }
1580
+ var DEFAULT_EGRESS_ALLOWLIST = [
1581
+ "*.github.com",
1582
+ "*.githubusercontent.com",
1583
+ "*.npmjs.org",
1584
+ "pypi.org",
1585
+ "*.pythonhosted.org",
1586
+ "crates.io",
1587
+ "*.crates.io",
1588
+ "rubygems.org",
1589
+ "proxy.golang.org",
1590
+ "sum.golang.org",
1591
+ "*.anthropic.com",
1592
+ "*.openai.com",
1593
+ "*.googleapis.com",
1594
+ "*.docker.io",
1595
+ "*.docker.com",
1596
+ "deb.debian.org",
1597
+ "*.ubuntu.com"
1598
+ ];
1599
+ function hostMatches(host, pattern) {
1600
+ const h = host.toLowerCase();
1601
+ const p = pattern.toLowerCase().trim();
1602
+ if (!p) return false;
1603
+ if (p === "*") return true;
1604
+ if (p.startsWith("*.")) {
1605
+ const suffix = p.slice(2);
1606
+ return h === suffix || h.endsWith("." + suffix);
1607
+ }
1608
+ return h === p;
1609
+ }
1610
+ function matchesAny(host, patterns) {
1611
+ for (const p of patterns) if (hostMatches(host, p)) return true;
1612
+ return false;
1613
+ }
1614
+ function isPrivateHost(host) {
1615
+ const h = host.toLowerCase();
1616
+ if (h === "localhost" || h === "0.0.0.0") return true;
1617
+ if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localhost")) return true;
1618
+ if (/^127\./.test(h)) return true;
1619
+ if (/^10\./.test(h)) return true;
1620
+ if (/^192\.168\./.test(h)) return true;
1621
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
1622
+ return false;
1623
+ }
1624
+ function evaluateEgress(dests, policy) {
1625
+ if (!policy.enabled) return null;
1626
+ let review = null;
1627
+ for (const d of dests) {
1628
+ if (matchesAny(d.host, policy.deny)) {
1629
+ return {
1630
+ verdict: "block",
1631
+ host: d.host,
1632
+ binary: d.binary,
1633
+ reason: `Egress to ${d.host} is on the deny list.`
1634
+ };
1635
+ }
1636
+ if (policy.allowPrivate && isPrivateHost(d.host)) continue;
1637
+ if (matchesAny(d.host, policy.allow) || matchesAny(d.host, DEFAULT_EGRESS_ALLOWLIST)) continue;
1638
+ if (policy.mode === "block") {
1639
+ return {
1640
+ verdict: "block",
1641
+ host: d.host,
1642
+ binary: d.binary,
1643
+ reason: `Egress to unknown host ${d.host} is blocked (egress policy: block).`
1644
+ };
1645
+ }
1646
+ if (policy.mode === "review" && !review) {
1647
+ review = {
1648
+ verdict: "review",
1649
+ host: d.host,
1650
+ binary: d.binary,
1651
+ reason: `${d.binary} is sending data to an unrecognized host (${d.host}). Approve this destination?`
1652
+ };
1653
+ }
1654
+ }
1655
+ return review;
1656
+ }
1345
1657
  var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
1346
1658
  "cat",
1347
1659
  "head",
@@ -1907,6 +2219,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
1907
2219
  }
1908
2220
  const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost2);
1909
2221
  if (ptVerdict) return ptVerdict;
2222
+ if (config.policy.egress?.enabled) {
2223
+ const dests = extractShellDestinations(shellCommand);
2224
+ if (dests.length > 0) {
2225
+ const eg = evaluateEgress(dests, config.policy.egress);
2226
+ if (eg) {
2227
+ return {
2228
+ decision: eg.verdict,
2229
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
2230
+ reason: eg.reason,
2231
+ ruleName: `egress:${eg.binary}:${eg.host}`,
2232
+ ruleDescription: eg.reason,
2233
+ tier: eg.verdict === "block" ? 3 : 4
2234
+ };
2235
+ }
2236
+ }
2237
+ }
1910
2238
  const firstToken = analyzed.actions[0] ?? "";
1911
2239
  if (["ssh", "scp", "rsync"].includes(firstToken)) {
1912
2240
  const rawTokens = shellCommand.trim().split(/\s+/);
@@ -2817,7 +3145,7 @@ assertBuiltinShieldRegexesAreSafe();
2817
3145
  var LOOP_MAX_RECORDS = 500;
2818
3146
  function computeArgsHash(args) {
2819
3147
  const str = JSON.stringify(args ?? "");
2820
- return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
3148
+ return crypto2.createHash("sha256").update(str).digest("hex").slice(0, 16);
2821
3149
  }
2822
3150
  function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
2823
3151
  const hash = computeArgsHash(args);
@@ -2828,6 +3156,32 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
2828
3156
  const nextRecords = fresh.slice(-LOOP_MAX_RECORDS);
2829
3157
  return { nextRecords, count, looping: count >= threshold };
2830
3158
  }
3159
+ var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3160
+ var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3161
+ var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3162
+ var PII_CC_RE = /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
3163
+ function detectPii(text) {
3164
+ const found = /* @__PURE__ */ new Set();
3165
+ if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
3166
+ if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
3167
+ if (PII_PHONE_RE.test(text)) found.add("Phone");
3168
+ if (PII_CC_RE.test(text)) found.add("Credit Card");
3169
+ return [...found];
3170
+ }
3171
+ var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
3172
+ var MAX_PII_SCAN_BYTES = 1e5;
3173
+ function detectArgsPii(args) {
3174
+ if (args === null || args === void 0) return [];
3175
+ let text;
3176
+ try {
3177
+ text = typeof args === "string" ? args : JSON.stringify(args);
3178
+ } catch {
3179
+ return [];
3180
+ }
3181
+ if (typeof text !== "string") return [];
3182
+ if (text.length > MAX_PII_SCAN_BYTES) text = text.slice(0, MAX_PII_SCAN_BYTES);
3183
+ return detectPii(text).filter((p) => REALTIME_PII_PATTERNS.includes(p));
3184
+ }
2831
3185
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
2832
3186
 
2833
3187
  // src/shields.ts
@@ -2930,7 +3284,8 @@ var DEFAULT_CONFIG = {
2930
3284
  flightRecorder: true,
2931
3285
  auditHashArgs: true,
2932
3286
  approvers: { native: true, browser: false, cloud: false, terminal: true },
2933
- cloudSyncIntervalHours: 5
3287
+ cloudSyncIntervalHours: 5,
3288
+ shipper: { enabled: true, intervalSeconds: 20 }
2934
3289
  },
2935
3290
  policy: {
2936
3291
  sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
@@ -3114,7 +3469,8 @@ var DEFAULT_CONFIG = {
3114
3469
  description: "The AI wants to download a script from the internet and run it immediately, without you seeing what it contains. This is one of the most common ways malware gets installed."
3115
3470
  }
3116
3471
  ],
3117
- dlp: { enabled: true, scanIgnoredTools: true },
3472
+ dlp: { enabled: true, scanIgnoredTools: true, pii: "off" },
3473
+ egress: { enabled: false, mode: "review", allow: [], deny: [], allowPrivate: true },
3118
3474
  loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
3119
3475
  skillPinning: { enabled: false, mode: "warn", roots: [] }
3120
3476
  },
@@ -3225,7 +3581,8 @@ function getConfig(cwd) {
3225
3581
  const projectConfig = tryLoadConfig(projectPath);
3226
3582
  const mergedSettings = {
3227
3583
  ...DEFAULT_CONFIG.settings,
3228
- approvers: { ...DEFAULT_CONFIG.settings.approvers }
3584
+ approvers: { ...DEFAULT_CONFIG.settings.approvers },
3585
+ shipper: { ...DEFAULT_CONFIG.settings.shipper }
3229
3586
  };
3230
3587
  const mergedPolicy = {
3231
3588
  sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
@@ -3239,6 +3596,11 @@ function getConfig(cwd) {
3239
3596
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
3240
3597
  },
3241
3598
  dlp: { ...DEFAULT_CONFIG.policy.dlp },
3599
+ egress: {
3600
+ ...DEFAULT_CONFIG.policy.egress,
3601
+ allow: [...DEFAULT_CONFIG.policy.egress.allow],
3602
+ deny: [...DEFAULT_CONFIG.policy.egress.deny]
3603
+ },
3242
3604
  loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
3243
3605
  skillPinning: {
3244
3606
  ...DEFAULT_CONFIG.policy.skillPinning,
@@ -3256,6 +3618,7 @@ function getConfig(cwd) {
3256
3618
  if (s.enableHookLogDebug !== void 0)
3257
3619
  mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
3258
3620
  if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
3621
+ if (s.shipper) mergedSettings.shipper = { ...mergedSettings.shipper, ...s.shipper };
3259
3622
  if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
3260
3623
  if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
3261
3624
  mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
@@ -3288,6 +3651,15 @@ function getConfig(cwd) {
3288
3651
  const d = p.dlp;
3289
3652
  if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
3290
3653
  if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
3654
+ if (d.pii !== void 0) mergedPolicy.dlp.pii = d.pii;
3655
+ }
3656
+ if (p.egress) {
3657
+ const e = p.egress;
3658
+ if (e.enabled !== void 0) mergedPolicy.egress.enabled = e.enabled;
3659
+ if (e.mode !== void 0) mergedPolicy.egress.mode = e.mode;
3660
+ if (Array.isArray(e.allow)) mergedPolicy.egress.allow.push(...e.allow);
3661
+ if (Array.isArray(e.deny)) mergedPolicy.egress.deny.push(...e.deny);
3662
+ if (e.allowPrivate !== void 0) mergedPolicy.egress.allowPrivate = e.allowPrivate;
3291
3663
  }
3292
3664
  if (p.loopDetection) {
3293
3665
  const ld = p.loopDetection;
@@ -4030,6 +4402,15 @@ function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWo
4030
4402
  var isTestEnv = () => {
4031
4403
  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";
4032
4404
  };
4405
+ var MIN_INTERACTION_MS = 400;
4406
+ function resolveNativeDecision(opts) {
4407
+ const { code, output, elapsedMs, locked } = opts;
4408
+ if (locked) return "deny";
4409
+ const tooFast = elapsedMs < MIN_INTERACTION_MS;
4410
+ if (output.includes("Always Allow")) return tooFast ? "deny" : "always_allow";
4411
+ if (code === 0) return tooFast ? "deny" : "allow";
4412
+ return "deny";
4413
+ }
4033
4414
  function formatArgs(args, matchedField, matchedWord) {
4034
4415
  if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
4035
4416
  let parsed = args;
@@ -4173,6 +4554,7 @@ async function askNativePopup(toolName, args, agent, explainableLabel, locked =
4173
4554
  );
4174
4555
  return new Promise((resolve) => {
4175
4556
  let childProcess = null;
4557
+ const startedAt = Date.now();
4176
4558
  const onAbort = () => {
4177
4559
  if (childProcess && childProcess.pid) {
4178
4560
  try {
@@ -4230,13 +4612,14 @@ end run`;
4230
4612
  }
4231
4613
  let output = "";
4232
4614
  childProcess?.stdout?.on("data", (d) => output += d.toString());
4233
- childProcess?.on("close", (code) => {
4615
+ childProcess?.on("error", () => {
4234
4616
  if (signal) signal.removeEventListener("abort", onAbort);
4235
- if (locked) return resolve("deny");
4236
- if (output.includes("Always Allow")) return resolve("always_allow");
4237
- if (code === 0) return resolve("allow");
4238
4617
  resolve("deny");
4239
4618
  });
4619
+ childProcess?.on("close", (code) => {
4620
+ if (signal) signal.removeEventListener("abort", onAbort);
4621
+ resolve(resolveNativeDecision({ code, output, elapsedMs: Date.now() - startedAt, locked }));
4622
+ });
4240
4623
  } catch {
4241
4624
  resolve("deny");
4242
4625
  }
@@ -4251,91 +4634,6 @@ init_audit();
4251
4634
  import fs9 from "fs";
4252
4635
  import os8 from "os";
4253
4636
  import path11 from "path";
4254
- var DLP_SAMPLE_MAX_LEN = 200;
4255
- var DLP_PATTERN_MAX_LEN = 100;
4256
- var KNOWN_CHECKED_BY = /* @__PURE__ */ new Set([
4257
- "dlp-block",
4258
- "observe-mode-dlp-would-block",
4259
- "dlp-review-flagged",
4260
- "loop-detected",
4261
- "audit-mode",
4262
- "local-policy",
4263
- "smart-rule-block",
4264
- // Smart-rule block was downgraded to review because the daemon was
4265
- // running and we're not in CI. The block attempt is still recorded;
4266
- // the user got a popup. Distinct from 'smart-rule-block' so the
4267
- // dashboard can show "block rule overridden" separately from a hard
4268
- // block that fired with no human in the loop.
4269
- "smart-rule-block-override",
4270
- "persistent",
4271
- "trust",
4272
- "observe-mode",
4273
- "observe-mode-would-block"
4274
- ]);
4275
- function validateApiUrl(raw) {
4276
- let u;
4277
- try {
4278
- u = new URL(raw);
4279
- } catch {
4280
- return null;
4281
- }
4282
- if (u.username || u.password) return null;
4283
- if (u.protocol === "https:") return u;
4284
- if (u.protocol === "http:") {
4285
- const h = u.hostname;
4286
- if (h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]") return u;
4287
- }
4288
- return null;
4289
- }
4290
- function auditLocalAllow(toolName, args, checkedBy, creds, meta, dlpInfo, containsSensitiveArgs = false, riskMetadata) {
4291
- const validated = validateApiUrl(creds.apiUrl);
4292
- if (!validated) {
4293
- try {
4294
- fs9.appendFileSync(
4295
- HOOK_DEBUG_LOG,
4296
- `[audit] refused to send: invalid apiUrl scheme/host (got "${String(creds.apiUrl).slice(0, 200)}")
4297
- `
4298
- );
4299
- } catch {
4300
- }
4301
- return Promise.resolve();
4302
- }
4303
- const safeArgs = containsSensitiveArgs ? { tool: toolName, redacted: true } : args;
4304
- const dlpSample = dlpInfo && typeof dlpInfo.redactedSample === "string" ? dlpInfo.redactedSample.slice(0, DLP_SAMPLE_MAX_LEN) : void 0;
4305
- const dlpPattern = dlpInfo && typeof dlpInfo.pattern === "string" ? dlpInfo.pattern.slice(0, DLP_PATTERN_MAX_LEN) : void 0;
4306
- const safeCheckedBy = KNOWN_CHECKED_BY.has(checkedBy) ? checkedBy : "unknown";
4307
- const cleanedRiskMetadata = riskMetadata ? Object.fromEntries(
4308
- Object.entries(riskMetadata).filter(([, v]) => typeof v === "string" && v.length > 0)
4309
- ) : void 0;
4310
- const hasRiskMetadata = cleanedRiskMetadata && Object.keys(cleanedRiskMetadata).length > 0;
4311
- return fetch(`${validated.toString().replace(/\/$/, "")}/audit`, {
4312
- method: "POST",
4313
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
4314
- body: JSON.stringify({
4315
- toolName,
4316
- args: safeArgs,
4317
- checkedBy: safeCheckedBy,
4318
- ...dlpInfo && { dlpPattern, dlpSample },
4319
- ...hasRiskMetadata && { riskMetadata: cleanedRiskMetadata },
4320
- // session_id (Claude Code + Gemini CLI) groups all audit rows from one
4321
- // agent run; transcript_path is the authoritative pointer to the
4322
- // session log (survives Gemini resume drift). Both optional —
4323
- // unsupported agents (MCP-mediated) leave them undefined.
4324
- ...meta?.sessionId && { runId: meta.sessionId },
4325
- ...meta?.transcriptPath && { transcriptPath: meta.transcriptPath },
4326
- context: {
4327
- agent: meta?.agent,
4328
- mcpServer: meta?.mcpServer,
4329
- hostname: os8.hostname(),
4330
- cwd: process.cwd(),
4331
- platform: os8.platform()
4332
- }
4333
- }),
4334
- signal: AbortSignal.timeout(5e3)
4335
- }).then(() => {
4336
- }).catch(() => {
4337
- });
4338
- }
4339
4637
  async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
4340
4638
  const controller = new AbortController();
4341
4639
  const timeout = setTimeout(() => controller.abort(), 1e4);
@@ -4623,6 +4921,37 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4623
4921
  if (taintResult.tainted && taintResult.record) {
4624
4922
  const { path: taintedPath, source: taintSource } = taintResult.record;
4625
4923
  taintWarning = `\u26A0\uFE0F ${taintedPath} was flagged by ${taintSource} \u2014 this file may contain sensitive data`;
4924
+ if (config.policy.egress?.enabled) {
4925
+ const a = args && typeof args === "object" && !Array.isArray(args) ? args : {};
4926
+ const cmd = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : "";
4927
+ const dests = cmd ? extractShellDestinations(cmd) : [];
4928
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
4929
+ if (eg) {
4930
+ if (!isManual)
4931
+ appendLocalAudit(
4932
+ toolName,
4933
+ args,
4934
+ "deny",
4935
+ isObserveMode ? "observe-mode-taint-egress-would-block" : "taint-egress-block",
4936
+ { ...meta, ruleName: `taint-egress:${eg.host}` },
4937
+ hashAuditArgs
4938
+ );
4939
+ if (isObserveMode) {
4940
+ return {
4941
+ approved: true,
4942
+ checkedBy: "audit",
4943
+ observeWouldBlock: true,
4944
+ blockedByLabel: "\u{1F534} Node9 Taint+Egress (Exfiltration)"
4945
+ };
4946
+ }
4947
+ return {
4948
+ approved: false,
4949
+ reason: `\u{1F534} EXFILTRATION BLOCKED: the tainted file "${taintedPath}" is being sent to untrusted host "${eg.host}". A flagged file leaving to an unrecognized destination is blocked outright.`,
4950
+ blockedBy: "local-config",
4951
+ blockedByLabel: "\u{1F534} Node9 Taint+Egress (Exfiltration Blocked)"
4952
+ };
4953
+ }
4954
+ }
4626
4955
  } else if (taintResult.daemonUnavailable) {
4627
4956
  taintWarning = `\u26A0\uFE0F Taint service unavailable \u2014 cannot verify if ${filePaths.join(", ")} is clean`;
4628
4957
  }
@@ -4641,17 +4970,11 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4641
4970
  args,
4642
4971
  "deny",
4643
4972
  isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
4644
- meta,
4645
- true
4646
- );
4647
- if (approvers.cloud && creds?.apiKey)
4648
- auditLocalAllow(
4649
- toolName,
4650
- args,
4651
- isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
4652
- creds,
4653
- meta,
4654
- { pattern: dlpMatch.patternName, redactedSample: dlpMatch.redactedSample },
4973
+ {
4974
+ ...meta,
4975
+ dlpPattern: dlpMatch.patternName,
4976
+ dlpSample: dlpMatch.redactedSample
4977
+ },
4655
4978
  true
4656
4979
  );
4657
4980
  if (isWriteTool(toolName) && filePath) {
@@ -4677,6 +5000,35 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4677
5000
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
4678
5001
  }
4679
5002
  }
5003
+ if (config.policy.dlp.pii === "block" && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
5004
+ const piiFound = detectArgsPii(args);
5005
+ if (piiFound.length > 0) {
5006
+ const piiReason = `\u{1F512} PII DETECTED: ${piiFound.join(", ")} found in tool arguments. Remove or tokenize personal data before passing it to a tool.`;
5007
+ if (!isManual)
5008
+ appendLocalAudit(
5009
+ toolName,
5010
+ args,
5011
+ "deny",
5012
+ isObserveMode ? "observe-mode-pii-would-block" : "pii-block",
5013
+ { ...meta, piiPatterns: piiFound.join(",") },
5014
+ true
5015
+ );
5016
+ if (isObserveMode) {
5017
+ return {
5018
+ approved: true,
5019
+ checkedBy: "audit",
5020
+ observeWouldBlock: true,
5021
+ blockedByLabel: "\u{1F512} Node9 PII (Detected)"
5022
+ };
5023
+ }
5024
+ return {
5025
+ approved: false,
5026
+ reason: piiReason,
5027
+ blockedBy: "local-config",
5028
+ blockedByLabel: "\u{1F512} Node9 PII (Detected)"
5029
+ };
5030
+ }
5031
+ }
4680
5032
  if (isObserveMode) {
4681
5033
  if (!isIgnoredTool2(toolName)) {
4682
5034
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
@@ -4707,9 +5059,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4707
5059
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
4708
5060
  if (policyResult.decision === "review") {
4709
5061
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
4710
- if (approvers.cloud && creds?.apiKey) {
4711
- await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
4712
- }
4713
5062
  }
4714
5063
  }
4715
5064
  return { approved: true, checkedBy: "audit" };
@@ -4722,8 +5071,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4722
5071
  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?`;
4723
5072
  if (!isManual)
4724
5073
  appendLocalAudit(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
4725
- if (approvers.cloud && creds?.apiKey)
4726
- auditLocalAllow(toolName, args, "loop-detected", creds, meta, void 0, true);
4727
5074
  return {
4728
5075
  approved: false,
4729
5076
  reason,
@@ -4742,15 +5089,15 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4742
5089
  };
4743
5090
  }
4744
5091
  if (policyResult.decision === "allow") {
4745
- if (approvers.cloud && creds?.apiKey)
4746
- await auditLocalAllow(toolName, args, "local-policy", creds, meta, void 0, false, {
4747
- ruleName: policyResult.ruleName,
4748
- ruleDescription: policyResult.ruleDescription,
4749
- blockedByLabel: policyResult.blockedByLabel,
4750
- matchedField: policyResult.matchedField,
4751
- matchedWord: policyResult.matchedWord
4752
- });
4753
- if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta, hashAuditArgs);
5092
+ if (!isManual)
5093
+ appendLocalAudit(
5094
+ toolName,
5095
+ args,
5096
+ "allow",
5097
+ "local-policy",
5098
+ { ...meta, ruleName: policyResult.ruleName },
5099
+ hashAuditArgs
5100
+ );
4754
5101
  return { approved: true, checkedBy: "local-policy" };
4755
5102
  }
4756
5103
  if (policyResult.decision === "block") {
@@ -4783,23 +5130,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4783
5130
  { ...meta, ruleName: policyResult.ruleName },
4784
5131
  hashAuditArgs
4785
5132
  );
4786
- if (approvers.cloud && creds?.apiKey)
4787
- auditLocalAllow(
4788
- toolName,
4789
- args,
4790
- "smart-rule-block-override",
4791
- creds,
4792
- meta,
4793
- void 0,
4794
- false,
4795
- {
4796
- ruleName: policyResult.ruleName,
4797
- ruleDescription: policyResult.ruleDescription,
4798
- blockedByLabel: policyResult.blockedByLabel,
4799
- matchedField: policyResult.matchedField,
4800
- matchedWord: policyResult.matchedWord
4801
- }
4802
- );
4803
5133
  const baseLabel = policyResult.blockedByLabel || "Smart Rule";
4804
5134
  const OVERRIDE_PREFIX = "\u26A0\uFE0F Override block rule: ";
4805
5135
  if (!baseLabel.startsWith(OVERRIDE_PREFIX)) {
@@ -4824,14 +5154,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4824
5154
  { ...meta, ruleName: policyResult.ruleName },
4825
5155
  hashAuditArgs
4826
5156
  );
4827
- if (approvers.cloud && creds?.apiKey)
4828
- auditLocalAllow(toolName, args, "smart-rule-block", creds, meta, void 0, false, {
4829
- ruleName: policyResult.ruleName,
4830
- ruleDescription: policyResult.ruleDescription,
4831
- blockedByLabel: policyResult.blockedByLabel,
4832
- matchedField: policyResult.matchedField,
4833
- matchedWord: policyResult.matchedWord
4834
- });
4835
5157
  return {
4836
5158
  approved: false,
4837
5159
  reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
@@ -4860,8 +5182,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4860
5182
  if (policyRuleDescription) riskMetadata.ruleDescription = policyRuleDescription.slice(0, 200);
4861
5183
  const persistent = policyResult.ruleName ? null : getPersistentDecision(toolName);
4862
5184
  if (persistent === "allow") {
4863
- if (approvers.cloud && creds?.apiKey)
4864
- await auditLocalAllow(toolName, args, "persistent", creds, meta);
4865
5185
  if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta, hashAuditArgs);
4866
5186
  return { approved: true, checkedBy: "persistent" };
4867
5187
  }
@@ -4894,8 +5214,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4894
5214
  }
4895
5215
  }
4896
5216
  if (!taintWarning && getActiveTrustSession(toolName, args)) {
4897
- if (approvers.cloud && creds?.apiKey)
4898
- await auditLocalAllow(toolName, args, "trust", creds, meta);
4899
5217
  if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
4900
5218
  return { approved: true, checkedBy: "trust" };
4901
5219
  }
@@ -5140,7 +5458,12 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
5140
5458
  args,
5141
5459
  finalResult.approved ? "allow" : "deny",
5142
5460
  finalResult.checkedBy || finalResult.blockedBy || "unknown",
5143
- meta,
5461
+ // cloudRequestId links this row to the BE-origin AuditLog row the
5462
+ // /intercept handshake created — the shipper hands it to the SaaS so
5463
+ // the BE enriches that row instead of inserting a duplicate. Matters
5464
+ // for EVERY racer outcome, not just cloud wins: a native-popup
5465
+ // decision on a cloud-pending request would otherwise count twice.
5466
+ cloudRequestId ? { ...meta, cloudRequestId } : meta,
5144
5467
  hashAuditArgs
5145
5468
  );
5146
5469
  }