@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.js CHANGED
@@ -74,8 +74,13 @@ __export(audit_exports, {
74
74
  appendHookDebug: () => appendHookDebug,
75
75
  appendLocalAudit: () => appendLocalAudit,
76
76
  appendToLog: () => appendToLog,
77
+ buildArgsPreview: () => buildArgsPreview,
78
+ generateEventId: () => generateEventId,
77
79
  redactSecrets: () => redactSecrets
78
80
  });
81
+ function generateEventId() {
82
+ return `${Date.now().toString(36)}-${import_crypto2.default.randomBytes(6).toString("hex")}`;
83
+ }
79
84
  function isTestCall(toolName, args) {
80
85
  if (toolName !== "Bash" && toolName !== "bash") return false;
81
86
  const cmd = args?.command;
@@ -94,6 +99,17 @@ function redactSecrets(text) {
94
99
  );
95
100
  return redacted;
96
101
  }
102
+ function buildArgsPreview(args) {
103
+ try {
104
+ const o = args && typeof args === "object" ? args : null;
105
+ const primary = o && (o.command ?? o.file_path ?? o.path ?? o.url ?? o.query);
106
+ const text = typeof primary === "string" ? primary : args ? JSON.stringify(args) : "";
107
+ if (!text) return void 0;
108
+ return redactSecrets(text).slice(0, 120);
109
+ } catch {
110
+ return void 0;
111
+ }
112
+ }
97
113
  function appendToLog(logPath, entry) {
98
114
  try {
99
115
  const dir = import_path.default.dirname(logPath);
@@ -116,11 +132,18 @@ function appendHookDebug(toolName, args, meta, auditHashArgsEnabled) {
116
132
  });
117
133
  }
118
134
  function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
119
- const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args) } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
135
+ const isDlpRow = checkedBy.toLowerCase().includes("dlp") || Boolean(meta?.dlpPattern);
136
+ const preview = auditHashArgsEnabled && !isDlpRow ? buildArgsPreview(args) : void 0;
137
+ const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args), ...preview ? { argsPreview: preview } : {} } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
120
138
  const testRun = isTestCall(toolName, args) || process.env.NODE9_TESTING === "1" ? { testRun: true } : {};
121
139
  const ruleNameField = meta?.ruleName ? { ruleName: meta.ruleName } : {};
122
140
  const agentToolNameField = meta?.agentToolName ? { agentToolName: meta.agentToolName } : {};
141
+ const dlpFields = meta?.dlpPattern ? { dlpPattern: meta.dlpPattern, dlpSample: meta.dlpSample } : {};
142
+ const cloudLinkField = meta?.cloudRequestId ? { cloudRequestId: meta.cloudRequestId } : {};
123
143
  appendToLog(LOCAL_AUDIT_LOG, {
144
+ // eid first: the outbox shipper dedups on it, and a fixed leading field
145
+ // makes the JSONL easy to eyeball.
146
+ eid: generateEventId(),
124
147
  ts: (/* @__PURE__ */ new Date()).toISOString(),
125
148
  tool: toolName,
126
149
  ...agentToolNameField,
@@ -128,6 +151,8 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
128
151
  decision,
129
152
  checkedBy,
130
153
  ...ruleNameField,
154
+ ...dlpFields,
155
+ ...cloudLinkField,
131
156
  ...testRun,
132
157
  agent: meta?.agent,
133
158
  mcpServer: meta?.mcpServer,
@@ -142,13 +167,14 @@ function appendConfigAudit(entry) {
142
167
  hostname: import_os.default.hostname()
143
168
  });
144
169
  }
145
- var import_fs, import_path, import_os, LOCAL_AUDIT_LOG, HOOK_DEBUG_LOG, TEST_COMMAND_RE;
170
+ var import_fs, import_path, import_os, import_crypto2, LOCAL_AUDIT_LOG, HOOK_DEBUG_LOG, TEST_COMMAND_RE;
146
171
  var init_audit = __esm({
147
172
  "src/audit/index.ts"() {
148
173
  "use strict";
149
174
  import_fs = __toESM(require("fs"));
150
175
  import_path = __toESM(require("path"));
151
176
  import_os = __toESM(require("os"));
177
+ import_crypto2 = __toESM(require("crypto"));
152
178
  init_hasher();
153
179
  LOCAL_AUDIT_LOG = import_path.default.join(import_os.default.homedir(), ".node9", "audit.log");
154
180
  HOOK_DEBUG_LOG = import_path.default.join(import_os.default.homedir(), ".node9", "hook-debug.log");
@@ -246,7 +272,13 @@ var ConfigFileSchema = import_zod.z.object({
246
272
  allowGlobalPause: import_zod.z.boolean().optional(),
247
273
  auditHashArgs: import_zod.z.boolean().optional(),
248
274
  agentPolicy: import_zod.z.enum(["require_approval", "block_on_rules"]).optional(),
249
- cloudSyncIntervalHours: import_zod.z.number().positive().optional()
275
+ cloudSyncIntervalHours: import_zod.z.number().positive().optional(),
276
+ // Outbox shipper (audit.log → SaaS batch ingest). enabled defaults
277
+ // to true; set false to fall back to local-only auditing.
278
+ shipper: import_zod.z.object({
279
+ enabled: import_zod.z.boolean().optional(),
280
+ intervalSeconds: import_zod.z.number().min(5).optional()
281
+ }).optional()
250
282
  }).optional(),
251
283
  policy: import_zod.z.object({
252
284
  sandboxPaths: import_zod.z.array(import_zod.z.string()).optional(),
@@ -261,7 +293,15 @@ var ConfigFileSchema = import_zod.z.object({
261
293
  }).optional(),
262
294
  dlp: import_zod.z.object({
263
295
  enabled: import_zod.z.boolean().optional(),
264
- scanIgnoredTools: import_zod.z.boolean().optional()
296
+ scanIgnoredTools: import_zod.z.boolean().optional(),
297
+ pii: import_zod.z.enum(["off", "block"]).optional()
298
+ }).optional(),
299
+ egress: import_zod.z.object({
300
+ enabled: import_zod.z.boolean().optional(),
301
+ mode: import_zod.z.enum(["off", "review", "block"]).optional(),
302
+ allow: import_zod.z.array(import_zod.z.string()).optional(),
303
+ deny: import_zod.z.array(import_zod.z.string()).optional(),
304
+ allowPrivate: import_zod.z.boolean().optional()
265
305
  }).optional(),
266
306
  loopDetection: import_zod.z.object({
267
307
  enabled: import_zod.z.boolean().optional(),
@@ -314,7 +354,7 @@ var import_mvdan_sh = __toESM(require("mvdan-sh"), 1);
314
354
  var import_picomatch = __toESM(require("picomatch"), 1);
315
355
  var import_safe_regex22 = __toESM(require("safe-regex2"), 1);
316
356
  var import_safe_regex23 = __toESM(require("safe-regex2"), 1);
317
- var import_crypto2 = __toESM(require("crypto"), 1);
357
+ var import_crypto3 = __toESM(require("crypto"), 1);
318
358
  var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
319
359
  function isAssignmentContext(text) {
320
360
  return ASSIGNMENT_CONTEXT_RE.test(text);
@@ -1243,6 +1283,201 @@ function extractLiteralArgs(callExpr) {
1243
1283
  }
1244
1284
  return { name, flags, paths };
1245
1285
  }
1286
+ var NET_BINARIES = /* @__PURE__ */ new Set(["curl", "wget", "scp", "ssh", "nc", "ncat", "netcat"]);
1287
+ var VALUE_FLAGS = {
1288
+ curl: /* @__PURE__ */ new Set([
1289
+ "-d",
1290
+ "--data",
1291
+ "--data-ascii",
1292
+ "--data-binary",
1293
+ "--data-raw",
1294
+ "--data-urlencode",
1295
+ "-F",
1296
+ "--form",
1297
+ "-H",
1298
+ "--header",
1299
+ "-X",
1300
+ "--request",
1301
+ "-o",
1302
+ "--output",
1303
+ "-T",
1304
+ "--upload-file",
1305
+ "-u",
1306
+ "--user",
1307
+ "-e",
1308
+ "--referer",
1309
+ "-A",
1310
+ "--user-agent",
1311
+ "-b",
1312
+ "--cookie",
1313
+ "-c",
1314
+ "--cookie-jar",
1315
+ "--connect-to",
1316
+ "--resolve",
1317
+ "--cacert",
1318
+ "--cert",
1319
+ "--key",
1320
+ "-x",
1321
+ "--proxy",
1322
+ "-m",
1323
+ "--max-time",
1324
+ "--retry"
1325
+ ]),
1326
+ wget: /* @__PURE__ */ new Set([
1327
+ "-O",
1328
+ "--output-document",
1329
+ "--post-data",
1330
+ "--post-file",
1331
+ "--header",
1332
+ "-U",
1333
+ "--user-agent",
1334
+ "--user",
1335
+ "--password",
1336
+ "-o",
1337
+ "--output-file",
1338
+ "-P",
1339
+ "--directory-prefix",
1340
+ "-t",
1341
+ "--tries",
1342
+ "-T",
1343
+ "--timeout"
1344
+ ]),
1345
+ scp: /* @__PURE__ */ new Set(["-i", "-F", "-l", "-o", "-c", "-S", "-P", "-J", "-D", "-W"]),
1346
+ ssh: /* @__PURE__ */ new Set([
1347
+ "-i",
1348
+ "-p",
1349
+ "-o",
1350
+ "-l",
1351
+ "-F",
1352
+ "-c",
1353
+ "-L",
1354
+ "-R",
1355
+ "-D",
1356
+ "-W",
1357
+ "-b",
1358
+ "-e",
1359
+ "-m",
1360
+ "-O",
1361
+ "-Q",
1362
+ "-S",
1363
+ "-J",
1364
+ "-w",
1365
+ "-B",
1366
+ "-I",
1367
+ "-E"
1368
+ ]),
1369
+ nc: /* @__PURE__ */ new Set(["-p", "-s", "-w", "-X", "-x", "-e", "-g", "-G", "-i", "-O", "-T", "-q", "-m"])
1370
+ };
1371
+ function resolveWordLiteral(w) {
1372
+ const parts = w?.Parts || [];
1373
+ let s = "";
1374
+ for (const p of parts) {
1375
+ const t = syntax.NodeType(p);
1376
+ if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
1377
+ else if (t === "SglQuoted") s += p.Value ?? "";
1378
+ else if (t === "DblQuoted") {
1379
+ const inner = p.Parts || [];
1380
+ if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
1381
+ s += inner.map((ip) => ip.Value ?? "").join("");
1382
+ } else {
1383
+ return null;
1384
+ }
1385
+ }
1386
+ return s;
1387
+ }
1388
+ function parseDestHost(token) {
1389
+ if (!token) return null;
1390
+ let t = token.trim();
1391
+ if (!t || t.startsWith("-")) return null;
1392
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) {
1393
+ try {
1394
+ const h = new URL(t).hostname.toLowerCase();
1395
+ return h || null;
1396
+ } catch {
1397
+ return null;
1398
+ }
1399
+ }
1400
+ const at = t.lastIndexOf("@");
1401
+ if (at >= 0) t = t.slice(at + 1);
1402
+ t = t.split("/")[0];
1403
+ t = t.replace(/:\d+$/, "");
1404
+ t = t.split(":")[0];
1405
+ t = t.toLowerCase();
1406
+ if (t.length > 253) return null;
1407
+ if (t === "localhost") return t;
1408
+ if (/^[a-z0-9.-]+\.[a-z0-9.-]+$/.test(t)) return t;
1409
+ return null;
1410
+ }
1411
+ function destTokensForBinary(binary, args) {
1412
+ const valueFlags = VALUE_FLAGS[binary] ?? /* @__PURE__ */ new Set();
1413
+ const positionals = [];
1414
+ const urlFlagValues = [];
1415
+ for (let i = 0; i < args.length; i++) {
1416
+ const tok = args[i];
1417
+ if (tok === null) continue;
1418
+ if (tok.startsWith("-")) {
1419
+ if (tok.startsWith("--url=")) {
1420
+ urlFlagValues.push(tok.slice("--url=".length));
1421
+ continue;
1422
+ }
1423
+ if (tok === "--url") {
1424
+ const next = args[i + 1];
1425
+ if (typeof next === "string") urlFlagValues.push(next);
1426
+ i++;
1427
+ continue;
1428
+ }
1429
+ if (tok.includes("=")) continue;
1430
+ if (valueFlags.has(tok)) i++;
1431
+ continue;
1432
+ }
1433
+ positionals.push(tok);
1434
+ }
1435
+ switch (binary) {
1436
+ case "curl":
1437
+ case "wget":
1438
+ return [...urlFlagValues, ...positionals];
1439
+ case "ssh":
1440
+ return positionals.slice(0, 1);
1441
+ case "scp":
1442
+ return positionals.filter((p) => p.includes(":") || p.includes("@"));
1443
+ case "nc":
1444
+ case "ncat":
1445
+ case "netcat":
1446
+ return positionals.slice(0, 1);
1447
+ default:
1448
+ return [];
1449
+ }
1450
+ }
1451
+ function extractShellDestinations(command) {
1452
+ const f = parseShared(command);
1453
+ if (f === PARSE_FAIL) return [];
1454
+ const out = [];
1455
+ const seen = /* @__PURE__ */ new Set();
1456
+ try {
1457
+ syntax.Walk(f, (node) => {
1458
+ if (!node) return false;
1459
+ const n = node;
1460
+ if (syntax.NodeType(n) !== "CallExpr") return true;
1461
+ const callArgs = n.Args || [];
1462
+ if (callArgs.length === 0) return true;
1463
+ const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
1464
+ if (!NET_BINARIES.has(name)) return true;
1465
+ const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
1466
+ for (const raw of destTokensForBinary(name, rest)) {
1467
+ const host = parseDestHost(raw);
1468
+ if (!host) continue;
1469
+ const key = `${name}:${host}`;
1470
+ if (seen.has(key)) continue;
1471
+ seen.add(key);
1472
+ out.push({ host, binary: name, raw });
1473
+ }
1474
+ return true;
1475
+ });
1476
+ } catch {
1477
+ return out;
1478
+ }
1479
+ return out;
1480
+ }
1246
1481
  var FS_OP_CACHE_MAX = 5e3;
1247
1482
  var fsOpCache = /* @__PURE__ */ new Map();
1248
1483
  function analyzeFsOperation(command) {
@@ -1372,6 +1607,83 @@ function analyzeShellCommand(command) {
1372
1607
  }
1373
1608
  return { actions, paths, allTokens };
1374
1609
  }
1610
+ var DEFAULT_EGRESS_ALLOWLIST = [
1611
+ "*.github.com",
1612
+ "*.githubusercontent.com",
1613
+ "*.npmjs.org",
1614
+ "pypi.org",
1615
+ "*.pythonhosted.org",
1616
+ "crates.io",
1617
+ "*.crates.io",
1618
+ "rubygems.org",
1619
+ "proxy.golang.org",
1620
+ "sum.golang.org",
1621
+ "*.anthropic.com",
1622
+ "*.openai.com",
1623
+ "*.googleapis.com",
1624
+ "*.docker.io",
1625
+ "*.docker.com",
1626
+ "deb.debian.org",
1627
+ "*.ubuntu.com"
1628
+ ];
1629
+ function hostMatches(host, pattern) {
1630
+ const h = host.toLowerCase();
1631
+ const p = pattern.toLowerCase().trim();
1632
+ if (!p) return false;
1633
+ if (p === "*") return true;
1634
+ if (p.startsWith("*.")) {
1635
+ const suffix = p.slice(2);
1636
+ return h === suffix || h.endsWith("." + suffix);
1637
+ }
1638
+ return h === p;
1639
+ }
1640
+ function matchesAny(host, patterns) {
1641
+ for (const p of patterns) if (hostMatches(host, p)) return true;
1642
+ return false;
1643
+ }
1644
+ function isPrivateHost(host) {
1645
+ const h = host.toLowerCase();
1646
+ if (h === "localhost" || h === "0.0.0.0") return true;
1647
+ if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localhost")) return true;
1648
+ if (/^127\./.test(h)) return true;
1649
+ if (/^10\./.test(h)) return true;
1650
+ if (/^192\.168\./.test(h)) return true;
1651
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
1652
+ return false;
1653
+ }
1654
+ function evaluateEgress(dests, policy) {
1655
+ if (!policy.enabled) return null;
1656
+ let review = null;
1657
+ for (const d of dests) {
1658
+ if (matchesAny(d.host, policy.deny)) {
1659
+ return {
1660
+ verdict: "block",
1661
+ host: d.host,
1662
+ binary: d.binary,
1663
+ reason: `Egress to ${d.host} is on the deny list.`
1664
+ };
1665
+ }
1666
+ if (policy.allowPrivate && isPrivateHost(d.host)) continue;
1667
+ if (matchesAny(d.host, policy.allow) || matchesAny(d.host, DEFAULT_EGRESS_ALLOWLIST)) continue;
1668
+ if (policy.mode === "block") {
1669
+ return {
1670
+ verdict: "block",
1671
+ host: d.host,
1672
+ binary: d.binary,
1673
+ reason: `Egress to unknown host ${d.host} is blocked (egress policy: block).`
1674
+ };
1675
+ }
1676
+ if (policy.mode === "review" && !review) {
1677
+ review = {
1678
+ verdict: "review",
1679
+ host: d.host,
1680
+ binary: d.binary,
1681
+ reason: `${d.binary} is sending data to an unrecognized host (${d.host}). Approve this destination?`
1682
+ };
1683
+ }
1684
+ }
1685
+ return review;
1686
+ }
1375
1687
  var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
1376
1688
  "cat",
1377
1689
  "head",
@@ -1937,6 +2249,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
1937
2249
  }
1938
2250
  const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost2);
1939
2251
  if (ptVerdict) return ptVerdict;
2252
+ if (config.policy.egress?.enabled) {
2253
+ const dests = extractShellDestinations(shellCommand);
2254
+ if (dests.length > 0) {
2255
+ const eg = evaluateEgress(dests, config.policy.egress);
2256
+ if (eg) {
2257
+ return {
2258
+ decision: eg.verdict,
2259
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
2260
+ reason: eg.reason,
2261
+ ruleName: `egress:${eg.binary}:${eg.host}`,
2262
+ ruleDescription: eg.reason,
2263
+ tier: eg.verdict === "block" ? 3 : 4
2264
+ };
2265
+ }
2266
+ }
2267
+ }
1940
2268
  const firstToken = analyzed.actions[0] ?? "";
1941
2269
  if (["ssh", "scp", "rsync"].includes(firstToken)) {
1942
2270
  const rawTokens = shellCommand.trim().split(/\s+/);
@@ -2847,7 +3175,7 @@ assertBuiltinShieldRegexesAreSafe();
2847
3175
  var LOOP_MAX_RECORDS = 500;
2848
3176
  function computeArgsHash(args) {
2849
3177
  const str = JSON.stringify(args ?? "");
2850
- return import_crypto2.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
3178
+ return import_crypto3.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
2851
3179
  }
2852
3180
  function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
2853
3181
  const hash = computeArgsHash(args);
@@ -2858,6 +3186,32 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
2858
3186
  const nextRecords = fresh.slice(-LOOP_MAX_RECORDS);
2859
3187
  return { nextRecords, count, looping: count >= threshold };
2860
3188
  }
3189
+ var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3190
+ var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3191
+ var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3192
+ 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/;
3193
+ function detectPii(text) {
3194
+ const found = /* @__PURE__ */ new Set();
3195
+ if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
3196
+ if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
3197
+ if (PII_PHONE_RE.test(text)) found.add("Phone");
3198
+ if (PII_CC_RE.test(text)) found.add("Credit Card");
3199
+ return [...found];
3200
+ }
3201
+ var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
3202
+ var MAX_PII_SCAN_BYTES = 1e5;
3203
+ function detectArgsPii(args) {
3204
+ if (args === null || args === void 0) return [];
3205
+ let text;
3206
+ try {
3207
+ text = typeof args === "string" ? args : JSON.stringify(args);
3208
+ } catch {
3209
+ return [];
3210
+ }
3211
+ if (typeof text !== "string") return [];
3212
+ if (text.length > MAX_PII_SCAN_BYTES) text = text.slice(0, MAX_PII_SCAN_BYTES);
3213
+ return detectPii(text).filter((p) => REALTIME_PII_PATTERNS.includes(p));
3214
+ }
2861
3215
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
2862
3216
 
2863
3217
  // src/shields.ts
@@ -2960,7 +3314,8 @@ var DEFAULT_CONFIG = {
2960
3314
  flightRecorder: true,
2961
3315
  auditHashArgs: true,
2962
3316
  approvers: { native: true, browser: false, cloud: false, terminal: true },
2963
- cloudSyncIntervalHours: 5
3317
+ cloudSyncIntervalHours: 5,
3318
+ shipper: { enabled: true, intervalSeconds: 20 }
2964
3319
  },
2965
3320
  policy: {
2966
3321
  sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
@@ -3144,7 +3499,8 @@ var DEFAULT_CONFIG = {
3144
3499
  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."
3145
3500
  }
3146
3501
  ],
3147
- dlp: { enabled: true, scanIgnoredTools: true },
3502
+ dlp: { enabled: true, scanIgnoredTools: true, pii: "off" },
3503
+ egress: { enabled: false, mode: "review", allow: [], deny: [], allowPrivate: true },
3148
3504
  loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
3149
3505
  skillPinning: { enabled: false, mode: "warn", roots: [] }
3150
3506
  },
@@ -3255,7 +3611,8 @@ function getConfig(cwd) {
3255
3611
  const projectConfig = tryLoadConfig(projectPath);
3256
3612
  const mergedSettings = {
3257
3613
  ...DEFAULT_CONFIG.settings,
3258
- approvers: { ...DEFAULT_CONFIG.settings.approvers }
3614
+ approvers: { ...DEFAULT_CONFIG.settings.approvers },
3615
+ shipper: { ...DEFAULT_CONFIG.settings.shipper }
3259
3616
  };
3260
3617
  const mergedPolicy = {
3261
3618
  sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
@@ -3269,6 +3626,11 @@ function getConfig(cwd) {
3269
3626
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
3270
3627
  },
3271
3628
  dlp: { ...DEFAULT_CONFIG.policy.dlp },
3629
+ egress: {
3630
+ ...DEFAULT_CONFIG.policy.egress,
3631
+ allow: [...DEFAULT_CONFIG.policy.egress.allow],
3632
+ deny: [...DEFAULT_CONFIG.policy.egress.deny]
3633
+ },
3272
3634
  loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
3273
3635
  skillPinning: {
3274
3636
  ...DEFAULT_CONFIG.policy.skillPinning,
@@ -3286,6 +3648,7 @@ function getConfig(cwd) {
3286
3648
  if (s.enableHookLogDebug !== void 0)
3287
3649
  mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
3288
3650
  if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
3651
+ if (s.shipper) mergedSettings.shipper = { ...mergedSettings.shipper, ...s.shipper };
3289
3652
  if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
3290
3653
  if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
3291
3654
  mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
@@ -3318,6 +3681,15 @@ function getConfig(cwd) {
3318
3681
  const d = p.dlp;
3319
3682
  if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
3320
3683
  if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
3684
+ if (d.pii !== void 0) mergedPolicy.dlp.pii = d.pii;
3685
+ }
3686
+ if (p.egress) {
3687
+ const e = p.egress;
3688
+ if (e.enabled !== void 0) mergedPolicy.egress.enabled = e.enabled;
3689
+ if (e.mode !== void 0) mergedPolicy.egress.mode = e.mode;
3690
+ if (Array.isArray(e.allow)) mergedPolicy.egress.allow.push(...e.allow);
3691
+ if (Array.isArray(e.deny)) mergedPolicy.egress.deny.push(...e.deny);
3692
+ if (e.allowPrivate !== void 0) mergedPolicy.egress.allowPrivate = e.allowPrivate;
3321
3693
  }
3322
3694
  if (p.loopDetection) {
3323
3695
  const ld = p.loopDetection;
@@ -3948,7 +4320,7 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
3948
4320
  }
3949
4321
 
3950
4322
  // src/auth/orchestrator.ts
3951
- var import_crypto3 = require("crypto");
4323
+ var import_crypto4 = require("crypto");
3952
4324
 
3953
4325
  // src/ui/native.ts
3954
4326
  var import_child_process = require("child_process");
@@ -4060,6 +4432,15 @@ function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWo
4060
4432
  var isTestEnv = () => {
4061
4433
  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";
4062
4434
  };
4435
+ var MIN_INTERACTION_MS = 400;
4436
+ function resolveNativeDecision(opts) {
4437
+ const { code, output, elapsedMs, locked } = opts;
4438
+ if (locked) return "deny";
4439
+ const tooFast = elapsedMs < MIN_INTERACTION_MS;
4440
+ if (output.includes("Always Allow")) return tooFast ? "deny" : "always_allow";
4441
+ if (code === 0) return tooFast ? "deny" : "allow";
4442
+ return "deny";
4443
+ }
4063
4444
  function formatArgs(args, matchedField, matchedWord) {
4064
4445
  if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
4065
4446
  let parsed = args;
@@ -4203,6 +4584,7 @@ async function askNativePopup(toolName, args, agent, explainableLabel, locked =
4203
4584
  );
4204
4585
  return new Promise((resolve) => {
4205
4586
  let childProcess = null;
4587
+ const startedAt = Date.now();
4206
4588
  const onAbort = () => {
4207
4589
  if (childProcess && childProcess.pid) {
4208
4590
  try {
@@ -4260,13 +4642,14 @@ end run`;
4260
4642
  }
4261
4643
  let output = "";
4262
4644
  childProcess?.stdout?.on("data", (d) => output += d.toString());
4263
- childProcess?.on("close", (code) => {
4645
+ childProcess?.on("error", () => {
4264
4646
  if (signal) signal.removeEventListener("abort", onAbort);
4265
- if (locked) return resolve("deny");
4266
- if (output.includes("Always Allow")) return resolve("always_allow");
4267
- if (code === 0) return resolve("allow");
4268
4647
  resolve("deny");
4269
4648
  });
4649
+ childProcess?.on("close", (code) => {
4650
+ if (signal) signal.removeEventListener("abort", onAbort);
4651
+ resolve(resolveNativeDecision({ code, output, elapsedMs: Date.now() - startedAt, locked }));
4652
+ });
4270
4653
  } catch {
4271
4654
  resolve("deny");
4272
4655
  }
@@ -4281,91 +4664,6 @@ var import_fs9 = __toESM(require("fs"));
4281
4664
  var import_os8 = __toESM(require("os"));
4282
4665
  var import_path11 = __toESM(require("path"));
4283
4666
  init_audit();
4284
- var DLP_SAMPLE_MAX_LEN = 200;
4285
- var DLP_PATTERN_MAX_LEN = 100;
4286
- var KNOWN_CHECKED_BY = /* @__PURE__ */ new Set([
4287
- "dlp-block",
4288
- "observe-mode-dlp-would-block",
4289
- "dlp-review-flagged",
4290
- "loop-detected",
4291
- "audit-mode",
4292
- "local-policy",
4293
- "smart-rule-block",
4294
- // Smart-rule block was downgraded to review because the daemon was
4295
- // running and we're not in CI. The block attempt is still recorded;
4296
- // the user got a popup. Distinct from 'smart-rule-block' so the
4297
- // dashboard can show "block rule overridden" separately from a hard
4298
- // block that fired with no human in the loop.
4299
- "smart-rule-block-override",
4300
- "persistent",
4301
- "trust",
4302
- "observe-mode",
4303
- "observe-mode-would-block"
4304
- ]);
4305
- function validateApiUrl(raw) {
4306
- let u;
4307
- try {
4308
- u = new URL(raw);
4309
- } catch {
4310
- return null;
4311
- }
4312
- if (u.username || u.password) return null;
4313
- if (u.protocol === "https:") return u;
4314
- if (u.protocol === "http:") {
4315
- const h = u.hostname;
4316
- if (h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]") return u;
4317
- }
4318
- return null;
4319
- }
4320
- function auditLocalAllow(toolName, args, checkedBy, creds, meta, dlpInfo, containsSensitiveArgs = false, riskMetadata) {
4321
- const validated = validateApiUrl(creds.apiUrl);
4322
- if (!validated) {
4323
- try {
4324
- import_fs9.default.appendFileSync(
4325
- HOOK_DEBUG_LOG,
4326
- `[audit] refused to send: invalid apiUrl scheme/host (got "${String(creds.apiUrl).slice(0, 200)}")
4327
- `
4328
- );
4329
- } catch {
4330
- }
4331
- return Promise.resolve();
4332
- }
4333
- const safeArgs = containsSensitiveArgs ? { tool: toolName, redacted: true } : args;
4334
- const dlpSample = dlpInfo && typeof dlpInfo.redactedSample === "string" ? dlpInfo.redactedSample.slice(0, DLP_SAMPLE_MAX_LEN) : void 0;
4335
- const dlpPattern = dlpInfo && typeof dlpInfo.pattern === "string" ? dlpInfo.pattern.slice(0, DLP_PATTERN_MAX_LEN) : void 0;
4336
- const safeCheckedBy = KNOWN_CHECKED_BY.has(checkedBy) ? checkedBy : "unknown";
4337
- const cleanedRiskMetadata = riskMetadata ? Object.fromEntries(
4338
- Object.entries(riskMetadata).filter(([, v]) => typeof v === "string" && v.length > 0)
4339
- ) : void 0;
4340
- const hasRiskMetadata = cleanedRiskMetadata && Object.keys(cleanedRiskMetadata).length > 0;
4341
- return fetch(`${validated.toString().replace(/\/$/, "")}/audit`, {
4342
- method: "POST",
4343
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
4344
- body: JSON.stringify({
4345
- toolName,
4346
- args: safeArgs,
4347
- checkedBy: safeCheckedBy,
4348
- ...dlpInfo && { dlpPattern, dlpSample },
4349
- ...hasRiskMetadata && { riskMetadata: cleanedRiskMetadata },
4350
- // session_id (Claude Code + Gemini CLI) groups all audit rows from one
4351
- // agent run; transcript_path is the authoritative pointer to the
4352
- // session log (survives Gemini resume drift). Both optional —
4353
- // unsupported agents (MCP-mediated) leave them undefined.
4354
- ...meta?.sessionId && { runId: meta.sessionId },
4355
- ...meta?.transcriptPath && { transcriptPath: meta.transcriptPath },
4356
- context: {
4357
- agent: meta?.agent,
4358
- mcpServer: meta?.mcpServer,
4359
- hostname: import_os8.default.hostname(),
4360
- cwd: process.cwd(),
4361
- platform: import_os8.default.platform()
4362
- }
4363
- }),
4364
- signal: AbortSignal.timeout(5e3)
4365
- }).then(() => {
4366
- }).catch(() => {
4367
- });
4368
- }
4369
4667
  async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
4370
4668
  const controller = new AbortController();
4371
4669
  const timeout = setTimeout(() => controller.abort(), 1e4);
@@ -4576,7 +4874,7 @@ function notifyActivity(data) {
4576
4874
  }
4577
4875
  async function authorizeHeadless(toolName, args, meta, options) {
4578
4876
  if (!options?.calledFromDaemon) {
4579
- const actId = (0, import_crypto3.randomUUID)();
4877
+ const actId = (0, import_crypto4.randomUUID)();
4580
4878
  const actTs = Date.now();
4581
4879
  const stripAnsi = (s) => s.replace(/\x1b(?:\[[0-9;?]*[a-zA-Z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])/g, "");
4582
4880
  const sanitizedAgent = meta?.agent ? stripAnsi(meta.agent).slice(0, 80) : void 0;
@@ -4653,6 +4951,37 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4653
4951
  if (taintResult.tainted && taintResult.record) {
4654
4952
  const { path: taintedPath, source: taintSource } = taintResult.record;
4655
4953
  taintWarning = `\u26A0\uFE0F ${taintedPath} was flagged by ${taintSource} \u2014 this file may contain sensitive data`;
4954
+ if (config.policy.egress?.enabled) {
4955
+ const a = args && typeof args === "object" && !Array.isArray(args) ? args : {};
4956
+ const cmd = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : "";
4957
+ const dests = cmd ? extractShellDestinations(cmd) : [];
4958
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
4959
+ if (eg) {
4960
+ if (!isManual)
4961
+ appendLocalAudit(
4962
+ toolName,
4963
+ args,
4964
+ "deny",
4965
+ isObserveMode ? "observe-mode-taint-egress-would-block" : "taint-egress-block",
4966
+ { ...meta, ruleName: `taint-egress:${eg.host}` },
4967
+ hashAuditArgs
4968
+ );
4969
+ if (isObserveMode) {
4970
+ return {
4971
+ approved: true,
4972
+ checkedBy: "audit",
4973
+ observeWouldBlock: true,
4974
+ blockedByLabel: "\u{1F534} Node9 Taint+Egress (Exfiltration)"
4975
+ };
4976
+ }
4977
+ return {
4978
+ approved: false,
4979
+ 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.`,
4980
+ blockedBy: "local-config",
4981
+ blockedByLabel: "\u{1F534} Node9 Taint+Egress (Exfiltration Blocked)"
4982
+ };
4983
+ }
4984
+ }
4656
4985
  } else if (taintResult.daemonUnavailable) {
4657
4986
  taintWarning = `\u26A0\uFE0F Taint service unavailable \u2014 cannot verify if ${filePaths.join(", ")} is clean`;
4658
4987
  }
@@ -4671,17 +5000,11 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4671
5000
  args,
4672
5001
  "deny",
4673
5002
  isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
4674
- meta,
4675
- true
4676
- );
4677
- if (approvers.cloud && creds?.apiKey)
4678
- auditLocalAllow(
4679
- toolName,
4680
- args,
4681
- isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
4682
- creds,
4683
- meta,
4684
- { pattern: dlpMatch.patternName, redactedSample: dlpMatch.redactedSample },
5003
+ {
5004
+ ...meta,
5005
+ dlpPattern: dlpMatch.patternName,
5006
+ dlpSample: dlpMatch.redactedSample
5007
+ },
4685
5008
  true
4686
5009
  );
4687
5010
  if (isWriteTool(toolName) && filePath) {
@@ -4707,6 +5030,35 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4707
5030
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
4708
5031
  }
4709
5032
  }
5033
+ if (config.policy.dlp.pii === "block" && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
5034
+ const piiFound = detectArgsPii(args);
5035
+ if (piiFound.length > 0) {
5036
+ const piiReason = `\u{1F512} PII DETECTED: ${piiFound.join(", ")} found in tool arguments. Remove or tokenize personal data before passing it to a tool.`;
5037
+ if (!isManual)
5038
+ appendLocalAudit(
5039
+ toolName,
5040
+ args,
5041
+ "deny",
5042
+ isObserveMode ? "observe-mode-pii-would-block" : "pii-block",
5043
+ { ...meta, piiPatterns: piiFound.join(",") },
5044
+ true
5045
+ );
5046
+ if (isObserveMode) {
5047
+ return {
5048
+ approved: true,
5049
+ checkedBy: "audit",
5050
+ observeWouldBlock: true,
5051
+ blockedByLabel: "\u{1F512} Node9 PII (Detected)"
5052
+ };
5053
+ }
5054
+ return {
5055
+ approved: false,
5056
+ reason: piiReason,
5057
+ blockedBy: "local-config",
5058
+ blockedByLabel: "\u{1F512} Node9 PII (Detected)"
5059
+ };
5060
+ }
5061
+ }
4710
5062
  if (isObserveMode) {
4711
5063
  if (!isIgnoredTool2(toolName)) {
4712
5064
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
@@ -4737,9 +5089,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4737
5089
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
4738
5090
  if (policyResult.decision === "review") {
4739
5091
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
4740
- if (approvers.cloud && creds?.apiKey) {
4741
- await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
4742
- }
4743
5092
  }
4744
5093
  }
4745
5094
  return { approved: true, checkedBy: "audit" };
@@ -4752,8 +5101,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4752
5101
  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?`;
4753
5102
  if (!isManual)
4754
5103
  appendLocalAudit(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
4755
- if (approvers.cloud && creds?.apiKey)
4756
- auditLocalAllow(toolName, args, "loop-detected", creds, meta, void 0, true);
4757
5104
  return {
4758
5105
  approved: false,
4759
5106
  reason,
@@ -4772,15 +5119,15 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4772
5119
  };
4773
5120
  }
4774
5121
  if (policyResult.decision === "allow") {
4775
- if (approvers.cloud && creds?.apiKey)
4776
- await auditLocalAllow(toolName, args, "local-policy", creds, meta, void 0, false, {
4777
- ruleName: policyResult.ruleName,
4778
- ruleDescription: policyResult.ruleDescription,
4779
- blockedByLabel: policyResult.blockedByLabel,
4780
- matchedField: policyResult.matchedField,
4781
- matchedWord: policyResult.matchedWord
4782
- });
4783
- if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta, hashAuditArgs);
5122
+ if (!isManual)
5123
+ appendLocalAudit(
5124
+ toolName,
5125
+ args,
5126
+ "allow",
5127
+ "local-policy",
5128
+ { ...meta, ruleName: policyResult.ruleName },
5129
+ hashAuditArgs
5130
+ );
4784
5131
  return { approved: true, checkedBy: "local-policy" };
4785
5132
  }
4786
5133
  if (policyResult.decision === "block") {
@@ -4813,23 +5160,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4813
5160
  { ...meta, ruleName: policyResult.ruleName },
4814
5161
  hashAuditArgs
4815
5162
  );
4816
- if (approvers.cloud && creds?.apiKey)
4817
- auditLocalAllow(
4818
- toolName,
4819
- args,
4820
- "smart-rule-block-override",
4821
- creds,
4822
- meta,
4823
- void 0,
4824
- false,
4825
- {
4826
- ruleName: policyResult.ruleName,
4827
- ruleDescription: policyResult.ruleDescription,
4828
- blockedByLabel: policyResult.blockedByLabel,
4829
- matchedField: policyResult.matchedField,
4830
- matchedWord: policyResult.matchedWord
4831
- }
4832
- );
4833
5163
  const baseLabel = policyResult.blockedByLabel || "Smart Rule";
4834
5164
  const OVERRIDE_PREFIX = "\u26A0\uFE0F Override block rule: ";
4835
5165
  if (!baseLabel.startsWith(OVERRIDE_PREFIX)) {
@@ -4854,14 +5184,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4854
5184
  { ...meta, ruleName: policyResult.ruleName },
4855
5185
  hashAuditArgs
4856
5186
  );
4857
- if (approvers.cloud && creds?.apiKey)
4858
- auditLocalAllow(toolName, args, "smart-rule-block", creds, meta, void 0, false, {
4859
- ruleName: policyResult.ruleName,
4860
- ruleDescription: policyResult.ruleDescription,
4861
- blockedByLabel: policyResult.blockedByLabel,
4862
- matchedField: policyResult.matchedField,
4863
- matchedWord: policyResult.matchedWord
4864
- });
4865
5187
  return {
4866
5188
  approved: false,
4867
5189
  reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
@@ -4890,8 +5212,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4890
5212
  if (policyRuleDescription) riskMetadata.ruleDescription = policyRuleDescription.slice(0, 200);
4891
5213
  const persistent = policyResult.ruleName ? null : getPersistentDecision(toolName);
4892
5214
  if (persistent === "allow") {
4893
- if (approvers.cloud && creds?.apiKey)
4894
- await auditLocalAllow(toolName, args, "persistent", creds, meta);
4895
5215
  if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta, hashAuditArgs);
4896
5216
  return { approved: true, checkedBy: "persistent" };
4897
5217
  }
@@ -4924,8 +5244,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
4924
5244
  }
4925
5245
  }
4926
5246
  if (!taintWarning && getActiveTrustSession(toolName, args)) {
4927
- if (approvers.cloud && creds?.apiKey)
4928
- await auditLocalAllow(toolName, args, "trust", creds, meta);
4929
5247
  if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
4930
5248
  return { approved: true, checkedBy: "trust" };
4931
5249
  }
@@ -5170,7 +5488,12 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
5170
5488
  args,
5171
5489
  finalResult.approved ? "allow" : "deny",
5172
5490
  finalResult.checkedBy || finalResult.blockedBy || "unknown",
5173
- meta,
5491
+ // cloudRequestId links this row to the BE-origin AuditLog row the
5492
+ // /intercept handshake created — the shipper hands it to the SaaS so
5493
+ // the BE enriches that row instead of inserting a duplicate. Matters
5494
+ // for EVERY racer outcome, not just cloud wins: a native-popup
5495
+ // decision on a cloud-pending request would otherwise count twice.
5496
+ cloudRequestId ? { ...meta, cloudRequestId } : meta,
5174
5497
  hashAuditArgs
5175
5498
  );
5176
5499
  }