@node9/proxy 2.31.3 → 2.33.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
@@ -58,6 +58,7 @@ import safeRegex3 from "safe-regex2";
58
58
  import { createHash as createHash2 } from "crypto";
59
59
  import { createHash as createHash22 } from "crypto";
60
60
  import mvdanSh3 from "mvdan-sh";
61
+ import mvdanSh4 from "mvdan-sh";
61
62
  import crypto from "crypto";
62
63
  function isShieldVerdict(v) {
63
64
  return v === "allow" || v === "review" || v === "block";
@@ -207,10 +208,10 @@ function compressIpv6(g) {
207
208
  if (bestLen < 2) return hex.join(":");
208
209
  return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
209
210
  }
210
- function normalizeIpLiteral(host) {
211
+ function normalizeIpLiteral(host2) {
211
212
  try {
212
- if (typeof host !== "string") return null;
213
- let s = host.trim();
213
+ if (typeof host2 !== "string") return null;
214
+ let s = host2.trim();
214
215
  if (!s || s.length > SSRF_MAX_HOST) return null;
215
216
  if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
216
217
  const zone = s.indexOf("%");
@@ -236,11 +237,11 @@ function isStrictGatedTier(tier) {
236
237
  function ssrfReason(m, asWritten) {
237
238
  return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
238
239
  }
239
- function classifySsrf(host) {
240
+ function classifySsrf(host2) {
240
241
  try {
241
- if (typeof host !== "string" || !host) return null;
242
- const lower2 = host.trim().toLowerCase().replace(/\.$/, "");
243
- const ip = normalizeIpLiteral(host);
242
+ if (typeof host2 !== "string" || !host2) return null;
243
+ const lower2 = host2.trim().toLowerCase().replace(/\.$/, "");
244
+ const ip = normalizeIpLiteral(host2);
244
245
  if (ip === null) {
245
246
  return METADATA_HOSTNAMES.has(lower2) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
246
247
  }
@@ -442,6 +443,7 @@ function catalogSettingsFromConfig(settings, policy) {
442
443
  injectionScan: policy.injectionScan,
443
444
  skillPinning: policy.skillPinning,
444
445
  packageCheck: policy.packageCheck,
446
+ untrustedContent: policy.untrustedContent,
445
447
  appliedShields: policy.appliedShields ?? [],
446
448
  checks: policy.checks
447
449
  };
@@ -949,11 +951,11 @@ function inlineExecStmt(stmt, pipeFed) {
949
951
  const firstFlag = args.findIndex((a) => a == null || a.startsWith("-"));
950
952
  args = firstFlag >= 0 ? args.slice(firstFlag) : [];
951
953
  }
952
- let positionals = 0;
954
+ let positionals2 = 0;
953
955
  let selectedProgram = false;
954
956
  for (const a of args) {
955
957
  if (a == null) {
956
- positionals++;
958
+ positionals2++;
957
959
  selectedProgram = true;
958
960
  continue;
959
961
  }
@@ -964,13 +966,13 @@ function inlineExecStmt(stmt, pipeFed) {
964
966
  }
965
967
  if (a === "-" && !selectedProgram) return true;
966
968
  if (!a.startsWith("-")) {
967
- positionals++;
969
+ positionals2++;
968
970
  selectedProgram = true;
969
971
  }
970
972
  }
971
973
  const redirs = stmt.Redirs || cmd.Redirs || [];
972
974
  const stdinFed = redirs.some((r) => r && redirStdinOps().has(r.Op));
973
- if (positionals === 0 && (stdinFed || pipeFed)) {
975
+ if (positionals2 === 0 && (stdinFed || pipeFed)) {
974
976
  if (!/^(bash|sh|zsh)$/i.test(interp)) return true;
975
977
  }
976
978
  return false;
@@ -1180,6 +1182,24 @@ function resolveWordLiteral(w) {
1180
1182
  }
1181
1183
  return s;
1182
1184
  }
1185
+ function resolveWordPrefix(w) {
1186
+ const walk2 = (parts) => {
1187
+ let s = "";
1188
+ for (const p of parts) {
1189
+ if (p && typeof p === "object" && Array.isArray(p.Parts) && p.Dollar !== void 0) {
1190
+ const inner = walk2(p.Parts);
1191
+ s += inner.prefix;
1192
+ if (inner.dynamic) return { prefix: s, dynamic: true };
1193
+ continue;
1194
+ }
1195
+ const piece = resolvePart(p, false);
1196
+ if (piece === void 0 || piece === null) return { prefix: s, dynamic: true };
1197
+ s += piece;
1198
+ }
1199
+ return { prefix: s, dynamic: false };
1200
+ };
1201
+ return walk2(w?.Parts || []);
1202
+ }
1183
1203
  function resolvePart(p, inQuotes) {
1184
1204
  const t = syntax.NodeType(p);
1185
1205
  if (t === "Lit") {
@@ -1227,7 +1247,7 @@ function parseDestHost(token) {
1227
1247
  }
1228
1248
  function destTokensForBinary(binary, args) {
1229
1249
  const valueFlags = VALUE_FLAGS[binary] ?? /* @__PURE__ */ new Set();
1230
- const positionals = [];
1250
+ const positionals2 = [];
1231
1251
  const urlFlagValues = [];
1232
1252
  for (let i = 0; i < args.length; i++) {
1233
1253
  const tok = args[i];
@@ -1247,21 +1267,21 @@ function destTokensForBinary(binary, args) {
1247
1267
  if (valueFlags.has(tok)) i++;
1248
1268
  continue;
1249
1269
  }
1250
- positionals.push(tok);
1270
+ positionals2.push(tok);
1251
1271
  }
1252
1272
  switch (binary) {
1253
1273
  case "curl":
1254
1274
  case "wget":
1255
- return [...urlFlagValues, ...positionals];
1275
+ return [...urlFlagValues, ...positionals2];
1256
1276
  case "ssh":
1257
- return positionals.slice(0, 1);
1277
+ return positionals2.slice(0, 1);
1258
1278
  case "scp":
1259
1279
  case "rsync":
1260
- return positionals.filter((p) => p.includes(":") || p.includes("@"));
1280
+ return positionals2.filter((p) => p.includes(":") || p.includes("@"));
1261
1281
  case "nc":
1262
1282
  case "ncat":
1263
1283
  case "netcat":
1264
- return positionals.slice(0, 1);
1284
+ return positionals2.slice(0, 1);
1265
1285
  default:
1266
1286
  return [];
1267
1287
  }
@@ -1278,16 +1298,18 @@ function extractShellDestinations(command) {
1278
1298
  if (syntax.NodeType(n) !== "CallExpr") return true;
1279
1299
  const callArgs = n.Args || [];
1280
1300
  if (callArgs.length === 0) return true;
1281
- const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
1301
+ const words = callArgs.map((a) => resolveWordLiteral(a));
1302
+ const at = unwrapCommandHead(words);
1303
+ const name = (words[at] || "").toLowerCase().split("/").pop() ?? "";
1282
1304
  if (!NET_BINARIES.has(name)) return true;
1283
- const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
1305
+ const rest = words.slice(at + 1);
1284
1306
  for (const raw of destTokensForBinary(name, rest)) {
1285
- const host = parseDestHost(raw);
1286
- if (!host) continue;
1287
- const key = `${name}:${host}`;
1307
+ const host2 = parseDestHost(raw);
1308
+ if (!host2) continue;
1309
+ const key = `${name}:${host2}`;
1288
1310
  if (seen.has(key)) continue;
1289
1311
  seen.add(key);
1290
- out.push({ host, binary: name, raw });
1312
+ out.push({ host: host2, binary: name, raw });
1291
1313
  }
1292
1314
  return true;
1293
1315
  });
@@ -2047,9 +2069,9 @@ function matchesPattern(text, patterns) {
2047
2069
  const withoutDotSlash = text.replace(/^\.\//, "");
2048
2070
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
2049
2071
  }
2050
- function getNestedValue(obj, path20) {
2072
+ function getNestedValue(obj, path26) {
2051
2073
  if (!obj || typeof obj !== "object") return null;
2052
- const segments2 = path20.split(".");
2074
+ const segments2 = path26.split(".");
2053
2075
  for (const seg of segments2) {
2054
2076
  if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
2055
2077
  }
@@ -2125,32 +2147,109 @@ function evaluateSmartConditions(args, rule) {
2125
2147
  function isBashTool(tool) {
2126
2148
  return BASH_TOOL_NAMES.has(tool.toLowerCase());
2127
2149
  }
2128
- function inspectionEntry(tool, inspection = {}) {
2150
+ function inspectionLayers(policy) {
2151
+ return { trusted: policy.toolInspection, project: policy.projectToolInspection };
2152
+ }
2153
+ function isShellEntry(value) {
2154
+ return typeof value === "object" && value !== null && value.kind === "shell" && typeof value.field === "string" && Object.keys(value).length === 2;
2155
+ }
2156
+ function isInspectionLayers(input) {
2157
+ const trusted = input.trusted;
2158
+ return typeof trusted === "object" && trusted !== null && !isShellEntry(trusted);
2159
+ }
2160
+ function layersOf(input) {
2161
+ return isInspectionLayers(input) ? { trusted: input.trusted, project: input.project ?? {} } : { trusted: input, project: {} };
2162
+ }
2163
+ function entryOf(tool, inspection) {
2129
2164
  const pattern = Object.keys(inspection).find((p) => matchesPattern(tool, p));
2130
2165
  return pattern === void 0 ? void 0 : inspection[pattern];
2131
2166
  }
2167
+ function inspectionEntry(tool, inspection = {}) {
2168
+ const { trusted, project } = layersOf(inspection);
2169
+ return entryOf(tool, trusted) ?? entryOf(tool, project);
2170
+ }
2171
+ function executorName(tool) {
2172
+ const name = tool.toLowerCase();
2173
+ if (!name.startsWith("mcp__")) return name;
2174
+ const at = name.lastIndexOf("__");
2175
+ return at >= "mcp__".length ? name.slice(at + 2) : name;
2176
+ }
2177
+ function entryFields(entry) {
2178
+ if (typeof entry === "object") return [entry.field];
2179
+ return entry === "command" ? ["command"] : null;
2180
+ }
2132
2181
  function shellFields(tool, inspection) {
2133
2182
  if (isBashTool(tool)) return ["command"];
2134
- const entry = inspectionEntry(tool, inspection);
2135
- if (typeof entry === "object") return [entry.field];
2136
- if (entry !== void 0) return entry === "command" ? [entry] : null;
2137
- const name = tool.toLowerCase().replace(/^mcp__[a-z0-9_-]+?__/, "");
2138
- return MCP_EXECUTORS.has(name) ? ["command", "cmd", "script"] : null;
2183
+ const { trusted, project } = layersOf(inspection);
2184
+ const trustedEntry = entryOf(tool, trusted);
2185
+ if (trustedEntry !== void 0) return entryFields(trustedEntry);
2186
+ const builtin = MCP_EXECUTORS.has(executorName(tool)) ? BUILTIN_FIELDS : null;
2187
+ const projectEntry = entryOf(tool, project);
2188
+ if (projectEntry === void 0) return builtin;
2189
+ const added = entryFields(projectEntry);
2190
+ if (!builtin) return added;
2191
+ if (!added) return builtin;
2192
+ return [.../* @__PURE__ */ new Set([...builtin, ...added])];
2139
2193
  }
2140
2194
  function isShellShapedTool(tool, inspection = {}) {
2141
2195
  return shellFields(tool, inspection) !== null;
2142
2196
  }
2197
+ function shellWord(word) {
2198
+ if (BARE_WORD.test(word)) return word;
2199
+ return word === "" ? "''" : `'${word.replace(/'/g, `'\\''`)}'`;
2200
+ }
2201
+ function isStringArray(value) {
2202
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
2203
+ }
2204
+ function commandLine(args, fieldPath, value, native) {
2205
+ const missing = { reason: "Shell command field is missing or is not a string" };
2206
+ const unreadable = { reason: "Shell command arguments could not be read" };
2207
+ if (typeof value !== "string" && (native || !Array.isArray(value))) return missing;
2208
+ const dot = fieldPath.lastIndexOf(".");
2209
+ const parent = dot === -1 ? args : getNestedValue(args, fieldPath.slice(0, dot));
2210
+ const siblings = ARGV_FIELDS.map((key) => ({
2211
+ key,
2212
+ value: parent && typeof parent === "object" ? parent[key] : void 0
2213
+ })).filter(({ value: value2 }) => value2 !== void 0 && value2 !== null);
2214
+ let argv = siblings[0];
2215
+ if (native) argv = siblings.find(({ value: v }) => isStringArray(v));
2216
+ else if (siblings.length === 2 && JSON.stringify(siblings[0].value) !== JSON.stringify(siblings[1].value))
2217
+ return { reason: "Shell command arguments disagree" };
2218
+ let command;
2219
+ let source = fieldPath;
2220
+ if (Array.isArray(value)) {
2221
+ if (argv || !value.length || !isStringArray(value)) return unreadable;
2222
+ command = value.map(shellWord).join(" ");
2223
+ } else if (!argv) {
2224
+ return { command: value, fieldPath };
2225
+ } else {
2226
+ if (!isStringArray(argv.value)) return unreadable;
2227
+ if (!argv.value.length) return { command: value, fieldPath };
2228
+ command = `${value} ${argv.value.map(shellWord).join(" ")}`;
2229
+ source = `${fieldPath}+${argv.key}`;
2230
+ }
2231
+ if (command.length > MAX_ARGV_CHARACTERS)
2232
+ return { reason: "Shell command arguments exceed the inspectable size" };
2233
+ return { command, fieldPath: source };
2234
+ }
2143
2235
  function resolveShellInput(tool, args, inspection = {}) {
2144
2236
  const fields = shellFields(tool, inspection);
2145
2237
  if (!fields) return { kind: "not-shell" };
2146
2238
  const values = fields.map((fieldPath) => ({ fieldPath, value: getNestedValue(args, fieldPath) })).filter(({ value }) => value !== void 0 && value !== null && value !== "");
2147
- if (!values.length || values.some(({ value }) => typeof value !== "string")) {
2239
+ if (!values.length) {
2148
2240
  return { kind: "uninspectable", reason: "Shell command field is missing or is not a string" };
2149
2241
  }
2150
- if (new Set(values.map(({ value }) => value)).size !== 1) {
2242
+ const native = isBashTool(tool);
2243
+ const lines = values.map(({ fieldPath, value }) => commandLine(args, fieldPath, value, native));
2244
+ const failed = lines.find((line) => "reason" in line);
2245
+ if (failed) return { kind: "uninspectable", reason: failed.reason };
2246
+ const commands = lines.filter(
2247
+ (line) => "command" in line
2248
+ );
2249
+ if (new Set(commands.map(({ command }) => command)).size !== 1) {
2151
2250
  return { kind: "uninspectable", reason: "Multiple shell command fields disagree" };
2152
2251
  }
2153
- return { kind: "shell", command: values[0].value, fieldPath: values[0].fieldPath };
2252
+ return { kind: "shell", ...commands[0] };
2154
2253
  }
2155
2254
  function shellRuleArgs(tool, args, ruleTool, inspection = {}) {
2156
2255
  if (!ruleTool || !matchesPattern("bash", ruleTool) || matchesPattern(tool, ruleTool)) return args;
@@ -2376,7 +2475,7 @@ function collectShellFileAccesses(command, context = {}) {
2376
2475
  if (!hasScript) hasScript = true;
2377
2476
  else files.push(token);
2378
2477
  }
2379
- for (const file of files) add(inplace ? "write" : "read", file, state);
2478
+ for (const file2 of files) add(inplace ? "write" : "read", file2, state);
2380
2479
  return;
2381
2480
  }
2382
2481
  if (FS_READ_TOOLS.has(name) && name !== "dd") {
@@ -2728,6 +2827,17 @@ function collectShellFileAccesses(command, context = {}) {
2728
2827
  if (program) statements(program.Stmts ?? [], initial, 0);
2729
2828
  return out;
2730
2829
  }
2830
+ function isAgentInstructionPath(absPath, root) {
2831
+ const r = root.replace(/\/$/, "");
2832
+ if (!(absPath === r || absPath.startsWith(r + "/"))) return false;
2833
+ const rel = absPath.slice(r.length + 1);
2834
+ if (AGENT_INSTRUCTION_FILES.includes(rel)) return true;
2835
+ return AGENT_INSTRUCTION_DIRS.some((d) => rel === d || rel.startsWith(d + "/"));
2836
+ }
2837
+ function isInside(root, p) {
2838
+ const r = root.replace(/\/$/, "") || "/";
2839
+ return p === r || p.startsWith(r === "/" ? "/" : r + "/");
2840
+ }
2731
2841
  function isPasswordHashPath(p) {
2732
2842
  return PASSWORD_HASH_FILES.includes(p);
2733
2843
  }
@@ -2797,8 +2907,8 @@ function collectFileAccesses(tool, args, context = {}, inspection = {}) {
2797
2907
  }
2798
2908
  return out;
2799
2909
  }
2800
- function within(path20, root) {
2801
- return path20 === root || path20.startsWith(root === "/" ? "/" : root + "/");
2910
+ function within(path26, root) {
2911
+ return path26 === root || path26.startsWith(root === "/" ? "/" : root + "/");
2802
2912
  }
2803
2913
  function hits(access, paths, directories, context) {
2804
2914
  const home = context.homeDirs?.[0] ?? "~";
@@ -2880,20 +2990,25 @@ function evaluateProtectedFileAccess(accesses, context, settings) {
2880
2990
  }
2881
2991
  return best;
2882
2992
  }
2883
- function findMnemonic(text, budget = { candidates: 0, reasons: /* @__PURE__ */ new Set() }) {
2884
- const words = [...text.matchAll(/[a-zA-Z]+/g)];
2993
+ function findMnemonic(text, budget = { hashes: 0, reasons: /* @__PURE__ */ new Set() }) {
2885
2994
  let run = [];
2995
+ let runIndex = [];
2886
2996
  let previousEnd = 0;
2887
2997
  const rejected = /* @__PURE__ */ new Set();
2888
2998
  const inspect = () => {
2999
+ if (run.length < 12) return false;
3000
+ const ascending = new Array(run.length).fill(0);
3001
+ for (let i = 1; i < run.length; i++)
3002
+ ascending[i] = ascending[i - 1] + (runIndex[i] > runIndex[i - 1] ? 1 : 0);
2889
3003
  for (let start = 0; start + 12 <= run.length; start++) {
2890
3004
  for (const count of lengths) {
2891
3005
  if (start + count > run.length) continue;
3006
+ if (ascending[start + count - 1] - ascending[start] === count - 1) continue;
2892
3007
  const candidate = run.slice(start, start + count);
2893
3008
  const sentence = candidate.join(" ");
2894
3009
  if (rejected.has(sentence)) continue;
2895
- if (++budget.candidates > 1024) {
2896
- budget.reasons.add("mnemonic-candidates");
3010
+ if (++budget.hashes > MAX_MNEMONIC_HASHES) {
3011
+ budget.reasons.add("mnemonic-budget");
2897
3012
  return false;
2898
3013
  }
2899
3014
  if (BIP39_PUBLIC_VECTORS.has(hash(sentence).toString("hex"))) {
@@ -2911,13 +3026,18 @@ function findMnemonic(text, budget = { candidates: 0, reasons: /* @__PURE__ */ n
2911
3026
  }
2912
3027
  return false;
2913
3028
  };
2914
- for (const word of words) {
2915
- if (!indices.has(word[0]) || run.length && !/^\s+$/.test(text.slice(previousEnd, word.index))) {
3029
+ for (const word of text.matchAll(/[a-zA-Z]+/g)) {
3030
+ const index = indices.get(word[0]);
3031
+ if (index === void 0 || run.length && !/^\s+$/.test(text.slice(previousEnd, word.index))) {
2916
3032
  if (inspect()) return true;
2917
- if (budget.reasons.has("mnemonic-candidates")) return false;
3033
+ if (budget.reasons.has("mnemonic-budget")) return false;
2918
3034
  run = [];
3035
+ runIndex = [];
3036
+ }
3037
+ if (index !== void 0) {
3038
+ run.push(word[0]);
3039
+ runIndex.push(index);
2919
3040
  }
2920
- if (indices.has(word[0])) run.push(word[0]);
2921
3041
  previousEnd = word.index + word[0].length;
2922
3042
  }
2923
3043
  return inspect();
@@ -3048,6 +3168,9 @@ function validateCask(raw) {
3048
3168
  const second = b64urlIndex(s[tsAt + 5]);
3049
3169
  return month <= 11 && day <= 30 && hour <= 23 && minute <= 59 && second <= 59;
3050
3170
  }
3171
+ function isInvisibleChar(char) {
3172
+ return INVISIBLE_ONE.test(char);
3173
+ }
3051
3174
  function stripInvisible(text) {
3052
3175
  return text.replace(INVISIBLE_RE, "");
3053
3176
  }
@@ -3234,6 +3357,45 @@ function matchArgumentText(text, fieldPath, budget) {
3234
3357
  }
3235
3358
  return best;
3236
3359
  }
3360
+ function matchArgumentBatch(fragments, fieldPath, budget) {
3361
+ if (!fragments.length) return null;
3362
+ const text = fragments.join(BATCH_SEPARATOR);
3363
+ const lower2 = text.toLowerCase();
3364
+ let best = null;
3365
+ for (const pattern of DLP_PATTERNS) {
3366
+ if (pattern.keywords && !pattern.keywords.some((kw) => lower2.includes(kw.toLowerCase())))
3367
+ continue;
3368
+ const flags = pattern.regex.flags.replace(/[gmy]/g, "");
3369
+ if (!new RegExp(pattern.regex.source, flags + "m").test(text)) continue;
3370
+ for (const fragment of fragments) {
3371
+ if (firstAcceptedMatch(pattern, fragment) === null) continue;
3372
+ const severity = pattern.contextBoost && isAssignmentContext(fragment) ? "block" : pattern.severity;
3373
+ if (severity === "block")
3374
+ return {
3375
+ patternName: pattern.name,
3376
+ fieldPath,
3377
+ redactedSample: "[encoded secret]",
3378
+ severity
3379
+ };
3380
+ best ??= {
3381
+ patternName: pattern.name,
3382
+ fieldPath,
3383
+ redactedSample: "[encoded secret]",
3384
+ severity
3385
+ };
3386
+ if (!pattern.contextBoost) break;
3387
+ }
3388
+ }
3389
+ if (findMnemonic(text, budget)) {
3390
+ return {
3391
+ patternName: "BIP-39 Recovery Phrase",
3392
+ fieldPath,
3393
+ redactedSample: "[recovery phrase]",
3394
+ severity: "block"
3395
+ };
3396
+ }
3397
+ return best;
3398
+ }
3237
3399
  function scanArgs(args, depth = 0, fieldPath = "args") {
3238
3400
  if (depth > MAX_DEPTH2 || args === null || args === void 0) return null;
3239
3401
  if (Array.isArray(args)) {
@@ -3599,7 +3761,7 @@ function pipInstall(words, from, manager, out) {
3599
3761
  if (v >= 0 && lower(words[v]) === "install")
3600
3762
  pushPy(out, manager, operands(words, v + 1, PIP_VALUE_FLAGS));
3601
3763
  }
3602
- function localContexts(file) {
3764
+ function localContexts(file2) {
3603
3765
  const contexts = /* @__PURE__ */ new Map();
3604
3766
  const sample = parseShared("cd . && true");
3605
3767
  if (typeof sample === "symbol") return contexts;
@@ -3627,7 +3789,7 @@ function localContexts(file) {
3627
3789
  contexts.set(cmd.Pos().Offset(), steps);
3628
3790
  return null;
3629
3791
  }
3630
- if (file.Stmts?.length === 1) visit(file.Stmts[0], []);
3792
+ if (file2.Stmts?.length === 1) visit(file2.Stmts[0], []);
3631
3793
  return contexts;
3632
3794
  }
3633
3795
  function collect(command, out, depth) {
@@ -3765,6 +3927,13 @@ function checkToLegacyPolicy(policy, id, v) {
3765
3927
  return offOr("packageCheck", () => {
3766
3928
  obj("packageCheck").onMalicious = v === "review" ? "review" : "block";
3767
3929
  });
3930
+ case "behavior.untrusted-content":
3931
+ if (v !== "off" && v !== "log" && v !== "review") return false;
3932
+ return offOr("untrustedContent", () => {
3933
+ const uc = obj("untrustedContent");
3934
+ if (v === "log") uc.afterExposure = "log-only";
3935
+ else if (uc.afterExposure !== "ask-every-time") uc.afterExposure = "ask-once";
3936
+ });
3768
3937
  default:
3769
3938
  return false;
3770
3939
  }
@@ -3778,38 +3947,38 @@ function configurableValues(id) {
3778
3947
  if (def.pack || MAP_GOVERNED.has(id)) return def.values;
3779
3948
  return KNOB_GOVERNED[id] ?? [];
3780
3949
  }
3781
- function hostMatches(host, pattern) {
3950
+ function hostMatches(host2, pattern) {
3782
3951
  const p = pattern.trim().toLowerCase();
3783
3952
  if (!p) return false;
3784
3953
  if (p === "*") return true;
3785
- const h = canonicalHost(host);
3954
+ const h = canonicalHost(host2);
3786
3955
  if (p.startsWith("*.")) {
3787
3956
  const suffix = canonicalHost(p.slice(2));
3788
3957
  return h === suffix || h.endsWith("." + suffix);
3789
3958
  }
3790
3959
  return h === canonicalHost(p);
3791
3960
  }
3792
- function matchesAny(host, patterns) {
3793
- for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
3961
+ function matchesAny(host2, patterns) {
3962
+ for (const p of patterns ?? []) if (hostMatches(host2, p)) return true;
3794
3963
  return false;
3795
3964
  }
3796
- function isUniqueLocalV6(host) {
3797
- const ip = normalizeIpLiteral(host);
3965
+ function isUniqueLocalV6(host2) {
3966
+ const ip = normalizeIpLiteral(host2);
3798
3967
  if (!ip || !ip.includes(":")) return false;
3799
3968
  const g = expandIpv6(ip);
3800
3969
  return g !== null && (g[0] & 65024) === 64512;
3801
3970
  }
3802
- function isPrivateHost(host) {
3803
- const h = host.trim().toLowerCase().replace(/\.$/, "");
3971
+ function isPrivateHost(host2) {
3972
+ const h = host2.trim().toLowerCase().replace(/\.$/, "");
3804
3973
  const m = classifySsrf(h);
3805
3974
  if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
3806
3975
  if (h === "localhost") return true;
3807
3976
  if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
3808
3977
  return isUniqueLocalV6(h);
3809
3978
  }
3810
- function canonicalHost(host) {
3811
- const ip = normalizeIpLiteral(host);
3812
- return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
3979
+ function canonicalHost(host2) {
3980
+ const ip = normalizeIpLiteral(host2);
3981
+ return ip ?? host2.trim().toLowerCase().replace(/\.$/, "");
3813
3982
  }
3814
3983
  function evaluateEgress(dests, policy) {
3815
3984
  if (!policy.enabled) return null;
@@ -4033,9 +4202,9 @@ function bareToolName(toolName) {
4033
4202
  const parts = toolName.split("__");
4034
4203
  return (parts.length >= 3 ? parts.slice(2).join("__") : toolName).toLowerCase();
4035
4204
  }
4036
- function valuesAt(args, path20) {
4205
+ function valuesAt(args, path26) {
4037
4206
  let cursors = [args];
4038
- for (const rawSegment of path20.split(".")) {
4207
+ for (const rawSegment of path26.split(".")) {
4039
4208
  const isArray = rawSegment.endsWith("[]");
4040
4209
  const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
4041
4210
  const next = [];
@@ -4067,10 +4236,10 @@ function extractToolDestinations(toolName, args) {
4067
4236
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
4068
4237
  if (!paths) return [];
4069
4238
  const out = [];
4070
- for (const path20 of paths) {
4071
- for (const value of valuesAt(args, path20)) {
4072
- const host = hostOf(value);
4073
- if (host) out.push({ host, binary: toolName, raw: value });
4239
+ for (const path26 of paths) {
4240
+ for (const value of valuesAt(args, path26)) {
4241
+ const host2 = hostOf(value);
4242
+ if (host2) out.push({ host: host2, binary: toolName, raw: value });
4074
4243
  }
4075
4244
  }
4076
4245
  return out;
@@ -4082,15 +4251,15 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
4082
4251
  try {
4083
4252
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
4084
4253
  if (!paths) return null;
4085
- for (const path20 of paths) {
4086
- for (const value of valuesAt(args, path20)) {
4087
- const host = hostOf(value);
4088
- if (!host) continue;
4089
- const m = classifySsrf(host);
4254
+ for (const path26 of paths) {
4255
+ for (const value of valuesAt(args, path26)) {
4256
+ const host2 = hostOf(value);
4257
+ if (!host2) continue;
4258
+ const m = classifySsrf(host2);
4090
4259
  if (!m) continue;
4091
4260
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
4092
4261
  if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
4093
- return { ...m, argPath: path20, host, reason: ssrfReason(m, host) };
4262
+ return { ...m, argPath: path26, host: host2, reason: ssrfReason(m, host2) };
4094
4263
  }
4095
4264
  }
4096
4265
  return null;
@@ -4098,6 +4267,9 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
4098
4267
  return null;
4099
4268
  }
4100
4269
  }
4270
+ function newSecretViewBudget() {
4271
+ return { hashes: 0, nestedBytes: 0, joinedLabelBytes: 0, reasons: /* @__PURE__ */ new Set() };
4272
+ }
4101
4273
  function textBytes(buf) {
4102
4274
  if (buf.length < 16) return null;
4103
4275
  const text = buf.toString("utf8");
@@ -4116,13 +4288,41 @@ function percentOnce(text) {
4116
4288
  }
4117
4289
  });
4118
4290
  }
4291
+ function* wrappedBase64(text) {
4292
+ if (!text.includes("\n")) return;
4293
+ const word = /^[A-Za-z0-9+/_-]+={0,2}$/;
4294
+ let run = [];
4295
+ let width = 0;
4296
+ const flush = function* () {
4297
+ if (run.length >= 2) yield run.join("");
4298
+ run = [];
4299
+ width = 0;
4300
+ };
4301
+ for (const raw of text.split("\n")) {
4302
+ const line = raw.trim();
4303
+ const full = line.length >= 16 && word.test(line) && !line.includes("=");
4304
+ if (full && (!run.length || line.length === width)) {
4305
+ run.push(line);
4306
+ width = line.length;
4307
+ continue;
4308
+ }
4309
+ if (run.length && word.test(line) && line.length >= 2 && line.length <= width) {
4310
+ run.push(line);
4311
+ yield* flush();
4312
+ continue;
4313
+ }
4314
+ yield* flush();
4315
+ if (full) {
4316
+ run.push(line);
4317
+ width = line.length;
4318
+ }
4319
+ }
4320
+ yield* flush();
4321
+ }
4119
4322
  function* encodedRegions(text, budget) {
4120
4323
  yield [text, false];
4324
+ for (const block of wrappedBase64(text)) yield [block, false];
4121
4325
  for (const match of text.matchAll(/(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,30}:\/\/[^\s<>"']+/gi)) {
4122
- if (++budget.candidates > SECRET_VIEW_LIMITS.maxCandidates) {
4123
- budget.reasons.add("decode-candidates");
4124
- return;
4125
- }
4126
4326
  try {
4127
4327
  new URL(match[0]);
4128
4328
  } catch {
@@ -4132,16 +4332,18 @@ function* encodedRegions(text, budget) {
4132
4332
  /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/i
4133
4333
  );
4134
4334
  if (!parts) continue;
4135
- const host = parts[1].slice(parts[1].lastIndexOf("@") + 1).replace(/:\d+$/, "");
4335
+ const host2 = parts[1].slice(parts[1].lastIndexOf("@") + 1).replace(/:\d+$/, "");
4136
4336
  const regions = [
4137
4337
  parts[1],
4138
- ...host.split("."),
4338
+ ...host2.split("."),
4139
4339
  ...parts[2].split("/"),
4140
4340
  ...(parts[3] ?? "").split("&"),
4141
4341
  parts[4] ?? ""
4142
4342
  ];
4143
4343
  for (const region of regions) if (region) yield [region, false];
4144
- const labels = host.split(".");
4344
+ const labels = host2.split(".");
4345
+ if (host2.length > SECRET_VIEW_LIMITS.maxHostLength || labels.length > SECRET_VIEW_LIMITS.maxLabels || !labels.every((l) => l.length >= 1 && l.length <= SECRET_VIEW_LIMITS.maxLabelLength))
4346
+ continue;
4145
4347
  for (let start = 0; start < labels.length; start++) {
4146
4348
  let joined = labels[start];
4147
4349
  for (let end = start + 1; end < Math.min(labels.length, start + 8); end++) {
@@ -4149,11 +4351,13 @@ function* encodedRegions(text, budget) {
4149
4351
  if (!run.every((s) => /^[a-f0-9]{2,}$/i.test(s)) && !run.every((s) => /^[A-Z2-7]{4,}$/i.test(s)))
4150
4352
  break;
4151
4353
  joined += labels[end];
4152
- if (++budget.candidates > SECRET_VIEW_LIMITS.maxCandidates) {
4153
- budget.reasons.add("decode-candidates");
4354
+ if (joined.length < 16) continue;
4355
+ budget.joinedLabelBytes += joined.length;
4356
+ if (budget.joinedLabelBytes > SECRET_VIEW_LIMITS.maxJoinedLabelBytes) {
4357
+ budget.reasons.add("decode-labels");
4154
4358
  return;
4155
4359
  }
4156
- if (joined.length >= 16 && joined.length <= 253) yield [joined, true];
4360
+ yield [joined, true];
4157
4361
  }
4158
4362
  }
4159
4363
  }
@@ -4190,10 +4394,6 @@ function* decodedCandidates(text, budget) {
4190
4394
  if (joined) yield ["dns-labels", region];
4191
4395
  for (const [kind, re] of families) {
4192
4396
  for (const match of region.matchAll(re)) {
4193
- if (++budget.candidates > SECRET_VIEW_LIMITS.maxCandidates) {
4194
- budget.reasons.add("decode-candidates");
4195
- return;
4196
- }
4197
4397
  const raw = match[0];
4198
4398
  let buf;
4199
4399
  if (kind === "base64") {
@@ -4216,42 +4416,76 @@ function* decodedCandidates(text, budget) {
4216
4416
  }
4217
4417
  }
4218
4418
  }
4219
- function* secretViews(text, budget) {
4419
+ function secretViews(text, budget) {
4220
4420
  const seen = /* @__PURE__ */ new Set([text]);
4221
- const queue = [{ text, transforms: [] }];
4222
- for (let i = 0; i < queue.length; i++) {
4223
- const view = queue[i];
4224
- yield view;
4225
- for (const [kind, decoded] of decodedCandidates(view.text, budget)) {
4226
- if (seen.has(decoded)) continue;
4227
- if (view.transforms.length >= SECRET_VIEW_LIMITS.maxDepth) {
4228
- budget.reasons.add("decode-depth");
4229
- continue;
4230
- }
4231
- const bytes = Buffer.byteLength(decoded);
4232
- if (queue.length >= SECRET_VIEW_LIMITS.maxViewsPerLeaf || budget.views >= SECRET_VIEW_LIMITS.maxViews || budget.bytes + bytes > SECRET_VIEW_LIMITS.maxDerivedBytes) {
4233
- budget.reasons.add("decode-budget");
4234
- return;
4421
+ const out = [];
4422
+ let frontier = [{ text, transforms: [] }];
4423
+ for (let depth = 1; depth <= SECRET_VIEW_LIMITS.maxDepth; depth++) {
4424
+ const next = [];
4425
+ for (const parent of frontier) {
4426
+ let perLeaf = 0;
4427
+ for (const [kind, decoded] of decodedCandidates(parent.text, budget)) {
4428
+ if (seen.has(decoded)) continue;
4429
+ seen.add(decoded);
4430
+ const normalizing = kind === "invisibles" || kind === "percent";
4431
+ if (depth >= 2 && !normalizing) {
4432
+ budget.nestedBytes += Buffer.byteLength(decoded);
4433
+ if (++perLeaf > SECRET_VIEW_LIMITS.maxNestedViewsPerLeaf || budget.nestedBytes > SECRET_VIEW_LIMITS.maxNestedBytes) {
4434
+ budget.reasons.add("decode-nested");
4435
+ return out;
4436
+ }
4437
+ }
4438
+ const view = { text: decoded, transforms: [...parent.transforms, kind] };
4439
+ out.push(view);
4440
+ next.push(view);
4235
4441
  }
4236
- seen.add(decoded);
4237
- budget.views++;
4238
- budget.bytes += bytes;
4239
- queue.push({ text: decoded, transforms: [...view.transforms, kind] });
4240
4442
  }
4241
- if (budget.candidates > SECRET_VIEW_LIMITS.maxCandidates) return;
4443
+ frontier = next;
4444
+ if (!frontier.length) return out;
4445
+ }
4446
+ for (const view of frontier) {
4447
+ if (!decodedCandidates(view.text, budget).next().done) {
4448
+ budget.reasons.add("decode-nested");
4449
+ break;
4450
+ }
4242
4451
  }
4452
+ return out;
4453
+ }
4454
+ function batches(views) {
4455
+ const out = [];
4456
+ let current = [];
4457
+ let size = 0;
4458
+ for (const { text } of views) {
4459
+ if (current.length && size + text.length > SECRET_VIEW_LIMITS.batchCharacters) {
4460
+ out.push(current);
4461
+ current = [];
4462
+ size = 0;
4463
+ }
4464
+ current.push(text);
4465
+ size += text.length;
4466
+ }
4467
+ if (current.length) out.push(current);
4468
+ return out;
4243
4469
  }
4244
4470
  function inspectArgumentSecrets(args) {
4245
- const budget = { views: 0, bytes: 0, candidates: 0, reasons: /* @__PURE__ */ new Set() };
4471
+ const budget = newSecretViewBudget();
4246
4472
  const ancestors = /* @__PURE__ */ new Set();
4247
4473
  let nodes = 0;
4248
4474
  let sourceBytes = 0;
4249
4475
  let tailBytes = 0;
4250
4476
  let best = null;
4477
+ const blocked = () => best?.severity === "block";
4251
4478
  const accept = (match) => {
4252
4479
  if (match && (!best || best.severity !== "block" && match.severity === "block")) best = match;
4253
4480
  };
4481
+ const decodeSegment = (segment, fieldPath) => {
4482
+ for (const batch of batches(secretViews(segment, budget))) {
4483
+ accept(matchArgumentBatch(batch, fieldPath, budget));
4484
+ if (blocked()) return;
4485
+ }
4486
+ };
4254
4487
  const visit = (value, depth, fieldPath) => {
4488
+ if (blocked()) return;
4255
4489
  if (++nodes > ARGUMENT_SCAN_LIMITS.maxNodes) {
4256
4490
  budget.reasons.add("argument-nodes");
4257
4491
  return;
@@ -4293,14 +4527,11 @@ function inspectArgumentSecrets(args) {
4293
4527
  }
4294
4528
  sourceBytes += Math.min(bytes, Math.max(0, remaining));
4295
4529
  if (!text) return;
4296
- for (const view of secretViews(text, budget)) {
4297
- const hit = matchArgumentText(view.text, fieldPath, budget);
4298
- if (hit)
4299
- accept(view.transforms.length ? { ...hit, redactedSample: "[encoded secret]" } : hit);
4300
- }
4530
+ accept(matchArgumentText(text, fieldPath, budget));
4531
+ let scannedEnd = text.length;
4301
4532
  if (!truncated && value.length > window) {
4302
- budget.reasons.add("decode-window");
4303
4533
  for (let start = window - overlap; start < value.length; start += window - overlap) {
4534
+ if (blocked()) return;
4304
4535
  const chunk = value.slice(start, start + window);
4305
4536
  const chunkBytes = Buffer.byteLength(chunk);
4306
4537
  if (tailBytes + chunkBytes > ARGUMENT_SCAN_LIMITS.maxTailBytes) {
@@ -4309,9 +4540,16 @@ function inspectArgumentSecrets(args) {
4309
4540
  }
4310
4541
  tailBytes += chunkBytes;
4311
4542
  accept(matchArgumentText(chunk, fieldPath, budget));
4543
+ scannedEnd = start + chunk.length;
4312
4544
  if (start + window >= value.length) break;
4313
4545
  }
4314
4546
  }
4547
+ for (let start = 0; start < scannedEnd && !blocked(); ) {
4548
+ let end = Math.min(start + window, scannedEnd);
4549
+ while (end < scannedEnd && runChar(value, end - 1) && runChar(value, end)) end++;
4550
+ decodeSegment(value.slice(start, end), fieldPath);
4551
+ start = end;
4552
+ }
4315
4553
  const trimmed = text.trim();
4316
4554
  if (value.length < ARGUMENT_SCAN_LIMITS.maxJsonCharacters && (trimmed.startsWith("{") || trimmed.startsWith("["))) {
4317
4555
  try {
@@ -4330,7 +4568,7 @@ function inspectArgumentSecrets(args) {
4330
4568
  function scanArgumentSecrets(args) {
4331
4569
  const result = inspectArgumentSecrets(args);
4332
4570
  if (result.match) return result.match;
4333
- return result.reasons.some((reason) => reason.startsWith("argument-")) ? {
4571
+ return result.coverage === "incomplete" ? {
4334
4572
  patternName: "Argument inspection incomplete",
4335
4573
  fieldPath: "args",
4336
4574
  redactedSample: "[uninspected content]",
@@ -4358,18 +4596,13 @@ function tokenize2(toolName) {
4358
4596
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
4359
4597
  }
4360
4598
  function extractShellCommand(toolName, args, toolInspection) {
4361
- const patterns = Object.keys(toolInspection);
4362
- const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
4363
- if (!matchingPattern) return null;
4364
- const fieldPath = toolInspection[matchingPattern];
4599
+ const fieldPath = inspectionEntry(toolName, toolInspection);
4600
+ if (fieldPath === void 0) return null;
4365
4601
  const value = getNestedValue(args, typeof fieldPath === "string" ? fieldPath : fieldPath.field);
4366
4602
  return typeof value === "string" ? value : null;
4367
4603
  }
4368
4604
  function isSqlTool(toolName, toolInspection) {
4369
- const patterns = Object.keys(toolInspection);
4370
- const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
4371
- if (!matchingPattern) return false;
4372
- const fieldName = toolInspection[matchingPattern];
4605
+ const fieldName = inspectionEntry(toolName, toolInspection);
4373
4606
  return fieldName === "sql" || fieldName === "query";
4374
4607
  }
4375
4608
  function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
@@ -4377,7 +4610,7 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
4377
4610
  if (!pipeAnalysis.isPipeline) return null;
4378
4611
  if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
4379
4612
  const sinks = pipeAnalysis.sinkTargets;
4380
- const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost2 ? isTrustedHost2(host) : false);
4613
+ const allTrusted = sinks.length > 0 && sinks.every((host2) => isTrustedHost2 ? isTrustedHost2(host2) : false);
4381
4614
  if (pipeAnalysis.risk === "critical") {
4382
4615
  if (allTrusted) {
4383
4616
  return {
@@ -4436,9 +4669,10 @@ function egressPolicyVerdict(eg) {
4436
4669
  }
4437
4670
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
4438
4671
  let protectedVerdict = null;
4672
+ const layers = inspectionLayers(config.policy);
4439
4673
  if (context.agent !== "Terminal") {
4440
4674
  try {
4441
- const accesses = context.accesses ?? collectFileAccesses(toolName, args, context, config.policy.toolInspection);
4675
+ const accesses = context.accesses ?? collectFileAccesses(toolName, args, context, layers);
4442
4676
  protectedVerdict = evaluateProtectedFileAccess(accesses, context, catalogSettings(config));
4443
4677
  } catch {
4444
4678
  protectedVerdict = incompleteFileVerdict();
@@ -4473,7 +4707,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4473
4707
  hooks
4474
4708
  );
4475
4709
  if (result.decision === "block") return result;
4476
- if (context.agent !== "Terminal" && resolveShellInput(toolName, args, config.policy.toolInspection).kind === "uninspectable") {
4710
+ if (context.agent !== "Terminal" && resolveShellInput(toolName, args, layers).kind === "uninspectable") {
4477
4711
  return {
4478
4712
  decision: "review",
4479
4713
  ruleName: "shell-inspection-incomplete",
@@ -4489,13 +4723,14 @@ async function evaluatePolicyCore(config, toolName, args, context = {}, hooks =
4489
4723
  const { agent, cwd, activeEnvironment } = context;
4490
4724
  const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
4491
4725
  const settingsView = catalogSettings(config);
4726
+ const layers = inspectionLayers(config.policy);
4492
4727
  const cv = (id) => checkValue(settingsView, id);
4493
4728
  let loggedFinding;
4494
4729
  const noteLogged = (v) => {
4495
4730
  if (v?.logged && !loggedFinding) loggedFinding = v;
4496
4731
  };
4497
4732
  const allowed = () => loggedFinding ?? { decision: "allow" };
4498
- const shellInput = resolveShellInput(toolName, args, config.policy.toolInspection);
4733
+ const shellInput = resolveShellInput(toolName, args, layers);
4499
4734
  const wouldBeIgnored = matchesPattern(toolName, config.policy.ignoredTools);
4500
4735
  {
4501
4736
  const dest = ssrfDestinationFloor(toolName, args, {
@@ -4600,13 +4835,10 @@ async function evaluatePolicyCore(config, toolName, args, context = {}, hooks =
4600
4835
  return { id, value: cv(id) };
4601
4836
  };
4602
4837
  const matches = config.policy.smartRules.filter((rule) => {
4603
- if (!toolMatchesRule(toolName, rule.tool, config.policy.toolInspection)) return false;
4838
+ if (!toolMatchesRule(toolName, rule.tool, layers)) return false;
4604
4839
  if (astSuppressed(rule)) return false;
4605
4840
  if (rmCleanupWaiver && rule.name === "review-rm" && rule.verdict === "review") return false;
4606
- if (!evaluateSmartConditions(
4607
- shellRuleArgs(toolName, args, rule.tool, config.policy.toolInspection),
4608
- rule
4609
- ))
4841
+ if (!evaluateSmartConditions(shellRuleArgs(toolName, args, rule.tool, layers), rule))
4610
4842
  return false;
4611
4843
  const check = ruleCheck(rule);
4612
4844
  if (check?.value === "off") return false;
@@ -4653,7 +4885,7 @@ async function evaluatePolicyCore(config, toolName, args, context = {}, hooks =
4653
4885
  let allTokens = [];
4654
4886
  let pathTokens = [];
4655
4887
  if (pendingToolEgress) return pendingToolEgress;
4656
- const shellCommand = shellInput.kind === "shell" ? shellInput.command : shellInput.kind === "not-shell" ? extractShellCommand(toolName, args, config.policy.toolInspection) : null;
4888
+ const shellCommand = shellInput.kind === "shell" ? shellInput.command : shellInput.kind === "not-shell" ? extractShellCommand(toolName, args, layers) : null;
4657
4889
  if (shellCommand) {
4658
4890
  const analyzed = analyzeShellCommand(shellCommand);
4659
4891
  allTokens = analyzed.allTokens;
@@ -4730,7 +4962,7 @@ async function evaluatePolicyCore(config, toolName, args, context = {}, hooks =
4730
4962
  if (["ssh", "scp", "rsync"].includes(firstToken)) {
4731
4963
  const rawTokens = shellCommand.trim().split(/\s+/);
4732
4964
  const sshHosts = extractAllSshHosts(rawTokens.slice(1));
4733
- allTokens.push(...sshHosts);
4965
+ allTokens = allTokens.concat(sshHosts);
4734
4966
  }
4735
4967
  if (firstToken && firstToken.startsWith("/") && checkProvenance2) {
4736
4968
  const prov = checkProvenance2(firstToken, cwd);
@@ -4759,7 +4991,7 @@ async function evaluatePolicyCore(config, toolName, args, context = {}, hooks =
4759
4991
  };
4760
4992
  }
4761
4993
  }
4762
- if (isSqlTool(toolName, config.policy.toolInspection)) {
4994
+ if (isSqlTool(toolName, layers)) {
4763
4995
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
4764
4996
  }
4765
4997
  } else {
@@ -4767,7 +4999,7 @@ async function evaluatePolicyCore(config, toolName, args, context = {}, hooks =
4767
4999
  if (args && typeof args === "object") {
4768
5000
  const flattenedArgs = JSON.stringify(args).toLowerCase();
4769
5001
  const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
4770
- allTokens.push(...extraTokens);
5002
+ allTokens = allTokens.concat(extraTokens);
4771
5003
  }
4772
5004
  }
4773
5005
  const isManual = agent === "Terminal";
@@ -4854,6 +5086,487 @@ async function evaluatePolicyCore(config, toolName, args, context = {}, hooks =
4854
5086
  function isIgnoredTool(toolName, config) {
4855
5087
  return matchesPattern(toolName, config.policy.ignoredTools);
4856
5088
  }
5089
+ function normalizeHost(raw) {
5090
+ let h = raw.trim().toLowerCase();
5091
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
5092
+ if (!h || h === "localhost") return null;
5093
+ if (/^[0-9a-f:]+$/.test(h) && h.includes(":")) return h;
5094
+ if (!h.includes(".") || !/^[\p{L}\p{N}.-]+$/u.test(h)) return null;
5095
+ return h;
5096
+ }
5097
+ function hostOfUrl(value) {
5098
+ const v = value.replace(/[.,;:!?)\]}>'"`]+$/, "");
5099
+ try {
5100
+ const u = new URL(v.toLowerCase().startsWith("www.") ? `http://${v}` : v);
5101
+ return normalizeHost(u.hostname);
5102
+ } catch {
5103
+ return null;
5104
+ }
5105
+ }
5106
+ function addIbans(text, out) {
5107
+ for (const m of text.toUpperCase().matchAll(IBAN_RE)) {
5108
+ const compact = m[0].replace(/[ -]/g, "");
5109
+ if (validateIban(compact)) out.add(`iban:${compact}`);
5110
+ else if (/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact)) out.add(`account:${compact}`);
5111
+ if (out.size >= MAX_DESTINATIONS) return;
5112
+ }
5113
+ }
5114
+ function addEmails(text, out) {
5115
+ for (const m of text.matchAll(EMAIL_RE)) {
5116
+ out.add(`email:${m[0].toLowerCase()}`);
5117
+ if (out.size >= MAX_DESTINATIONS) return;
5118
+ }
5119
+ }
5120
+ function addUrlHosts(text, out) {
5121
+ for (const m of text.matchAll(URL_RE)) {
5122
+ const h = hostOfUrl(m[0]);
5123
+ if (h) out.add(`host:${h}`);
5124
+ if (out.size >= MAX_DESTINATIONS) return;
5125
+ }
5126
+ }
5127
+ function extractContentDestinations(text) {
5128
+ const out = /* @__PURE__ */ new Set();
5129
+ try {
5130
+ const t = text.length > MAX_SCAN_CHARS ? text.slice(0, MAX_SCAN_CHARS) : text;
5131
+ if (t.includes("@")) addEmails(t, out);
5132
+ if (out.size < MAX_DESTINATIONS) addUrlHosts(t, out);
5133
+ if (out.size < MAX_DESTINATIONS) addIbans(t, out);
5134
+ } catch {
5135
+ }
5136
+ return [...out];
5137
+ }
5138
+ function extractArgDestinations(args) {
5139
+ const out = /* @__PURE__ */ new Set();
5140
+ try {
5141
+ walk(args, void 0, out, 0);
5142
+ } catch {
5143
+ }
5144
+ return [...out];
5145
+ }
5146
+ function walk(value, key, out, depth) {
5147
+ if (out.size >= MAX_DESTINATIONS || depth > 12) return;
5148
+ if (typeof value === "string") {
5149
+ const s = value.length > MAX_SCAN_CHARS ? value.slice(0, MAX_SCAN_CHARS) : value;
5150
+ if (s.includes("@")) addEmails(s, out);
5151
+ addUrlHosts(s, out);
5152
+ addIbans(s, out);
5153
+ if (key && RECIPIENT_KEYS.has(key.toLowerCase()) && !/[\s@]/.test(s)) {
5154
+ const h = normalizeHost(
5155
+ s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^\/\//, "").split(/[/?#]/)[0].replace(/:\d+$/, "")
5156
+ );
5157
+ if (h && (h.includes(":") || /\.\p{L}{2,}$/u.test(h))) out.add(`host:${h}`);
5158
+ const acct = s.replace(/[ .-]/g, "").toUpperCase();
5159
+ if (/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(acct) && !validateIban(acct))
5160
+ out.add(`account:${acct}`);
5161
+ }
5162
+ return;
5163
+ }
5164
+ if (Array.isArray(value)) {
5165
+ for (const v of value) walk(v, key, out, depth + 1);
5166
+ return;
5167
+ }
5168
+ if (value && typeof value === "object") {
5169
+ for (const [k, v] of Object.entries(value))
5170
+ walk(v, k, out, depth + 1);
5171
+ }
5172
+ }
5173
+ function destinationDomain(dest) {
5174
+ if (!dest.startsWith("email:")) return null;
5175
+ const at = dest.lastIndexOf("@");
5176
+ return at > 0 ? dest.slice(at + 1) : null;
5177
+ }
5178
+ function destinationKind(dest) {
5179
+ const i = dest.indexOf(":");
5180
+ const k = i > 0 ? dest.slice(0, i) : "";
5181
+ return k === "email" || k === "host" || k === "iban" || k === "account" ? k : null;
5182
+ }
5183
+ function destinationValue(dest) {
5184
+ const i = dest.indexOf(":");
5185
+ return i > 0 ? dest.slice(i + 1) : dest;
5186
+ }
5187
+ function isLoopbackHost(host2) {
5188
+ let h = host2.trim().toLowerCase();
5189
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
5190
+ return h === "localhost" || h.endsWith(".localhost") || /^127(?:\.\d{1,3}){3}$/.test(h) || h === "::1" || h === "0:0:0:0:0:0:0:1" || h === "0.0.0.0";
5191
+ }
5192
+ function gitRepoPath(url) {
5193
+ const u = url.trim();
5194
+ if (!u) return null;
5195
+ let host2;
5196
+ let p;
5197
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) {
5198
+ try {
5199
+ const parsed = new URL(u);
5200
+ host2 = parsed.hostname.toLowerCase();
5201
+ p = decodeURIComponent(parsed.pathname);
5202
+ } catch {
5203
+ return null;
5204
+ }
5205
+ } else {
5206
+ const scp = /^(?:[^@/\s]+@)?([A-Za-z0-9.-]+):(?!\/\/)(.+)$/.exec(u);
5207
+ if (!scp || !scp[1].includes(".")) return null;
5208
+ host2 = scp[1].toLowerCase();
5209
+ p = scp[2];
5210
+ }
5211
+ let start = 0;
5212
+ let end = p.length;
5213
+ while (start < end && p[start] === "/") start++;
5214
+ while (end > start && p[end - 1] === "/") end--;
5215
+ const tail = p.slice(start, end).replace(/\.git$/i, "").toLowerCase();
5216
+ if (!host2 || !tail.includes("/")) return null;
5217
+ return `${host2}/${tail}`;
5218
+ }
5219
+ function isDestructiveToolName(bareTool) {
5220
+ return DESTRUCTIVE_VERB_RE.test(bareTool);
5221
+ }
5222
+ function host(token) {
5223
+ if (!token) return null;
5224
+ const h = parseDestHost(token);
5225
+ return h ? `host:${h}` : null;
5226
+ }
5227
+ function ghRepoArg(v, defaultHost) {
5228
+ if (/:\/\/|@/.test(v)) {
5229
+ const r = gitRepoPath(v);
5230
+ if (r) return { host: r.split("/")[0], repo: r };
5231
+ }
5232
+ const parts = v.toLowerCase().replace(/\.git$/, "").split("/").filter(Boolean);
5233
+ if (parts.length === 3) return { host: parts[0], repo: parts.join("/") };
5234
+ if (parts.length === 2) return { host: defaultHost, repo: `${defaultHost}/${parts.join("/")}` };
5235
+ return { host: defaultHost, repo: `${defaultHost}/*` };
5236
+ }
5237
+ function ghApiRepo(endpoint, ghHost) {
5238
+ if (endpoint === null) return `${ghHost}/*`;
5239
+ const e = endpoint.replace(/^https?:\/\/[^/]+\//i, "").replace(/^\/+/, "");
5240
+ const m = /^repos\/([^/]+)\/([^/?#]+)/i.exec(e);
5241
+ if (!m) return `${ghHost}/*`;
5242
+ if (m[1] === "{owner}" && m[2] === "{repo}") return void 0;
5243
+ return `${ghHost}/${m[1].toLowerCase()}/${m[2].toLowerCase()}`;
5244
+ }
5245
+ function sendToGitUrl(url, out) {
5246
+ out.sends = true;
5247
+ const h = typeof url === "string" ? host(url) : null;
5248
+ if (!url || !h) {
5249
+ out.unresolved = true;
5250
+ return;
5251
+ }
5252
+ out.destinations.push(h);
5253
+ const r = gitRepoPath(url);
5254
+ if (r) out.repos.push(r);
5255
+ }
5256
+ function callWords(call) {
5257
+ const raw = call.Args || [];
5258
+ if (raw.length === 0) return null;
5259
+ const words = raw.map((a) => resolveWordLiteral(a));
5260
+ const prefixes = raw.map((a) => resolveWordPrefix(a).prefix);
5261
+ const first = (words[0] || "").toLowerCase().split("/").pop() ?? "";
5262
+ const at = OWN_HEADS.has(first) ? 0 : unwrapCommandHead(words);
5263
+ const head = (words[at] || "").toLowerCase().split("/").pop() ?? "";
5264
+ if (!head) return null;
5265
+ const assigns = call.Assigns || [];
5266
+ const env = [
5267
+ ...assigns.map((a) => String(a?.Name?.Value ?? "")),
5268
+ ...words.slice(0, at).map((w) => w === null ? "" : /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(w)?.[1] ?? "")
5269
+ ].filter(Boolean);
5270
+ return { head, args: words.slice(at + 1), prefixes: prefixes.slice(at + 1), env };
5271
+ }
5272
+ function flagValue(args, names) {
5273
+ for (let i = 0; i < args.length; i++) {
5274
+ const w = args[i];
5275
+ if (w === null) continue;
5276
+ for (const n of names) {
5277
+ if (w === n && typeof args[i + 1] === "string") return args[i + 1];
5278
+ if (w.startsWith(`${n}=`)) return w.slice(n.length + 1);
5279
+ }
5280
+ }
5281
+ return null;
5282
+ }
5283
+ function positionals(args, valueFlags) {
5284
+ const out = [];
5285
+ for (let i = 0; i < args.length; i++) {
5286
+ const w = args[i];
5287
+ if (w === null) {
5288
+ out.push(null);
5289
+ continue;
5290
+ }
5291
+ if (w === "--") {
5292
+ out.push(...args.slice(i + 1));
5293
+ break;
5294
+ }
5295
+ if (w.startsWith("-")) {
5296
+ if (!w.includes("=") && valueFlags.has(w)) i++;
5297
+ continue;
5298
+ }
5299
+ out.push(w);
5300
+ }
5301
+ return out;
5302
+ }
5303
+ function classifyCall(head, args, prefixes, env, state, out) {
5304
+ const lower2 = args.map((a) => a === null ? null : a.toLowerCase());
5305
+ switch (head) {
5306
+ case "curl":
5307
+ case "wget":
5308
+ case "iwr":
5309
+ case "invoke-webrequest":
5310
+ case "irm":
5311
+ case "invoke-restmethod": {
5312
+ const binary = head === "wget" ? "wget" : "curl";
5313
+ const sendFlags = head === "wget" ? WGET_SEND_FLAGS : head === "curl" ? CURL_SEND_FLAGS : IWR_SEND_FLAGS;
5314
+ let sends = false;
5315
+ for (let i = 0; i < args.length; i++) {
5316
+ const w = args[i];
5317
+ if (w === null) continue;
5318
+ const name = (head === "curl" || head === "wget" ? w : w.toLowerCase()).split("=")[0];
5319
+ if (sendFlags.has(name)) sends = true;
5320
+ const next = lower2[i + 1];
5321
+ if ((name === "-X" || name === "--request" || name === "-method") && next) {
5322
+ if (WRITE_METHODS.has(next)) sends = true;
5323
+ }
5324
+ if (/^(?:--request=|-X)(POST|PUT|PATCH|DELETE)$/i.test(w)) sends = true;
5325
+ if (name === "--method" && next && WRITE_METHODS.has(next)) sends = true;
5326
+ }
5327
+ const withPrefixes = args.map(
5328
+ (a, i) => a === null && /^[a-z][a-z0-9+.-]*:\/\//i.test(prefixes[i] ?? "") ? prefixes[i] : a
5329
+ );
5330
+ const dests = destTokensForBinary(binary, withPrefixes).map((t) => host(t)).filter((h) => h !== null);
5331
+ out.fetches = true;
5332
+ out.fetchKinds.push(head);
5333
+ out.fetchDestinations.push(...dests);
5334
+ if (sends) {
5335
+ out.sends = true;
5336
+ out.destinations.push(...dests);
5337
+ }
5338
+ return;
5339
+ }
5340
+ case "scp":
5341
+ case "rsync": {
5342
+ const pos = positionals(args, VALUE_FLAGS[head] ?? /* @__PURE__ */ new Set()).filter(
5343
+ (p) => p !== null
5344
+ );
5345
+ if (pos.length < 2) return;
5346
+ const remote = (p) => /^[a-z][a-z0-9+.-]*:\/\//i.test(p) || /^[^/]*[^/:]:[^/]/.test(p) || p.includes("@");
5347
+ const target = pos[pos.length - 1];
5348
+ const sources = pos.slice(0, -1);
5349
+ if (remote(target)) {
5350
+ out.sends = true;
5351
+ const h = host(target);
5352
+ if (h) out.destinations.push(h);
5353
+ }
5354
+ if (sources.some(remote)) {
5355
+ out.fetches = true;
5356
+ out.fetchKinds.push(head);
5357
+ for (const src of sources.filter(remote)) {
5358
+ const h = host(src);
5359
+ if (h) out.fetchDestinations.push(h);
5360
+ }
5361
+ }
5362
+ return;
5363
+ }
5364
+ case "nc":
5365
+ case "ncat":
5366
+ case "netcat":
5367
+ case "ssh": {
5368
+ out.fetches = true;
5369
+ out.sends = true;
5370
+ out.fetchKinds.push(head);
5371
+ for (const t of destTokensForBinary(head, args)) {
5372
+ const h = host(t);
5373
+ if (h) {
5374
+ out.destinations.push(h);
5375
+ out.fetchDestinations.push(h);
5376
+ }
5377
+ }
5378
+ return;
5379
+ }
5380
+ case "gh": {
5381
+ const pos = positionals(args, GH_HOSTNAME_FLAGS);
5382
+ const group = pos[0] ?? null;
5383
+ const verb = pos[1] ?? null;
5384
+ const hn = flagValue(args, ["--hostname"]);
5385
+ let dest = host(hn ?? "github.com") ?? "host:github.com";
5386
+ const ghHost = dest.slice("host:".length);
5387
+ const envRepo = env.some((n) => n === "GH_REPO" || n === "GH_HOST");
5388
+ if (group === "api") {
5389
+ const method = (flagValue(args, ["-X", "--method"]) ?? "get").toLowerCase();
5390
+ const hasBody = args.some(
5391
+ (w) => w !== null && /^(-f|--raw-field|-F|--field|--input)(=|$)/.test(w)
5392
+ );
5393
+ if (WRITE_METHODS.has(method) || hasBody) {
5394
+ out.sends = true;
5395
+ out.destinations.push(dest);
5396
+ const endpoint = positionals(args, GH_API_VALUE_FLAGS)[1] ?? null;
5397
+ const repo = ghApiRepo(endpoint, ghHost);
5398
+ if (repo !== void 0) out.repos.push(repo);
5399
+ else if (envRepo) out.repos.push(`${ghHost}/*`);
5400
+ } else {
5401
+ out.fetches = true;
5402
+ out.fetchKinds.push("gh api");
5403
+ out.fetchDestinations.push(dest);
5404
+ }
5405
+ return;
5406
+ }
5407
+ if (group === null || verb === null) return;
5408
+ if (GH_WRITE.has(verb)) {
5409
+ out.sends = true;
5410
+ const rIdx = args.findIndex((w) => w === "-R" || w === "--repo");
5411
+ const rEq = args.find((w) => w !== null && /^(-R|--repo)=/.test(w));
5412
+ const rVal = rEq !== void 0 && rEq !== null ? rEq.slice(rEq.indexOf("=") + 1) : rIdx >= 0 ? args[rIdx + 1] : void 0;
5413
+ let repo;
5414
+ if (rIdx >= 0 || rEq !== void 0) {
5415
+ if (typeof rVal === "string") {
5416
+ const named = ghRepoArg(rVal, ghHost);
5417
+ repo = named.repo;
5418
+ if (named.host !== ghHost) dest = `host:${named.host}`;
5419
+ } else repo = `${ghHost}/*`;
5420
+ } else if (group === "gist" || group === "repo" && (verb === "create" || verb === "fork")) {
5421
+ repo = `${ghHost}/*`;
5422
+ } else if (group === "repo" && verb !== "rename") {
5423
+ const operand = positionals(args, GH_REPO_VALUE_FLAGS)[2];
5424
+ if (operand === null) repo = `${ghHost}/*`;
5425
+ else if (operand !== void 0) repo = ghRepoArg(operand, ghHost).repo;
5426
+ else if (envRepo) repo = `${ghHost}/*`;
5427
+ } else if (envRepo) {
5428
+ repo = `${ghHost}/*`;
5429
+ }
5430
+ out.destinations.push(dest);
5431
+ if (repo !== void 0) out.repos.push(repo);
5432
+ } else if (GH_READ.has(verb)) {
5433
+ out.fetches = true;
5434
+ out.fetchKinds.push(`gh ${group}`);
5435
+ out.fetchDestinations.push(dest);
5436
+ }
5437
+ return;
5438
+ }
5439
+ case "git": {
5440
+ const pos = positionals(args, /* @__PURE__ */ new Set(["-C", "-c", "--git-dir", "--work-tree"]));
5441
+ const verb = pos[0];
5442
+ if (verb === null || verb === void 0) return;
5443
+ for (let i = 0; i < args.length; i++) {
5444
+ if (args[i] !== "-c") continue;
5445
+ const v = args[i + 1];
5446
+ if (v === null || v === void 0) {
5447
+ if (verb === "push") out.unresolved = true;
5448
+ continue;
5449
+ }
5450
+ const m = /^remote\.[^=]+\.(?:push)?url=(.*)$/i.exec(v);
5451
+ if (m) sendToGitUrl(m[1], out);
5452
+ }
5453
+ if (verb === "remote") {
5454
+ const rp = positionals(args, /* @__PURE__ */ new Set(["-C", "-c", "--git-dir", "--work-tree", "-t", "-m"]));
5455
+ const sub = rp[1];
5456
+ if (sub === "add" || sub === "set-url") {
5457
+ const url = rp.slice(2).filter((p) => p !== "--push" && p !== "--add")[1];
5458
+ sendToGitUrl(url, out);
5459
+ }
5460
+ return;
5461
+ }
5462
+ if (verb === "config") {
5463
+ const reads = args.some(
5464
+ (w) => w !== null && /^--(get|get-all|get-regexp|unset|unset-all|list|get-urlmatch)$/.test(w)
5465
+ );
5466
+ const k = pos.findIndex((p) => p !== null && /^remote\..+\.(?:push)?url$/i.test(p));
5467
+ if (k >= 0 && k + 1 < pos.length && !reads) sendToGitUrl(pos[k + 1], out);
5468
+ return;
5469
+ }
5470
+ if (GIT_FETCH.has(verb)) {
5471
+ out.fetches = true;
5472
+ out.fetchKinds.push(`git ${verb}`);
5473
+ for (const p of pos.slice(1)) {
5474
+ const h = p !== null && /:\/\/|@/.test(p) ? host(p) : null;
5475
+ if (h) out.fetchDestinations.push(h);
5476
+ }
5477
+ return;
5478
+ }
5479
+ if (verb === "push") {
5480
+ if (pos.length > 1 && pos[1] === null) {
5481
+ out.sends = true;
5482
+ out.unresolved = true;
5483
+ return;
5484
+ }
5485
+ const target = flagValue(args, ["--repo"]) ?? pos.slice(1).find((p) => p !== null && !p.startsWith("-")) ?? null;
5486
+ if (target !== null && (/:\/\/|@/.test(target) || /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+:/.test(target))) {
5487
+ sendToGitUrl(target, out);
5488
+ return;
5489
+ }
5490
+ if (target !== null && /^[./~]|\//.test(target)) return;
5491
+ out.sends = true;
5492
+ const elsewhere = state.cwdChanged || args.some((w) => w !== null && /^(-C|--git-dir|--work-tree)(=|$)/.test(w)) || env.some((n) => n === "GIT_DIR" || n === "GIT_WORK_TREE" || n === "GIT_CONFIG");
5493
+ if (elsewhere) out.unresolved = true;
5494
+ else out.gitRemotes.push(target ?? "");
5495
+ }
5496
+ return;
5497
+ }
5498
+ default: {
5499
+ const pub = PUBLISH[head];
5500
+ if (!pub) return;
5501
+ const pos = positionals(args, new Set(pub.flag ?? []));
5502
+ const verb = pos[0];
5503
+ if (verb === null || verb === void 0 || !pub.verbs.has(verb)) return;
5504
+ if (head === "yarn" && verb === "npm" && pos[1] !== "publish") return;
5505
+ out.sends = true;
5506
+ const named = pub.flag ? flagValue(args, pub.flag) : null;
5507
+ const h = named ? host(named) : null;
5508
+ if (named && !h) return;
5509
+ if (head === "docker") {
5510
+ const image = pos[1];
5511
+ const first = image?.split("/")[0] ?? "";
5512
+ const reg = first.includes(".") || first.includes(":") ? host(first) : null;
5513
+ out.destinations.push(reg ?? "host:docker.io");
5514
+ return;
5515
+ }
5516
+ out.destinations.push(h ?? `host:${pub.registry}`);
5517
+ }
5518
+ }
5519
+ }
5520
+ function walkCalls(command, out, depth, state) {
5521
+ const f = parseShared(command);
5522
+ if (typeof f === "symbol" || !f) return;
5523
+ syntax4.Walk(f, (node) => {
5524
+ if (!node) return false;
5525
+ const type = syntax4.NodeType(node);
5526
+ if (type === "DeclClause") {
5527
+ for (const a of node.Args || []) if (a?.Name?.Value) state.env.add(a.Name.Value);
5528
+ return true;
5529
+ }
5530
+ if (type !== "CallExpr") return true;
5531
+ const call = node;
5532
+ if ((call.Args || []).length === 0) {
5533
+ for (const a of call.Assigns || []) if (a?.Name?.Value) state.env.add(a.Name.Value);
5534
+ return true;
5535
+ }
5536
+ const c = callWords(node);
5537
+ if (!c) return true;
5538
+ if (SHELLS2.has(c.head) && depth < MAX_DEPTH3) {
5539
+ const i = c.args.findIndex((w) => w === "-c" || w === "-lc" || w === "-ec");
5540
+ const payload = i >= 0 ? c.args[i + 1] : null;
5541
+ if (typeof payload === "string") walkCalls(payload, out, depth + 1, state);
5542
+ return true;
5543
+ }
5544
+ if (CWD_CHANGERS.has(c.head)) state.cwdChanged = true;
5545
+ classifyCall(c.head, c.args, c.prefixes, [...c.env, ...state.env], state, out);
5546
+ return true;
5547
+ });
5548
+ }
5549
+ function classifyShellExposure(command) {
5550
+ const out = {
5551
+ ...EMPTY,
5552
+ destinations: [],
5553
+ gitRemotes: [],
5554
+ repos: [],
5555
+ fetchDestinations: [],
5556
+ fetchKinds: []
5557
+ };
5558
+ try {
5559
+ walkCalls(command, out, 0, { cwdChanged: false, env: /* @__PURE__ */ new Set() });
5560
+ } catch {
5561
+ return { ...EMPTY };
5562
+ }
5563
+ out.destinations = [...new Set(out.destinations)];
5564
+ out.fetchDestinations = [...new Set(out.fetchDestinations)];
5565
+ out.gitRemotes = [...new Set(out.gitRemotes)];
5566
+ out.repos = [...new Set(out.repos)];
5567
+ out.fetchKinds = [...new Set(out.fetchKinds)];
5568
+ return out;
5569
+ }
4857
5570
  function stripTerminalEscapes(s) {
4858
5571
  return s.replace(TERMINAL_ESCAPE_RE, "");
4859
5572
  }
@@ -5013,8 +5726,8 @@ function matchCanaryArgs(args, values) {
5013
5726
  if (values.length === 0) return null;
5014
5727
  const needles = prepare(values);
5015
5728
  if (needles.length === 0) return null;
5016
- const walk = (v, depth, fieldPath) => {
5017
- if (depth > MAX_DEPTH3) return null;
5729
+ const walk2 = (v, depth, fieldPath) => {
5730
+ if (depth > MAX_DEPTH4) return null;
5018
5731
  if (typeof v === "string") {
5019
5732
  const hit = matchPrepared(v, needles);
5020
5733
  if (hit) return { id: hit.v.id, view: hit.view, fieldPath, retired: Boolean(hit.v.retired) };
@@ -5022,31 +5735,31 @@ function matchCanaryArgs(args, values) {
5022
5735
  const trimmed = v.trim();
5023
5736
  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
5024
5737
  try {
5025
- return walk(JSON.parse(v), depth + 1, fieldPath);
5738
+ return walk2(JSON.parse(v), depth + 1, fieldPath);
5026
5739
  } catch {
5027
5740
  }
5028
5741
  }
5029
5742
  }
5030
5743
  return null;
5031
5744
  }
5032
- if (typeof v === "number" && Number.isFinite(v)) return walk(String(v), depth, fieldPath);
5745
+ if (typeof v === "number" && Number.isFinite(v)) return walk2(String(v), depth, fieldPath);
5033
5746
  if (Array.isArray(v)) {
5034
5747
  for (let i = 0; i < v.length; i++) {
5035
- const h = walk(v[i], depth + 1, `${fieldPath}[${i}]`);
5748
+ const h = walk2(v[i], depth + 1, `${fieldPath}[${i}]`);
5036
5749
  if (h) return h;
5037
5750
  }
5038
5751
  return null;
5039
5752
  }
5040
5753
  if (v && typeof v === "object") {
5041
5754
  for (const [k, child] of Object.entries(v)) {
5042
- const h = walk(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
5755
+ const h = walk2(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
5043
5756
  if (h) return h;
5044
5757
  }
5045
5758
  }
5046
5759
  return null;
5047
5760
  };
5048
5761
  try {
5049
- return walk(args, 0, "");
5762
+ return walk2(args, 0, "");
5050
5763
  } catch (e) {
5051
5764
  console.error(
5052
5765
  "[node9 engine] canary args walk failed:",
@@ -5055,7 +5768,7 @@ function matchCanaryArgs(args, values) {
5055
5768
  return null;
5056
5769
  }
5057
5770
  }
5058
- var aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, SSRF_MAX_HOST, METADATA_ADDRESSES, STRICT_TIERS, METADATA_HOSTNAMES, v4Octets, TIER_REASON, CHECK_TEXT, VERDICTS, CHECK_VERDICT_RANK, ALL, BUILTIN_DANGEROUS_WORDS, PRODUCT_CHECKS, SHIELD_RULE_NAME, SHIELD_RULES_FOLDED_INTO_PRODUCT, CHECKS, CHECK_BY_ID, LEGACY_TO_ID, PROJECT_JAIL_BLOCK_READ, OVERRIDE_PREFIX, LABEL_WRAPPERS, LEGACY_READERS, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, defaultExpansionOps, VARIABLE_READERS, FS_READ_TOOLS, GREP_SHAPE, PATTERN_VERBS, PATTERN_VERB_NAMES, GREP_FILE_OPERANDS, READER_VALUE_LETTERS, FILE_OPERAND_FLAGS, SCP_VALUE_FLAGS, TAR_VALUE_LETTERS, ZIP_VALUE_LETTERS, RSYNC_VALUE_LETTERS, RSYNC_SKIP, CP_VALUE_LETTERS, INSTALL_VALUE_LETTERS, COPY_VERBS, TAR_MODE_WORD, COPY_VERB_HEADS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, AST_FS_REGEX_RULES, SQL_DB_CLIS, SQL_DDL_RE, CHMOD_OPEN_PERM_TOKENS, COMMAND_WRAPPERS, INLINE_INTERPRETER, RUNNER_WRAPPERS, _redirStdinOps, _listOps, WRAPPER_TAKES_TARGET, FIND_EXEC_FLAGS, FS_MUTATION_TOOLS, INTERP_LEADING_TARGET, NET_BINARIES, VALUE_FLAGS, HOME_VARIABLES, assignmentTable, currentStmtOffset, PAYLOAD_BUDGET, payloadBudget, seenPayloads, UNANALYSABLE_NESTING, ASSIGNMENT_HEADS, AND_OP, OR_OP, AND_OR_OPS, FS_OP_CACHE_MAX, fsOpCache, stripDotSlash, REDIR_TRUNCATE_OPS, REDIR_FILE_IN_OPS, REDIR_HEREDOC_OPS, FIND_OPTIONS, COPY_RULE_OF, isReaderWord, positionalAfter, NONE, UNKNOWN, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, BASH_TOOL_NAMES, MCP_EXECUTORS, syntax2, MAX_ALTERNATIVES, MAX_DEPTH, MAX_PAYLOAD_DEPTH, unique, clone, base, quoteGlob, asWord, WRITES, READS, DUP_OUT, HERE_STRING, AND, OR, DEFAULT_OPS, SYSTEM_AUTH_FILES, SYSTEM_AUTH_DIRS, PASSWORD_HASH_FILES, STARTUP_NAMES, LEGACY_SYSTEM_PATH_RE, RANK, BIP39_ENGLISH, BIP39_PUBLIC_VECTORS, indices, lengths, hash, IBAN_LENGTH, B58, B58_INDEX, XPRV_VERSIONS, B62, CRC32_TABLE, GITHUB_TOKEN_RE, B64URL, B64URL_INDEX, CASK_TWO_ZERO_SUFFIX, CASK_FOUR_ZERO_SUFFIX, INVISIBLE_RE, HOMOGLYPHS, HOMOGLYPH_RE, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, REFERENCE_VALUE_RE, IDENTIFIER_VALUE_RE, PATH_VALUE_RE, DOTTED_REFERENCE_RE, EMAIL_VALUE_RE, FIXTURE_NAME_RE, DERIVATIVE_NAME_RE, QUERY_CREDENTIAL_VALUE_RE, OTP_SECRET_RE, ASSIGNED_SECRET_VALUE_RE, ASSIGNED_SECRET_NAME_RE, NAME_KEYWORD_RE, ARN_CONTEXT_RE, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, SENSITIVE_PATH_REGEXES, MAX_DEPTH2, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, syntax3, NPM_INSTALL_VERBS, NPM_EXEC_VERBS, PNPM_INSTALL_VERBS, YARN_INSTALL_VERBS, BUN_INSTALL_VERBS, DLX_VERBS, NON_REGISTRY_RE, NPM_NAME_RE, EXACT_SEMVER_RE, NPM_VALUE_FLAGS, PNPM_VALUE_FLAGS, YARN_VALUE_FLAGS, BUN_VALUE_FLAGS, PIP_VALUE_FLAGS, UV_VALUE_FLAGS, POETRY_VALUE_FLAGS, PIPENV_VALUE_FLAGS, PIPX_VALUE_FLAGS, PY_SPEC_RE, PY_EXACT_RE, MANAGER_HEADS, SHELLS, MAX_NESTING, SHELL_VALUE_FLAGS, TUNING_FIELDS, MAP_GOVERNED, KNOB_GOVERNED, CONFIGURABLE_CHECK_IDS, DEFAULT_EGRESS_ALLOWLIST, PRIVATE_HOST_SUFFIXES, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, DESTINATION_ARGS, SECRET_VIEW_LIMITS, ARGUMENT_SCAN_LIMITS, VERDICT_RANK, SQL_DML_KEYWORDS, TERMINAL_ESCAPE_RE, LOOP_MAX_RECORDS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC16_RE, PII_CC15_RE, PII_IBAN_RE, REALTIME_PII_PATTERNS, MAX_PII_SCAN_BYTES, CANARY_MIN_LENGTH, MAX_TEXT, MAX_DEPTH3, MAX_JSON_PARSE, URL_DEPTH, B64_DEPTH, MIN_SEGMENT, SEPARATORS, stripSeparators, looksText, CANARY_DECODERS, VIEWS, LONG_OUTPUT_THRESHOLD_BYTES;
5771
+ var aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, SSRF_MAX_HOST, METADATA_ADDRESSES, STRICT_TIERS, METADATA_HOSTNAMES, v4Octets, TIER_REASON, CHECK_TEXT, VERDICTS, CHECK_VERDICT_RANK, ALL, BUILTIN_DANGEROUS_WORDS, PRODUCT_CHECKS, SHIELD_RULE_NAME, SHIELD_RULES_FOLDED_INTO_PRODUCT, CHECKS, CHECK_BY_ID, LEGACY_TO_ID, PROJECT_JAIL_BLOCK_READ, OVERRIDE_PREFIX, LABEL_WRAPPERS, LEGACY_READERS, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, defaultExpansionOps, VARIABLE_READERS, FS_READ_TOOLS, GREP_SHAPE, PATTERN_VERBS, PATTERN_VERB_NAMES, GREP_FILE_OPERANDS, READER_VALUE_LETTERS, FILE_OPERAND_FLAGS, SCP_VALUE_FLAGS, TAR_VALUE_LETTERS, ZIP_VALUE_LETTERS, RSYNC_VALUE_LETTERS, RSYNC_SKIP, CP_VALUE_LETTERS, INSTALL_VALUE_LETTERS, COPY_VERBS, TAR_MODE_WORD, COPY_VERB_HEADS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, AST_FS_REGEX_RULES, SQL_DB_CLIS, SQL_DDL_RE, CHMOD_OPEN_PERM_TOKENS, COMMAND_WRAPPERS, INLINE_INTERPRETER, RUNNER_WRAPPERS, _redirStdinOps, _listOps, WRAPPER_TAKES_TARGET, FIND_EXEC_FLAGS, FS_MUTATION_TOOLS, INTERP_LEADING_TARGET, NET_BINARIES, VALUE_FLAGS, HOME_VARIABLES, assignmentTable, currentStmtOffset, PAYLOAD_BUDGET, payloadBudget, seenPayloads, UNANALYSABLE_NESTING, ASSIGNMENT_HEADS, AND_OP, OR_OP, AND_OR_OPS, FS_OP_CACHE_MAX, fsOpCache, stripDotSlash, REDIR_TRUNCATE_OPS, REDIR_FILE_IN_OPS, REDIR_HEREDOC_OPS, FIND_OPTIONS, COPY_RULE_OF, isReaderWord, positionalAfter, NONE, UNKNOWN, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, BASH_TOOL_NAMES, MCP_EXECUTORS, BUILTIN_FIELDS, ARGV_FIELDS, BARE_WORD, MAX_ARGV_CHARACTERS, syntax2, MAX_ALTERNATIVES, MAX_DEPTH, MAX_PAYLOAD_DEPTH, unique, clone, base, quoteGlob, asWord, WRITES, READS, DUP_OUT, HERE_STRING, AND, OR, DEFAULT_OPS, SYSTEM_AUTH_FILES, SYSTEM_AUTH_DIRS, PASSWORD_HASH_FILES, STARTUP_NAMES, AGENT_INSTRUCTION_FILES, AGENT_INSTRUCTION_DIRS, LEGACY_SYSTEM_PATH_RE, RANK, BIP39_ENGLISH, BIP39_PUBLIC_VECTORS, indices, lengths, hash, MAX_MNEMONIC_HASHES, IBAN_LENGTH, B58, B58_INDEX, XPRV_VERSIONS, B62, CRC32_TABLE, GITHUB_TOKEN_RE, B64URL, B64URL_INDEX, CASK_TWO_ZERO_SUFFIX, CASK_FOUR_ZERO_SUFFIX, INVISIBLE_RE, INVISIBLE_ONE, HOMOGLYPHS, HOMOGLYPH_RE, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, REFERENCE_VALUE_RE, IDENTIFIER_VALUE_RE, PATH_VALUE_RE, DOTTED_REFERENCE_RE, EMAIL_VALUE_RE, FIXTURE_NAME_RE, DERIVATIVE_NAME_RE, QUERY_CREDENTIAL_VALUE_RE, OTP_SECRET_RE, ASSIGNED_SECRET_VALUE_RE, ASSIGNED_SECRET_NAME_RE, NAME_KEYWORD_RE, ARN_CONTEXT_RE, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, SENSITIVE_PATH_REGEXES, MAX_DEPTH2, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, BATCH_SEPARATOR, syntax3, NPM_INSTALL_VERBS, NPM_EXEC_VERBS, PNPM_INSTALL_VERBS, YARN_INSTALL_VERBS, BUN_INSTALL_VERBS, DLX_VERBS, NON_REGISTRY_RE, NPM_NAME_RE, EXACT_SEMVER_RE, NPM_VALUE_FLAGS, PNPM_VALUE_FLAGS, YARN_VALUE_FLAGS, BUN_VALUE_FLAGS, PIP_VALUE_FLAGS, UV_VALUE_FLAGS, POETRY_VALUE_FLAGS, PIPENV_VALUE_FLAGS, PIPX_VALUE_FLAGS, PY_SPEC_RE, PY_EXACT_RE, MANAGER_HEADS, SHELLS, MAX_NESTING, SHELL_VALUE_FLAGS, TUNING_FIELDS, MAP_GOVERNED, KNOB_GOVERNED, CONFIGURABLE_CHECK_IDS, DEFAULT_EGRESS_ALLOWLIST, PRIVATE_HOST_SUFFIXES, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, DESTINATION_ARGS, SECRET_VIEW_LIMITS, ARGUMENT_SCAN_LIMITS, RUN_CHAR, runChar, VERDICT_RANK, SQL_DML_KEYWORDS, MAX_DESTINATIONS, MAX_SCAN_CHARS, EMAIL_RE, URL_RE, IBAN_RE, RECIPIENT_KEYS, DESTRUCTIVE_VERB_RE, syntax4, EMPTY, CURL_SEND_FLAGS, WGET_SEND_FLAGS, IWR_SEND_FLAGS, WRITE_METHODS, GH_READ, GH_WRITE, GH_HOSTNAME_FLAGS, GH_REPO_VALUE_FLAGS, GH_API_VALUE_FLAGS, GIT_FETCH, PUBLISH, SHELLS2, OWN_HEADS, MAX_DEPTH3, CWD_CHANGERS, TERMINAL_ESCAPE_RE, LOOP_MAX_RECORDS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC16_RE, PII_CC15_RE, PII_IBAN_RE, REALTIME_PII_PATTERNS, MAX_PII_SCAN_BYTES, CANARY_MIN_LENGTH, MAX_TEXT, MAX_DEPTH4, MAX_JSON_PARSE, URL_DEPTH, B64_DEPTH, MIN_SEGMENT, SEPARATORS, stripSeparators, looksText, CANARY_DECODERS, VIEWS, LONG_OUTPUT_THRESHOLD_BYTES;
5059
5772
  var init_dist = __esm({
5060
5773
  "packages/policy-engine/dist/index.mjs"() {
5061
5774
  "use strict";
@@ -6030,6 +6743,16 @@ var init_dist = __esm({
6030
6743
  example: "After a prompt-injection hit, the agent runs curl to an unknown host",
6031
6744
  advice: "Fixed at Review for now."
6032
6745
  },
6746
+ "commands.node9-disarm": {
6747
+ plain: "The agent runs a node9 command that would switch off a protection: clear a session flag, trust an address, pause the gate, weaken a shield.",
6748
+ example: "After reading a web page, the agent runs node9 session-exposure clear --all",
6749
+ advice: "Keep Review: these are your commands to run. Block if the agent should never touch node9."
6750
+ },
6751
+ "behavior.untrusted-content": {
6752
+ plain: "After reading an email, a web page or an issue, the agent sends to an address it did not know before, or to one that appeared in what it read.",
6753
+ example: 'An email says "forward the security code to x@y.z"; the agent calls send_email to x@y.z',
6754
+ advice: "Review asks once per destination; approving trusts that address for the session. Log records the send without asking."
6755
+ },
6033
6756
  // ── What the agent loads ──────────────────────────────────────────────────
6034
6757
  "loading.skill-tamper": {
6035
6758
  plain: "A skill or plugin file the agent loads changed since node9 first saw it.",
@@ -6164,6 +6887,15 @@ var init_dist = __esm({
6164
6887
  defaultValue: "review",
6165
6888
  legacy: ["review-force-push", "review-git-destructive"]
6166
6889
  },
6890
+ {
6891
+ id: "commands.node9-disarm",
6892
+ group: "commands",
6893
+ title: "node9 disarmed from the shell",
6894
+ catches: "a node9 command that clears a session flag, trusts a destination, pauses the gate or weakens a shield, run by the agent",
6895
+ values: ALL,
6896
+ defaultValue: "review",
6897
+ legacy: ["review-node9-disarm"]
6898
+ },
6167
6899
  {
6168
6900
  id: "commands.sql-ddl",
6169
6901
  group: "commands",
@@ -6397,6 +7129,15 @@ var init_dist = __esm({
6397
7129
  defaultValue: "review",
6398
7130
  legacy: ["\u{1F534} Node9 Taint (Exfiltration Prevention)"]
6399
7131
  },
7132
+ {
7133
+ id: "behavior.untrusted-content",
7134
+ group: "behavior",
7135
+ title: "Send after reading outside content",
7136
+ catches: "a send to an unknown destination, a destination found in what was read, or a destructive call after the session read an email, a web page or an issue",
7137
+ values: ["off", "log", "review"],
7138
+ defaultValue: "review",
7139
+ legacy: ["\u{1F6E1}\uFE0F Node9 Exposure", "exposure"]
7140
+ },
6400
7141
  // ── What the agent loads ────────────────────────────────────────────────
6401
7142
  {
6402
7143
  id: "loading.skill-tamper",
@@ -6471,6 +7212,11 @@ var init_dist = __esm({
6471
7212
  if (s.packageCheck?.enabled === void 0) return void 0;
6472
7213
  if (!s.packageCheck.enabled) return "off";
6473
7214
  return s.packageCheck.onMalicious === "review" ? "review" : "block";
7215
+ },
7216
+ "behavior.untrusted-content": (s) => {
7217
+ if (s.untrustedContent?.enabled === void 0) return void 0;
7218
+ if (!s.untrustedContent.enabled) return "off";
7219
+ return s.untrustedContent.afterExposure === "log-only" ? "log" : "review";
6474
7220
  }
6475
7221
  };
6476
7222
  ({ syntax } = mvdanSh);
@@ -7387,7 +8133,23 @@ var init_dist = __esm({
7387
8133
  "shell",
7388
8134
  "exec_command"
7389
8135
  ]);
7390
- MCP_EXECUTORS = /* @__PURE__ */ new Set([...BASH_TOOL_NAMES, "exec", "run_command", "terminal"]);
8136
+ MCP_EXECUTORS = /* @__PURE__ */ new Set([
8137
+ ...BASH_TOOL_NAMES,
8138
+ "exec",
8139
+ "run_command",
8140
+ "terminal",
8141
+ "execute_command",
8142
+ "execute_shell",
8143
+ "execute_shell_command",
8144
+ "shell_execute",
8145
+ "run_shell",
8146
+ "run_terminal_command",
8147
+ "start_process"
8148
+ ]);
8149
+ BUILTIN_FIELDS = ["command", "cmd", "script"];
8150
+ ARGV_FIELDS = ["args", "argv"];
8151
+ BARE_WORD = /^[A-Za-z0-9_./+:@%,-]+$/;
8152
+ MAX_ARGV_CHARACTERS = 262144;
7391
8153
  ({ syntax: syntax2 } = mvdanSh2);
7392
8154
  MAX_ALTERNATIVES = 64;
7393
8155
  MAX_DEPTH = 64;
@@ -7418,6 +8180,22 @@ var init_dist = __esm({
7418
8180
  SYSTEM_AUTH_DIRS = ["/etc/security", "/etc/pam.d", "/etc/sudoers.d"];
7419
8181
  PASSWORD_HASH_FILES = ["/etc/shadow", "/etc/gshadow"];
7420
8182
  STARTUP_NAMES = [".bashrc", ".zshrc", ".profile", ".bash_profile"];
8183
+ AGENT_INSTRUCTION_FILES = [
8184
+ ".gitlab-ci.yml",
8185
+ "CLAUDE.md",
8186
+ "AGENTS.md",
8187
+ ".cursorrules",
8188
+ ".mcp.json",
8189
+ ".vscode/settings.json",
8190
+ "package.json"
8191
+ ];
8192
+ AGENT_INSTRUCTION_DIRS = [
8193
+ ".github/workflows",
8194
+ ".claude",
8195
+ ".cursor",
8196
+ ".git/hooks",
8197
+ ".husky"
8198
+ ];
7421
8199
  LEGACY_SYSTEM_PATH_RE = /^(?:[a-zA-Z]:)?\/etc\/(?:passwd|shadow|sudoers)$/;
7422
8200
  RANK = { allow: 0, review: 1, block: 2 };
7423
8201
  BIP39_ENGLISH = `abandon
@@ -9497,6 +10275,7 @@ zoo`.split("\n");
9497
10275
  indices = new Map(BIP39_ENGLISH.map((word, i) => [word, i]));
9498
10276
  lengths = [24, 21, 18, 15, 12];
9499
10277
  hash = (value) => createHash2("sha256").update(value).digest();
10278
+ MAX_MNEMONIC_HASHES = 25e3;
9500
10279
  IBAN_LENGTH = {
9501
10280
  AD: 24,
9502
10281
  AE: 23,
@@ -9604,6 +10383,7 @@ zoo`.split("\n");
9604
10383
  CASK_TWO_ZERO_SUFFIX = /* @__PURE__ */ new Set([..."AEIMQUYcgkosw048"]);
9605
10384
  CASK_FOUR_ZERO_SUFFIX = /* @__PURE__ */ new Set([..."AQgw"]);
9606
10385
  INVISIBLE_RE = /[­͏؜᠎​-‏‪-‮⁠-⁤-︀-️]|[\u{E0000}-\u{E007F}]/gu;
10386
+ INVISIBLE_ONE = new RegExp(`^(?:${INVISIBLE_RE.source.split("|")[0]})$`, "u");
9607
10387
  HOMOGLYPHS = {
9608
10388
  // Cyrillic lowercase
9609
10389
  \u0430: "a",
@@ -10262,6 +11042,7 @@ zoo`.split("\n");
10262
11042
  MAX_DEPTH2 = 5;
10263
11043
  MAX_STRING_BYTES = 1e5;
10264
11044
  MAX_JSON_PARSE_BYTES = 1e4;
11045
+ BATCH_SEPARATOR = "\n";
10265
11046
  ({ syntax: syntax3 } = mvdanSh3);
10266
11047
  NPM_INSTALL_VERBS = /* @__PURE__ */ new Set([
10267
11048
  "install",
@@ -10517,7 +11298,25 @@ zoo`.split("\n");
10517
11298
  name: "onlineFallback",
10518
11299
  legacy: ["packageCheck", "onlineFallback"]
10519
11300
  },
10520
- { checkId: "loading.malicious-package", name: "allow", legacy: ["packageCheck", "allow"] }
11301
+ { checkId: "loading.malicious-package", name: "allow", legacy: ["packageCheck", "allow"] },
11302
+ // fw#318: the verdict carries off / log (log-only) / review; the tuning
11303
+ // carries whether a review asks once per destination or every time, the
11304
+ // frame around outside content, and the organisation's own domains.
11305
+ {
11306
+ checkId: "behavior.untrusted-content",
11307
+ name: "afterExposure",
11308
+ legacy: ["untrustedContent", "afterExposure"]
11309
+ },
11310
+ {
11311
+ checkId: "behavior.untrusted-content",
11312
+ name: "spotlight",
11313
+ legacy: ["untrustedContent", "spotlight"]
11314
+ },
11315
+ {
11316
+ checkId: "behavior.untrusted-content",
11317
+ name: "orgDomains",
11318
+ legacy: ["untrustedContent", "orgDomains"]
11319
+ }
10521
11320
  ];
10522
11321
  MAP_GOVERNED = /* @__PURE__ */ new Set([
10523
11322
  "files.shell-startup",
@@ -10534,6 +11333,7 @@ zoo`.split("\n");
10534
11333
  "commands.temp-binary",
10535
11334
  "commands.disk-destroy",
10536
11335
  "commands.dangerous-word",
11336
+ "commands.node9-disarm",
10537
11337
  "data.pipe-chain"
10538
11338
  ]);
10539
11339
  KNOB_GOVERNED = {
@@ -10545,7 +11345,8 @@ zoo`.split("\n");
10545
11345
  "behavior.loops": ["off", "block"],
10546
11346
  "behavior.prompt-injection": ["off", "log"],
10547
11347
  "loading.skill-tamper": ["off", "log", "block"],
10548
- "loading.malicious-package": ["off", "review", "block"]
11348
+ "loading.malicious-package": ["off", "review", "block"],
11349
+ "behavior.untrusted-content": ["off", "log", "review"]
10549
11350
  };
10550
11351
  CONFIGURABLE_CHECK_IDS = CHECKS.filter(
10551
11352
  (c) => configurableValues(c.id).length > 0
@@ -10711,16 +11512,24 @@ zoo`.split("\n");
10711
11512
  ["browser_batch", ["actions[].input.url"]]
10712
11513
  ]);
10713
11514
  SECRET_VIEW_LIMITS = {
11515
+ /** A decoded text that itself decodes is followed this many layers; a deeper layer is reviewed. */
10714
11516
  maxDepth: 4,
10715
- maxViewsPerLeaf: 64,
10716
- maxViews: 256,
10717
- maxDerivedBytes: 1048576,
10718
- maxCandidates: 4096
11517
+ /** Nested views per parent view, and nested text per call; past either the call is reviewed. */
11518
+ maxNestedViewsPerLeaf: 64,
11519
+ maxNestedBytes: 1048576,
11520
+ /** Joined hostname-label text per call; past it the call is reviewed. */
11521
+ maxJoinedLabelBytes: 1048576,
11522
+ /** Decoded fragments are matched in batches of about this many characters. */
11523
+ batchCharacters: 65536,
11524
+ /** Only a valid DNS name has its labels joined. */
11525
+ maxHostLength: 253,
11526
+ maxLabelLength: 63,
11527
+ maxLabels: 127
10719
11528
  };
10720
11529
  ARGUMENT_SCAN_LIMITS = {
10721
11530
  maxDepth: 12,
10722
11531
  maxNodes: 16384,
10723
- /** Raw text is matched in windows of this size; decoding views use the first window. */
11532
+ /** Text is matched and decoded in windows of this size. */
10724
11533
  maxStringCharacters: 1e5,
10725
11534
  /** Windows overlap by this much so a token on a window edge is still seen whole. */
10726
11535
  windowOverlap: 4096,
@@ -10730,25 +11539,176 @@ zoo`.split("\n");
10730
11539
  maxTailBytes: 4194304,
10731
11540
  maxJsonCharacters: 1e4
10732
11541
  };
11542
+ RUN_CHAR = /[A-Za-z0-9+/=_%\\.-]/;
11543
+ runChar = (value, index) => {
11544
+ const char = value[index] ?? "";
11545
+ return RUN_CHAR.test(char) || char !== "" && isInvisibleChar(char);
11546
+ };
10733
11547
  VERDICT_RANK = {
10734
11548
  allow: 0,
10735
11549
  review: 1,
10736
11550
  block: 2
10737
11551
  };
10738
11552
  SQL_DML_KEYWORDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "merge", "upsert"]);
10739
- TERMINAL_ESCAPE_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
10740
- LOOP_MAX_RECORDS = 500;
10741
- PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
10742
- PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
10743
- PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
10744
- PII_CC16_RE = /\b(?:4\d{3}|5[1-5]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
10745
- PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
11553
+ MAX_DESTINATIONS = 200;
11554
+ MAX_SCAN_CHARS = 1e5;
11555
+ EMAIL_RE = /[\p{L}\p{N}._%+-]+@[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?)*\.\p{L}{2,}/gu;
11556
+ URL_RE = /\b(?:[a-z][a-z0-9+.-]*:\/\/|www\.)[^\s<>"'`){}]+/gi;
11557
+ IBAN_RE = /\b[A-Z]{2}\d{2}(?:[ -]?[A-Z0-9]{4}){2,7}(?:[ -]?[A-Z0-9]{1,4})?\b/g;
11558
+ RECIPIENT_KEYS = /* @__PURE__ */ new Set([
11559
+ "to",
11560
+ "cc",
11561
+ "bcc",
11562
+ "recipient",
11563
+ "recipients",
11564
+ "email",
11565
+ "participants",
11566
+ "attendees",
11567
+ "destination",
11568
+ "iban",
11569
+ "account",
11570
+ // Where a request goes, when the value is a bare host or a scheme-less URL.
11571
+ "url",
11572
+ "uri",
11573
+ "host",
11574
+ "hostname",
11575
+ "endpoint",
11576
+ "server",
11577
+ "domain",
11578
+ "address",
11579
+ "webhook",
11580
+ "webhook_url",
11581
+ "callback",
11582
+ "callback_url",
11583
+ "target"
11584
+ ]);
11585
+ DESTRUCTIVE_VERB_RE = /^(?:delete|remove|drop|destroy|purge|wipe|erase|transfer|pay|send_money|withdraw|change_password|update_password|reset_password|set_permission|grant|revoke|share)(?:_|$)/i;
11586
+ ({ syntax: syntax4 } = mvdanSh4);
11587
+ EMPTY = {
11588
+ fetches: false,
11589
+ sends: false,
11590
+ destinations: [],
11591
+ gitRemotes: [],
11592
+ repos: [],
11593
+ unresolved: false,
11594
+ fetchDestinations: [],
11595
+ fetchKinds: []
11596
+ };
11597
+ CURL_SEND_FLAGS = /* @__PURE__ */ new Set([
11598
+ "-d",
11599
+ "--data",
11600
+ "--data-ascii",
11601
+ "--data-binary",
11602
+ "--data-raw",
11603
+ "--data-urlencode",
11604
+ "-F",
11605
+ "--form",
11606
+ "--form-string",
11607
+ "-T",
11608
+ "--upload-file",
11609
+ "--json"
11610
+ ]);
11611
+ WGET_SEND_FLAGS = /* @__PURE__ */ new Set(["--post-data", "--post-file", "--body-data", "--body-file"]);
11612
+ IWR_SEND_FLAGS = /* @__PURE__ */ new Set(["-body", "-infile"]);
11613
+ WRITE_METHODS = /* @__PURE__ */ new Set(["post", "put", "patch", "delete"]);
11614
+ GH_READ = /* @__PURE__ */ new Set(["view", "list", "diff", "checks", "status", "download", "clone"]);
11615
+ GH_WRITE = /* @__PURE__ */ new Set([
11616
+ "create",
11617
+ "comment",
11618
+ "edit",
11619
+ "close",
11620
+ "reopen",
11621
+ "merge",
11622
+ "review",
11623
+ "upload",
11624
+ "delete",
11625
+ "transfer",
11626
+ "pin",
11627
+ "lock",
11628
+ "ready",
11629
+ "set",
11630
+ "sync",
11631
+ "fork"
11632
+ ]);
11633
+ GH_HOSTNAME_FLAGS = /* @__PURE__ */ new Set(["--hostname", "-h", "-R", "--repo"]);
11634
+ GH_REPO_VALUE_FLAGS = /* @__PURE__ */ new Set([
11635
+ "--hostname",
11636
+ "-R",
11637
+ "--repo",
11638
+ "-d",
11639
+ "--description",
11640
+ "-h",
11641
+ "--homepage",
11642
+ "--visibility",
11643
+ "--default-branch",
11644
+ "--add-topic",
11645
+ "--remove-topic",
11646
+ "-p",
11647
+ "--template",
11648
+ "-s",
11649
+ "--source",
11650
+ "-b",
11651
+ "--branch",
11652
+ "-l",
11653
+ "--license",
11654
+ "-g",
11655
+ "--gitignore",
11656
+ "-t",
11657
+ "--team",
11658
+ "-r",
11659
+ "--remote",
11660
+ "--org"
11661
+ ]);
11662
+ GH_API_VALUE_FLAGS = /* @__PURE__ */ new Set([
11663
+ "--hostname",
11664
+ "-X",
11665
+ "--method",
11666
+ "-f",
11667
+ "--raw-field",
11668
+ "-F",
11669
+ "--field",
11670
+ "-H",
11671
+ "--header",
11672
+ "--input",
11673
+ "-q",
11674
+ "--jq",
11675
+ "-t",
11676
+ "--template",
11677
+ "--cache",
11678
+ "-p",
11679
+ "--preview"
11680
+ ]);
11681
+ GIT_FETCH = /* @__PURE__ */ new Set(["clone", "fetch", "pull", "ls-remote"]);
11682
+ PUBLISH = {
11683
+ npm: { verbs: /* @__PURE__ */ new Set(["publish"]), registry: "registry.npmjs.org", flag: ["--registry"] },
11684
+ pnpm: { verbs: /* @__PURE__ */ new Set(["publish"]), registry: "registry.npmjs.org", flag: ["--registry"] },
11685
+ yarn: {
11686
+ verbs: /* @__PURE__ */ new Set(["publish", "npm"]),
11687
+ registry: "registry.npmjs.org",
11688
+ flag: ["--registry"]
11689
+ },
11690
+ twine: { verbs: /* @__PURE__ */ new Set(["upload"]), registry: "upload.pypi.org", flag: ["--repository-url"] },
11691
+ cargo: { verbs: /* @__PURE__ */ new Set(["publish"]), registry: "crates.io", flag: ["--registry", "--index"] },
11692
+ gem: { verbs: /* @__PURE__ */ new Set(["push"]), registry: "rubygems.org", flag: ["--host"] },
11693
+ docker: { verbs: /* @__PURE__ */ new Set(["push"]), registry: "docker.io" }
11694
+ };
11695
+ SHELLS2 = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ksh"]);
11696
+ OWN_HEADS = /* @__PURE__ */ new Set([...Object.keys(PUBLISH), "git", "gh"]);
11697
+ MAX_DEPTH3 = 3;
11698
+ CWD_CHANGERS = /* @__PURE__ */ new Set(["cd", "pushd", "popd"]);
11699
+ TERMINAL_ESCAPE_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
11700
+ LOOP_MAX_RECORDS = 500;
11701
+ PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
11702
+ PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
11703
+ PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
11704
+ PII_CC16_RE = /\b(?:4\d{3}|5[1-5]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
11705
+ PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
10746
11706
  PII_IBAN_RE = /\b[A-Z]{2}\d{2}(?:[A-Z0-9]|[ -][A-Z0-9]){11,30}\b/;
10747
11707
  REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
10748
11708
  MAX_PII_SCAN_BYTES = 1e5;
10749
11709
  CANARY_MIN_LENGTH = 16;
10750
11710
  MAX_TEXT = 1e5;
10751
- MAX_DEPTH3 = 6;
11711
+ MAX_DEPTH4 = 6;
10752
11712
  MAX_JSON_PARSE = 1e4;
10753
11713
  URL_DEPTH = 4;
10754
11714
  B64_DEPTH = 3;
@@ -10860,8 +11820,8 @@ function truncateAuditPreview(text) {
10860
11820
  }
10861
11821
  function appendToLog(logPath, entry) {
10862
11822
  try {
10863
- const dir = path.dirname(logPath);
10864
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
11823
+ const dir2 = path.dirname(logPath);
11824
+ if (!fs.existsSync(dir2)) fs.mkdirSync(dir2, { recursive: true });
10865
11825
  fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
10866
11826
  } catch {
10867
11827
  }
@@ -10928,6 +11888,10 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
10928
11888
  taintFromEid: meta.taintFromEid,
10929
11889
  ...meta.taintSource && { taintSource: meta.taintSource }
10930
11890
  } : {};
11891
+ const exposureFields = {
11892
+ ...meta?.exposure ? { exposure: meta.exposure } : {},
11893
+ ...meta?.exposureFromEid ? { exposureFromEid: meta.exposureFromEid } : {}
11894
+ };
10931
11895
  const eid = generateEventId();
10932
11896
  appendToLog(LOCAL_AUDIT_LOG, {
10933
11897
  // eid first: the outbox shipper dedups on it, and a fixed leading field
@@ -10950,6 +11914,7 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
10950
11914
  ...loopCountField,
10951
11915
  ...transcriptPathField,
10952
11916
  ...taintFields,
11917
+ ...exposureFields,
10953
11918
  ...testRun,
10954
11919
  agent: meta?.agent,
10955
11920
  mcpServer: meta?.mcpServer,
@@ -10981,10 +11946,72 @@ var init_audit = __esm({
10981
11946
  // src/core.ts
10982
11947
  init_audit();
10983
11948
 
11949
+ // src/auth/trusted-recipients.ts
11950
+ init_dist();
11951
+ import fs2 from "fs";
11952
+ import path2 from "path";
11953
+ import os2 from "os";
11954
+ function getTrustedRecipientsPath() {
11955
+ return path2.join(os2.homedir(), ".node9", "trusted-recipients.json");
11956
+ }
11957
+ function readTrustedRecipients() {
11958
+ try {
11959
+ const raw = fs2.readFileSync(getTrustedRecipientsPath(), "utf8");
11960
+ const parsed = JSON.parse(raw);
11961
+ return Array.isArray(parsed.entries) ? parsed.entries.filter((e) => e && typeof e.value === "string") : [];
11962
+ } catch {
11963
+ return [];
11964
+ }
11965
+ }
11966
+ function writeTrustedRecipients(entries) {
11967
+ const filePath = getTrustedRecipientsPath();
11968
+ fs2.mkdirSync(path2.dirname(filePath), { recursive: true });
11969
+ const tmp = filePath + ".node9-tmp";
11970
+ fs2.writeFileSync(tmp, JSON.stringify({ entries }, null, 2), { mode: 384 });
11971
+ fs2.renameSync(tmp, filePath);
11972
+ }
11973
+ function normalizeRecipient(raw) {
11974
+ const v = raw.trim();
11975
+ if (!v) return null;
11976
+ if (/^(email|host|iban):/i.test(v)) {
11977
+ const i = v.indexOf(":");
11978
+ const kind = v.slice(0, i).toLowerCase();
11979
+ const inner = normalizeRecipient(v.slice(i + 1));
11980
+ return inner && inner.startsWith(`${kind}:`) ? inner : null;
11981
+ }
11982
+ const found = extractArgDestinations({ to: v });
11983
+ return found.length === 1 ? found[0] : null;
11984
+ }
11985
+ function addTrustedRecipient(raw, addedBy = "user") {
11986
+ const value = normalizeRecipient(raw);
11987
+ if (!value) return null;
11988
+ const entries = readTrustedRecipients();
11989
+ if (!entries.some((e) => e.value === value)) {
11990
+ entries.push({ value, addedAt: Date.now(), addedBy });
11991
+ writeTrustedRecipients(entries);
11992
+ }
11993
+ return value;
11994
+ }
11995
+ var cache = null;
11996
+ function mtimeOf() {
11997
+ try {
11998
+ return fs2.statSync(getTrustedRecipientsPath()).mtimeMs;
11999
+ } catch {
12000
+ return 0;
12001
+ }
12002
+ }
12003
+ function isTrustedRecipient(value) {
12004
+ const mtime = mtimeOf();
12005
+ if (!cache || cache.mtime !== mtime) {
12006
+ cache = { mtime, values: new Set(readTrustedRecipients().map((e) => e.value)) };
12007
+ }
12008
+ return cache.values.has(value);
12009
+ }
12010
+
10984
12011
  // src/config/index.ts
10985
- import fs4 from "fs";
10986
- import path4 from "path";
10987
- import os4 from "os";
12012
+ import fs5 from "fs";
12013
+ import path5 from "path";
12014
+ import os5 from "os";
10988
12015
 
10989
12016
  // src/config-schema.ts
10990
12017
  import { z } from "zod";
@@ -11154,6 +12181,13 @@ var ConfigFileSchema = z.object({
11154
12181
  onlineFallback: z.boolean().optional(),
11155
12182
  allow: z.array(z.string()).optional()
11156
12183
  }).optional(),
12184
+ untrustedContent: z.object({
12185
+ enabled: z.boolean().optional(),
12186
+ spotlight: z.boolean().optional(),
12187
+ afterExposure: z.enum(["ask-once", "ask-every-time", "log-only"]).optional(),
12188
+ orgDomains: z.array(z.string()).optional(),
12189
+ roles: z.record(z.string(), z.record(z.string(), z.enum(["source", "action", "read"]))).optional()
12190
+ }).optional(),
11157
12191
  skillPinning: z.object({
11158
12192
  enabled: z.boolean().optional(),
11159
12193
  mode: z.enum(["warn", "block"]).optional(),
@@ -11164,8 +12198,8 @@ var ConfigFileSchema = z.object({
11164
12198
  }).strict();
11165
12199
  function formatIssues(issues) {
11166
12200
  const lines = issues.map((issue) => {
11167
- const path20 = issue.path.length > 0 ? issue.path.map(String).join(".") : "root";
11168
- return ` \u2022 ${path20}: ${issue.message}`;
12201
+ const path26 = issue.path.length > 0 ? issue.path.map(String).join(".") : "root";
12202
+ return ` \u2022 ${path26}: ${issue.message}`;
11169
12203
  });
11170
12204
  return `Invalid config:
11171
12205
  ${lines.join("\n")}`;
@@ -11178,9 +12212,9 @@ function prunePaths(root, paths) {
11178
12212
  const yi = y[y.length - 1];
11179
12213
  return typeof xi === "number" && typeof yi === "number" ? yi - xi : 0;
11180
12214
  });
11181
- for (const path20 of ordered) {
12215
+ for (const path26 of ordered) {
11182
12216
  let cur = root;
11183
- for (const key of path20.slice(0, -1)) {
12217
+ for (const key of path26.slice(0, -1)) {
11184
12218
  if (cur === null || typeof cur !== "object") {
11185
12219
  cur = void 0;
11186
12220
  break;
@@ -11188,7 +12222,7 @@ function prunePaths(root, paths) {
11188
12222
  cur = cur[key];
11189
12223
  }
11190
12224
  if (cur === null || typeof cur !== "object") continue;
11191
- const last = path20[path20.length - 1];
12225
+ const last = path26[path26.length - 1];
11192
12226
  if (Array.isArray(cur)) {
11193
12227
  const i = Number(last);
11194
12228
  if (Number.isInteger(i) && i >= 0 && i < cur.length) {
@@ -11224,7 +12258,7 @@ function sanitizeConfig(raw) {
11224
12258
  return issue.keys.map((k) => [...at, k]);
11225
12259
  }
11226
12260
  return [at.slice(0, -level || void 0)];
11227
- }).filter((path20) => path20.length > 0);
12261
+ }).filter((path26) => path26.length > 0);
11228
12262
  if (paths.length === 0 || !prunePaths(working, paths)) break;
11229
12263
  }
11230
12264
  if (ConfigFileSchema.safeParse(working).success) break;
@@ -11246,17 +12280,17 @@ function sanitizeConfig(raw) {
11246
12280
  }
11247
12281
 
11248
12282
  // src/shields.ts
11249
- import fs2 from "fs";
11250
- import path2 from "path";
11251
- import os2 from "os";
12283
+ import fs3 from "fs";
12284
+ import path3 from "path";
12285
+ import os3 from "os";
11252
12286
  init_dist();
11253
12287
  init_dist();
11254
- var USER_SHIELDS_DIR = path2.join(os2.homedir(), ".node9", "shields");
12288
+ var USER_SHIELDS_DIR = path3.join(os3.homedir(), ".node9", "shields");
11255
12289
  function loadUserShields() {
11256
12290
  const result = {};
11257
12291
  let entries;
11258
12292
  try {
11259
- entries = fs2.readdirSync(USER_SHIELDS_DIR).filter((f) => f.endsWith(".json"));
12293
+ entries = fs3.readdirSync(USER_SHIELDS_DIR).filter((f) => f.endsWith(".json"));
11260
12294
  } catch (err) {
11261
12295
  if (err.code !== "ENOENT") {
11262
12296
  process.stderr.write(
@@ -11266,10 +12300,10 @@ function loadUserShields() {
11266
12300
  }
11267
12301
  return result;
11268
12302
  }
11269
- for (const file of entries) {
11270
- const filePath = path2.join(USER_SHIELDS_DIR, file);
12303
+ for (const file2 of entries) {
12304
+ const filePath = path3.join(USER_SHIELDS_DIR, file2);
11271
12305
  try {
11272
- const raw = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
12306
+ const raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
11273
12307
  const v = validateShieldDefinition(raw);
11274
12308
  if ("error" in v) {
11275
12309
  process.stderr.write(`[node9] ${v.error}: ${filePath}
@@ -11278,7 +12312,7 @@ function loadUserShields() {
11278
12312
  }
11279
12313
  result[v.ok.name] = v.ok;
11280
12314
  } catch (err) {
11281
- process.stderr.write(`[node9] Failed to load user shield ${file}: ${String(err)}
12315
+ process.stderr.write(`[node9] Failed to load user shield ${file2}: ${String(err)}
11282
12316
  `);
11283
12317
  }
11284
12318
  }
@@ -11300,10 +12334,10 @@ function getShield(name) {
11300
12334
  const resolved = resolveShieldName(name);
11301
12335
  return resolved ? SHIELDS[resolved] : null;
11302
12336
  }
11303
- var SHIELDS_STATE_FILE = path2.join(os2.homedir(), ".node9", "shields.json");
12337
+ var SHIELDS_STATE_FILE = path3.join(os3.homedir(), ".node9", "shields.json");
11304
12338
  function readShieldsFile() {
11305
12339
  try {
11306
- const raw = fs2.readFileSync(SHIELDS_STATE_FILE, "utf-8");
12340
+ const raw = fs3.readFileSync(SHIELDS_STATE_FILE, "utf-8");
11307
12341
  if (!raw.trim()) return { active: [] };
11308
12342
  const parsed = JSON.parse(raw);
11309
12343
  const active = Array.isArray(parsed.active) ? parsed.active.filter(
@@ -11515,15 +12549,15 @@ function pathRules(rawPath, verdict, reason) {
11515
12549
  }
11516
12550
 
11517
12551
  // src/auth/trusted-hosts.ts
11518
- import fs3 from "fs";
11519
- import path3 from "path";
11520
- import os3 from "os";
12552
+ import fs4 from "fs";
12553
+ import path4 from "path";
12554
+ import os4 from "os";
11521
12555
  function getTrustedHostsPath() {
11522
- return path3.join(os3.homedir(), ".node9", "trusted-hosts.json");
12556
+ return path4.join(os4.homedir(), ".node9", "trusted-hosts.json");
11523
12557
  }
11524
12558
  function readTrustedHosts() {
11525
12559
  try {
11526
- const raw = fs3.readFileSync(getTrustedHostsPath(), "utf8");
12560
+ const raw = fs4.readFileSync(getTrustedHostsPath(), "utf8");
11527
12561
  const parsed = JSON.parse(raw);
11528
12562
  return Array.isArray(parsed.hosts) ? parsed.hosts : [];
11529
12563
  } catch {
@@ -11534,7 +12568,7 @@ var _cache = null;
11534
12568
  var CACHE_TTL_MS = 5e3;
11535
12569
  function getFileMtime() {
11536
12570
  try {
11537
- return fs3.statSync(getTrustedHostsPath()).mtimeMs;
12571
+ return fs4.statSync(getTrustedHostsPath()).mtimeMs;
11538
12572
  } catch {
11539
12573
  return 0;
11540
12574
  }
@@ -11549,11 +12583,11 @@ function getCachedHosts() {
11549
12583
  _cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
11550
12584
  return hosts;
11551
12585
  }
11552
- function normalizeHost(raw) {
12586
+ function normalizeHost2(raw) {
11553
12587
  return raw.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^[^@]+@/, "").replace(/:\d+$/, "");
11554
12588
  }
11555
- function matchesTrustedHost(host, list) {
11556
- const normalized = normalizeHost(host);
12589
+ function matchesTrustedHost(host2, list) {
12590
+ const normalized = normalizeHost2(host2);
11557
12591
  return list.some((raw) => {
11558
12592
  const entryHost = raw.toLowerCase();
11559
12593
  if (entryHost.startsWith("*.")) {
@@ -11563,9 +12597,9 @@ function matchesTrustedHost(host, list) {
11563
12597
  return normalized === entryHost;
11564
12598
  });
11565
12599
  }
11566
- function isTrustedHost(host) {
12600
+ function isTrustedHost(host2) {
11567
12601
  return matchesTrustedHost(
11568
- host,
12602
+ host2,
11569
12603
  getCachedHosts().map((entry) => entry.host)
11570
12604
  );
11571
12605
  }
@@ -11683,6 +12717,10 @@ function v2ToLegacy(raw, options = {}) {
11683
12717
  const [id] = pair.split(".", 1);
11684
12718
  if (CHECK_BY_ID.has(id)) warnings.push(`tuning.${pair}: no such setting`);
11685
12719
  }
12720
+ if (stated["behavior.untrusted-content"] === "log") {
12721
+ const uc = policy.untrustedContent;
12722
+ if (uc) uc.afterExposure = "log-only";
12723
+ }
11686
12724
  const legacy = { version: "1.0" };
11687
12725
  if (Object.keys(settings).length) legacy.settings = settings;
11688
12726
  if (Object.keys(policy).length) legacy.policy = policy;
@@ -11700,9 +12738,9 @@ var DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
11700
12738
  var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0"]);
11701
12739
  var HOST_ALLOW_ENV = "NODE9_API_HOST_ALLOW";
11702
12740
  function defaultHostSuffixes() {
11703
- const host = new URL(DEFAULT_API_URL).hostname.toLowerCase();
11704
- const parent = host.split(".").slice(1).join(".");
11705
- return parent.includes(".") ? [host, parent] : [host];
12741
+ const host2 = new URL(DEFAULT_API_URL).hostname.toLowerCase();
12742
+ const parent = host2.split(".").slice(1).join(".");
12743
+ return parent.includes(".") ? [host2, parent] : [host2];
11706
12744
  }
11707
12745
  function allowedSuffixes() {
11708
12746
  const extra = (process.env[HOST_ALLOW_ENV] ?? "").split(",").map(
@@ -11720,11 +12758,11 @@ function validateApiUrl(raw) {
11720
12758
  }
11721
12759
  if (u.username || u.password) return null;
11722
12760
  if (u.protocol !== "https:" && u.protocol !== "http:") return null;
11723
- const host = u.hostname.toLowerCase();
11724
- if (LOOPBACK.has(host)) return u;
12761
+ const host2 = u.hostname.toLowerCase();
12762
+ if (LOOPBACK.has(host2)) return u;
11725
12763
  if (u.protocol !== "https:") return null;
11726
12764
  const suffixes = allowedSuffixes();
11727
- const ok = suffixes.some((s) => host === s || host.endsWith("." + s));
12765
+ const ok = suffixes.some((s) => host2 === s || host2.endsWith("." + s));
11728
12766
  return ok ? u : null;
11729
12767
  }
11730
12768
  function safeApiUrl(raw, onReject) {
@@ -11866,6 +12904,29 @@ var DEFAULT_CONFIG = {
11866
12904
  reason: "SQL DDL destructive statement inside a shell command",
11867
12905
  description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
11868
12906
  },
12907
+ // ── node9 itself ──────────────────────────────────────────────────────
12908
+ // The agent runs as the user, so a `node9 ...` command from Bash can
12909
+ // clear the session's taint or exposure, trust a destination or pause
12910
+ // the gate. Those are the user's own commands; from the agent they are
12911
+ // a review (fw#318 review finding 1).
12912
+ {
12913
+ name: "review-node9-disarm",
12914
+ tool: "bash",
12915
+ conditions: [
12916
+ {
12917
+ field: "command",
12918
+ op: "matches",
12919
+ // Single spaces on purpose: a nested `\\s+` inside the alternation
12920
+ // fails the engine's ReDoS check and the rule would be dropped.
12921
+ value: "(^|&&|\\|\\||;)\\s*(npx )?node9 (session-(exposure|taint) clear|trust (add|recipient add)|pause|shield (disable|set)|egress allow|approver set)\\b",
12922
+ flags: "i"
12923
+ }
12924
+ ],
12925
+ conditionMode: "all",
12926
+ verdict: "review",
12927
+ reason: "node9 self-weakening command run by the agent",
12928
+ description: "The AI wants to run a node9 command that clears a session flag, trusts a destination, pauses the gate or weakens a shield. These are your commands to run, not the agent's."
12929
+ },
11869
12930
  // ── Git safety ────────────────────────────────────────────────────────
11870
12931
  {
11871
12932
  name: "review-force-push",
@@ -11967,6 +13028,14 @@ var DEFAULT_CONFIG = {
11967
13028
  allow: []
11968
13029
  },
11969
13030
  skillPinning: { enabled: false, mode: "warn", roots: [] },
13031
+ untrustedContent: {
13032
+ enabled: true,
13033
+ spotlight: true,
13034
+ afterExposure: "ask-once",
13035
+ orgDomains: [],
13036
+ roles: {},
13037
+ trustedRecipients: []
13038
+ },
11970
13039
  trustedHosts: [],
11971
13040
  trustedHostsManaged: false,
11972
13041
  appPermissions: {},
@@ -12039,8 +13108,8 @@ function noteRejectedApiUrl(raw) {
12039
13108
  if (rejectedApiUrlsSeen.has(key)) return;
12040
13109
  rejectedApiUrlsSeen.add(key);
12041
13110
  try {
12042
- fs4.appendFileSync(
12043
- path4.join(os4.homedir(), ".node9", "hook-debug.log"),
13111
+ fs5.appendFileSync(
13112
+ path5.join(os5.homedir(), ".node9", "hook-debug.log"),
12044
13113
  `[${(/* @__PURE__ */ new Date()).toISOString()}] REFUSED apiUrl, using the default instead: ${String(raw).slice(0, 200).replace(/[\r\n]+/g, " ")}
12045
13114
  `
12046
13115
  );
@@ -12057,9 +13126,9 @@ function getCredentials() {
12057
13126
  };
12058
13127
  }
12059
13128
  try {
12060
- const credPath = path4.join(os4.homedir(), ".node9", "credentials.json");
12061
- if (fs4.existsSync(credPath)) {
12062
- const creds = JSON.parse(fs4.readFileSync(credPath, "utf-8"));
13129
+ const credPath = path5.join(os5.homedir(), ".node9", "credentials.json");
13130
+ if (fs5.existsSync(credPath)) {
13131
+ const creds = JSON.parse(fs5.readFileSync(credPath, "utf-8"));
12063
13132
  const profileName = process.env.NODE9_PROFILE || "default";
12064
13133
  const profile = creds[profileName];
12065
13134
  if (typeof profile?.apiKey === "string" && profile.apiKey.length > 0) {
@@ -12091,7 +13160,7 @@ function readRulesCacheResilient(cacheFile) {
12091
13160
  for (let attempt = 0; attempt < 3; attempt++) {
12092
13161
  let content;
12093
13162
  try {
12094
- content = fs4.readFileSync(cacheFile, "utf-8");
13163
+ content = fs5.readFileSync(cacheFile, "utf-8");
12095
13164
  existed = true;
12096
13165
  } catch (err) {
12097
13166
  if (err.code === "ENOENT") return {};
@@ -12106,10 +13175,10 @@ function readRulesCacheResilient(cacheFile) {
12106
13175
  }
12107
13176
  }
12108
13177
  if (existed || sawReadError) {
12109
- const backup = path4.join(path4.dirname(cacheFile), "rules-cache.last-good.json");
13178
+ const backup = path5.join(path5.dirname(cacheFile), "rules-cache.last-good.json");
12110
13179
  if (backup !== cacheFile) {
12111
13180
  try {
12112
- const raw = JSON.parse(fs4.readFileSync(backup, "utf-8"));
13181
+ const raw = JSON.parse(fs5.readFileSync(backup, "utf-8"));
12113
13182
  logCacheReadIssue(cacheFile, "RULES_CACHE_CORRUPT_USED_BACKUP");
12114
13183
  lastParsedRulesCache = raw;
12115
13184
  return raw;
@@ -12132,8 +13201,8 @@ function logCacheReadIssue(cacheFile, kind) {
12132
13201
  if (now - cacheReadLastLoggedAt < CACHE_LOG_REARM_MS) return;
12133
13202
  cacheReadLastLoggedAt = now;
12134
13203
  try {
12135
- fs4.appendFileSync(
12136
- path4.join(os4.homedir(), ".node9", "hook-debug.log"),
13204
+ fs5.appendFileSync(
13205
+ path5.join(os5.homedir(), ".node9", "hook-debug.log"),
12137
13206
  `[${(/* @__PURE__ */ new Date()).toISOString()}] ${kind} ${cacheFile}
12138
13207
  `
12139
13208
  );
@@ -12154,8 +13223,8 @@ function catalogSettingsFrom(settings, policy) {
12154
13223
  }
12155
13224
  function getConfig(cwd) {
12156
13225
  if (!cwd && cachedConfig) return cachedConfig;
12157
- const globalPath = path4.join(os4.homedir(), ".node9", "config.json");
12158
- const projectPath = path4.join(cwd ?? process.cwd(), "node9.config.json");
13226
+ const globalPath = path5.join(os5.homedir(), ".node9", "config.json");
13227
+ const projectPath = path5.join(cwd ?? process.cwd(), "node9.config.json");
12159
13228
  const globalConfig = tryLoadConfig(globalPath);
12160
13229
  const projectConfig = tryLoadConfig(projectPath, { project: true });
12161
13230
  const mergedSettings = {
@@ -12168,6 +13237,7 @@ function getConfig(cwd) {
12168
13237
  dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
12169
13238
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
12170
13239
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
13240
+ projectToolInspection: {},
12171
13241
  // Shipped rules carry `builtin`: the catalog's per-check value governs
12172
13242
  // them and only them (a user rule that reuses a name keeps its verdict).
12173
13243
  smartRules: DEFAULT_CONFIG.policy.smartRules.map((r) => ({ ...r, builtin: true })),
@@ -12191,6 +13261,12 @@ function getConfig(cwd) {
12191
13261
  ...DEFAULT_CONFIG.policy.skillPinning,
12192
13262
  roots: [...DEFAULT_CONFIG.policy.skillPinning.roots]
12193
13263
  },
13264
+ untrustedContent: {
13265
+ ...DEFAULT_CONFIG.policy.untrustedContent,
13266
+ orgDomains: [...DEFAULT_CONFIG.policy.untrustedContent.orgDomains],
13267
+ roles: {},
13268
+ trustedRecipients: []
13269
+ },
12194
13270
  // Left empty on purpose: the local file is read fresh at policy-eval time
12195
13271
  // (getCachedHosts via isTrustedHost), NOT snapshotted into the frozen config
12196
13272
  // here. A managed list fills this below and flips trustedHostsManaged.
@@ -12235,8 +13311,14 @@ function getConfig(cwd) {
12235
13311
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
12236
13312
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
12237
13313
  if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
12238
- if (p.toolInspection)
12239
- mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
13314
+ if (p.toolInspection) {
13315
+ if (isProject)
13316
+ mergedPolicy.projectToolInspection = {
13317
+ ...mergedPolicy.projectToolInspection,
13318
+ ...p.toolInspection
13319
+ };
13320
+ else mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
13321
+ }
12240
13322
  if (p.smartRules) {
12241
13323
  const defaultBlocks = mergedPolicy.smartRules.filter((r) => r.verdict === "block");
12242
13324
  const defaultNonBlocks = mergedPolicy.smartRules.filter((r) => r.verdict !== "block");
@@ -12350,6 +13432,9 @@ function getConfig(cwd) {
12350
13432
  }
12351
13433
  }
12352
13434
  }
13435
+ if (p.untrustedContent && typeof p.untrustedContent === "object") {
13436
+ applyUntrustedContent(mergedPolicy.untrustedContent, p.untrustedContent, false, isProject);
13437
+ }
12353
13438
  const envs = source.environments || {};
12354
13439
  for (const [envName, envConfig] of Object.entries(envs)) {
12355
13440
  if (envConfig && typeof envConfig === "object") {
@@ -12381,7 +13466,7 @@ function getConfig(cwd) {
12381
13466
  let cloudMandatesEnforcement = false;
12382
13467
  let cloudMandatesAppPerm = false;
12383
13468
  {
12384
- const cacheFile = path4.join(os4.homedir(), ".node9", "rules-cache.json");
13469
+ const cacheFile = path5.join(os5.homedir(), ".node9", "rules-cache.json");
12385
13470
  try {
12386
13471
  const raw = readRulesCacheResilient(cacheFile);
12387
13472
  if (Array.isArray(raw.rules) && raw.rules.length > 0) {
@@ -12549,6 +13634,9 @@ function getConfig(cwd) {
12549
13634
  allow: Array.isArray(pc.allow) ? pc.allow.filter((a) => typeof a === "string" && a.length > 0) : cur.allow
12550
13635
  };
12551
13636
  }
13637
+ if (mc.untrustedContent && typeof mc.untrustedContent === "object") {
13638
+ applyUntrustedContent(mergedPolicy.untrustedContent, mc.untrustedContent, true);
13639
+ }
12552
13640
  if (mc.loopDetection && typeof mc.loopDetection === "object") {
12553
13641
  const l = mc.loopDetection;
12554
13642
  const cur = mergedPolicy.loopDetection;
@@ -12569,18 +13657,18 @@ function getConfig(cwd) {
12569
13657
  }
12570
13658
  if (Array.isArray(mc.jailPaths)) {
12571
13659
  for (const jp of mc.jailPaths) {
12572
- const path20 = typeof jp?.path === "string" ? jp.path.trim() : "";
12573
- if (!path20) continue;
13660
+ const path26 = typeof jp?.path === "string" ? jp.path.trim() : "";
13661
+ if (!path26) continue;
12574
13662
  const verdict = jp?.verdict === "review" ? "review" : "block";
12575
- for (const r of pathRules(path20, verdict, "org-managed jail")) {
13663
+ for (const r of pathRules(path26, verdict, "org-managed jail")) {
12576
13664
  mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
12577
13665
  }
12578
- mergedPolicy.managedJailPaths.push({ path: path20, verdict });
13666
+ mergedPolicy.managedJailPaths.push({ path: path26, verdict });
12579
13667
  }
12580
13668
  }
12581
13669
  if (Array.isArray(mc.trustedHosts)) {
12582
13670
  mergedPolicy.trustedHostsManaged = true;
12583
- mergedPolicy.trustedHosts = mc.trustedHosts.filter((h) => typeof h === "string").map((h) => normalizeHost(h));
13671
+ mergedPolicy.trustedHosts = mc.trustedHosts.filter((h) => typeof h === "string").map((h) => normalizeHost2(h));
12584
13672
  }
12585
13673
  if (mc.appPermissions && typeof mc.appPermissions === "object" && !Array.isArray(mc.appPermissions)) {
12586
13674
  const coerced = {};
@@ -12598,7 +13686,7 @@ function getConfig(cwd) {
12598
13686
  );
12599
13687
  }
12600
13688
  const on = (v) => !!v && typeof v === "object" && v.enabled === true;
12601
- cloudMandatesEnforcement = cloudMandatesAppPerm || Array.isArray(mc.jailPaths) && mc.jailPaths.some((jp) => typeof jp?.path === "string" && jp.path.trim() !== "") || on(mc.egress) || on(mc.dlp) || on(mc.injectionScan) || on(mc.skillPinning) || on(mc.loopDetection) || !!mc.commandChecks && typeof mc.commandChecks === "object" && Object.values(mc.commandChecks).some(
13689
+ cloudMandatesEnforcement = cloudMandatesAppPerm || Array.isArray(mc.jailPaths) && mc.jailPaths.some((jp) => typeof jp?.path === "string" && jp.path.trim() !== "") || on(mc.egress) || on(mc.dlp) || on(mc.injectionScan) || on(mc.untrustedContent) || on(mc.skillPinning) || on(mc.loopDetection) || !!mc.commandChecks && typeof mc.commandChecks === "object" && Object.values(mc.commandChecks).some(
12602
13690
  (v) => typeof v === "string" && v !== "off"
12603
13691
  );
12604
13692
  }
@@ -12771,10 +13859,10 @@ function getConfig(cwd) {
12771
13859
  }
12772
13860
  function tryLoadConfig(filePath, options = {}) {
12773
13861
  v2ChecksByPath.delete(filePath);
12774
- if (!fs4.existsSync(filePath)) return null;
13862
+ if (!fs5.existsSync(filePath)) return null;
12775
13863
  let raw;
12776
13864
  try {
12777
- raw = JSON.parse(fs4.readFileSync(filePath, "utf-8"));
13865
+ raw = JSON.parse(fs5.readFileSync(filePath, "utf-8"));
12778
13866
  } catch (err) {
12779
13867
  const msg = err instanceof Error ? err.message : String(err);
12780
13868
  process.stderr.write(
@@ -12834,6 +13922,44 @@ ${error.replace("Invalid config:\n", "")}
12834
13922
  }
12835
13923
  return sanitized;
12836
13924
  }
13925
+ function applyUntrustedContent(cur, raw, replaceLists = false, isProject = false) {
13926
+ if (!raw || typeof raw !== "object") return;
13927
+ const uc = raw;
13928
+ const AFTER_RANK = { "log-only": 0, "ask-once": 1, "ask-every-time": 2 };
13929
+ if (typeof uc.enabled === "boolean" && !(isProject && uc.enabled === false))
13930
+ cur.enabled = uc.enabled;
13931
+ if (typeof uc.spotlight === "boolean" && !(isProject && uc.spotlight === false))
13932
+ cur.spotlight = uc.spotlight;
13933
+ if ((uc.afterExposure === "ask-once" || uc.afterExposure === "ask-every-time" || uc.afterExposure === "log-only") && !(isProject && AFTER_RANK[uc.afterExposure] < AFTER_RANK[cur.afterExposure]))
13934
+ cur.afterExposure = uc.afterExposure;
13935
+ if (Array.isArray(uc.orgDomains) && !isProject) {
13936
+ const domains = uc.orgDomains.filter((d) => typeof d === "string").map((d) => d.trim().toLowerCase().replace(/^@/, "")).filter((d) => /^[a-z0-9.-]+\.[a-z]{2,}$/.test(d));
13937
+ if (replaceLists) cur.orgDomains = domains;
13938
+ else for (const d of domains) if (!cur.orgDomains.includes(d)) cur.orgDomains.push(d);
13939
+ }
13940
+ if (replaceLists && Array.isArray(uc.trustedRecipients)) {
13941
+ cur.trustedRecipients = [
13942
+ ...new Set(
13943
+ uc.trustedRecipients.filter((v) => typeof v === "string").map((v) => normalizeRecipient(v)).filter((v) => v !== null)
13944
+ )
13945
+ ].slice(0, 200);
13946
+ }
13947
+ if (uc.roles && typeof uc.roles === "object" && !Array.isArray(uc.roles)) {
13948
+ const next = replaceLists ? {} : cur.roles;
13949
+ for (const [server, tools] of Object.entries(uc.roles)) {
13950
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) continue;
13951
+ const m = { ...next[server] ?? {} };
13952
+ for (const [tool, role] of Object.entries(tools)) {
13953
+ if (role === "source" || role === "action" || role === "read") {
13954
+ if (isProject && role !== "action") continue;
13955
+ m[tool] = role;
13956
+ }
13957
+ }
13958
+ if (Object.keys(m).length > 0) next[server] = m;
13959
+ }
13960
+ cur.roles = next;
13961
+ }
13962
+ }
12837
13963
 
12838
13964
  // src/utils/regex.ts
12839
13965
  init_dist();
@@ -12841,18 +13967,18 @@ init_dist();
12841
13967
  // src/dlp.ts
12842
13968
  init_dist();
12843
13969
  init_dist();
12844
- import fs5 from "fs";
12845
- import path5 from "path";
13970
+ import fs6 from "fs";
13971
+ import path6 from "path";
12846
13972
  function scanFilePath(filePath, cwd = process.cwd(), options = {}) {
12847
13973
  if (!filePath) return null;
12848
13974
  let resolved;
12849
13975
  try {
12850
- const absolute = path5.resolve(cwd, filePath);
12851
- resolved = fs5.realpathSync.native(absolute);
13976
+ const absolute = path6.resolve(cwd, filePath);
13977
+ resolved = fs6.realpathSync.native(absolute);
12852
13978
  } catch (err) {
12853
13979
  const code = err.code;
12854
13980
  if (code === "ENOENT" || code === "ENOTDIR") {
12855
- resolved = path5.resolve(cwd, filePath);
13981
+ resolved = path6.resolve(cwd, filePath);
12856
13982
  } else {
12857
13983
  return sensitivePathMatch(filePath);
12858
13984
  }
@@ -12862,29 +13988,29 @@ function scanFilePath(filePath, cwd = process.cwd(), options = {}) {
12862
13988
 
12863
13989
  // src/policy/file-access.ts
12864
13990
  init_dist();
12865
- import fs6 from "fs";
12866
- import os5 from "os";
12867
- import path6 from "path";
13991
+ import fs7 from "fs";
13992
+ import os6 from "os";
13993
+ import path7 from "path";
12868
13994
  function absoluteSpelling(p, cwd, home) {
12869
13995
  if (p === "~" || p.startsWith("~/")) p = home + p.slice(1);
12870
- return path6.isAbsolute(p) ? p : `${cwd}/${p}`;
13996
+ return path7.isAbsolute(p) ? p : `${cwd}/${p}`;
12871
13997
  }
12872
13998
  function physical(p, followFinal, links = 0) {
12873
13999
  if (links > 40) throw new Error("Too many symbolic links");
12874
14000
  if (!followFinal) {
12875
- const parent = physical(path6.dirname(p), true, links);
12876
- return { path: `${parent.path}/${path6.basename(p)}`, missing: parent.missing };
14001
+ const parent = physical(path7.dirname(p), true, links);
14002
+ return { path: `${parent.path}/${path7.basename(p)}`, missing: parent.missing };
12877
14003
  }
12878
14004
  try {
12879
- return { path: fs6.realpathSync.native(p), missing: false };
14005
+ return { path: fs7.realpathSync.native(p), missing: false };
12880
14006
  } catch (error) {
12881
14007
  const code = error.code;
12882
14008
  if (!["ENOENT", "ENOTDIR"].includes(code ?? "")) throw error;
12883
14009
  try {
12884
- if (fs6.lstatSync(p).isSymbolicLink()) {
12885
- const target = fs6.readlinkSync(p);
14010
+ if (fs7.lstatSync(p).isSymbolicLink()) {
14011
+ const target = fs7.readlinkSync(p);
12886
14012
  return physical(
12887
- path6.isAbsolute(target) ? target : `${path6.dirname(p)}/${target}`,
14013
+ path7.isAbsolute(target) ? target : `${path7.dirname(p)}/${target}`,
12888
14014
  true,
12889
14015
  links + 1
12890
14016
  );
@@ -12893,13 +14019,13 @@ function physical(p, followFinal, links = 0) {
12893
14019
  if (!["ENOENT", "ENOTDIR"].includes(linkError.code ?? ""))
12894
14020
  throw linkError;
12895
14021
  }
12896
- const parent = path6.dirname(p);
14022
+ const parent = path7.dirname(p);
12897
14023
  if (parent === p) return { path: p, missing: true };
12898
14024
  const resolved = physical(parent, true, links);
12899
- return { path: `${resolved.path}/${path6.basename(p)}`, missing: true };
14025
+ return { path: `${resolved.path}/${path7.basename(p)}`, missing: true };
12900
14026
  }
12901
14027
  }
12902
- function prepareFileAccesses(tool, args, cwd = process.cwd(), inspection = {}, home = os5.homedir()) {
14028
+ function prepareFileAccesses(tool, args, cwd = process.cwd(), inspection = {}, home = os6.homedir()) {
12903
14029
  const context = { cwd, homeDirs: [home, "/root"] };
12904
14030
  const accesses = collectFileAccesses(tool, args, context, inspection);
12905
14031
  const handledPaths = /* @__PURE__ */ new Set();
@@ -12922,14 +14048,14 @@ function prepareFileAccesses(tool, args, cwd = process.cwd(), inspection = {}, h
12922
14048
  const raw = absoluteSpelling(access.path, access.cwd ?? cwd, home);
12923
14049
  if (access.directoryTarget) {
12924
14050
  try {
12925
- if (!fs6.statSync(absoluteSpelling(access.directoryTarget, access.cwd ?? cwd, home)).isDirectory())
14051
+ if (!fs7.statSync(absoluteSpelling(access.directoryTarget, access.cwd ?? cwd, home)).isDirectory())
12926
14052
  continue;
12927
14053
  } catch {
12928
14054
  }
12929
14055
  }
12930
14056
  if (access.operation === "create") {
12931
14057
  try {
12932
- fs6.statSync(raw);
14058
+ fs7.statSync(raw);
12933
14059
  continue;
12934
14060
  } catch {
12935
14061
  }
@@ -12978,27 +14104,27 @@ function prepareFileAccesses(tool, args, cwd = process.cwd(), inspection = {}, h
12978
14104
  }
12979
14105
 
12980
14106
  // src/utils/provenance.ts
12981
- import fs7 from "fs";
12982
- import path7 from "path";
12983
- import os6 from "os";
14107
+ import fs8 from "fs";
14108
+ import path8 from "path";
14109
+ import os7 from "os";
12984
14110
  var SYSTEM_PREFIXES = ["/usr/bin", "/usr/sbin", "/bin", "/sbin"];
12985
14111
  var MANAGED_PREFIXES = ["/usr/local/bin", "/opt/homebrew", "/home/linuxbrew", "/nix/store"];
12986
14112
  var USER_PREFIXES = [
12987
- path7.join(os6.homedir(), "bin"),
12988
- path7.join(os6.homedir(), ".local", "bin"),
12989
- path7.join(os6.homedir(), ".cargo", "bin"),
12990
- path7.join(os6.homedir(), ".npm-global", "bin"),
12991
- path7.join(os6.homedir(), ".volta", "bin")
14113
+ path8.join(os7.homedir(), "bin"),
14114
+ path8.join(os7.homedir(), ".local", "bin"),
14115
+ path8.join(os7.homedir(), ".cargo", "bin"),
14116
+ path8.join(os7.homedir(), ".npm-global", "bin"),
14117
+ path8.join(os7.homedir(), ".volta", "bin")
12992
14118
  ];
12993
14119
  var SUSPECT_PREFIXES = ["/tmp", "/var/tmp", "/dev/shm"];
12994
14120
  function findInPath(cmd) {
12995
- if (path7.posix.isAbsolute(cmd)) return cmd;
14121
+ if (path8.posix.isAbsolute(cmd)) return cmd;
12996
14122
  const pathEnv = process.env.PATH ?? "";
12997
- for (const dir of pathEnv.split(path7.delimiter)) {
12998
- if (!dir) continue;
12999
- const full = path7.join(dir, cmd);
14123
+ for (const dir2 of pathEnv.split(path8.delimiter)) {
14124
+ if (!dir2) continue;
14125
+ const full = path8.join(dir2, cmd);
13000
14126
  try {
13001
- fs7.accessSync(full, fs7.constants.X_OK);
14127
+ fs8.accessSync(full, fs8.constants.X_OK);
13002
14128
  return full;
13003
14129
  } catch {
13004
14130
  }
@@ -13009,7 +14135,7 @@ function _classifyPath(resolved, cwd) {
13009
14135
  if (cwd && resolved.startsWith(cwd + "/")) {
13010
14136
  return { trustLevel: "user", reason: "binary in project directory" };
13011
14137
  }
13012
- const osTmp = os6.tmpdir();
14138
+ const osTmp = os7.tmpdir();
13013
14139
  const allSuspect = osTmp ? [...SUSPECT_PREFIXES, osTmp] : SUSPECT_PREFIXES;
13014
14140
  if (allSuspect.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
13015
14141
  return { trustLevel: "suspect", reason: `binary in temp directory: ${resolved}` };
@@ -13027,7 +14153,7 @@ function _classifyPath(resolved, cwd) {
13027
14153
  }
13028
14154
  function checkProvenance(cmd, cwd) {
13029
14155
  const bare = cmd.startsWith("./") ? cmd.slice(2) : cmd;
13030
- if (path7.posix.isAbsolute(bare)) {
14156
+ if (path8.posix.isAbsolute(bare)) {
13031
14157
  const early = _classifyPath(bare, cwd);
13032
14158
  if (early.trustLevel === "suspect") {
13033
14159
  return { resolvedPath: bare, ...early };
@@ -13043,7 +14169,7 @@ function checkProvenance(cmd, cwd) {
13043
14169
  reason: "binary not found in PATH"
13044
14170
  };
13045
14171
  }
13046
- resolved = fs7.realpathSync(found);
14172
+ resolved = fs8.realpathSync(found);
13047
14173
  } catch {
13048
14174
  return {
13049
14175
  resolvedPath: cmd,
@@ -13052,7 +14178,7 @@ function checkProvenance(cmd, cwd) {
13052
14178
  };
13053
14179
  }
13054
14180
  try {
13055
- const stat = fs7.statSync(resolved);
14181
+ const stat = fs8.statSync(resolved);
13056
14182
  if (stat.mode & 2) {
13057
14183
  return {
13058
14184
  resolvedPath: resolved,
@@ -13077,7 +14203,7 @@ init_dist();
13077
14203
  init_dist();
13078
14204
  async function evaluatePolicy2(toolName, args, agent, cwd, opts) {
13079
14205
  const config = opts?.config ?? getConfig(cwd);
13080
- const prepared = opts?.fileAccess ?? prepareFileAccesses(toolName, args, cwd, config.policy.toolInspection);
14206
+ const prepared = opts?.fileAccess ?? prepareFileAccesses(toolName, args, cwd, inspectionLayers(config.policy));
13081
14207
  const activeEnvironment = getActiveEnvironment(config) ?? void 0;
13082
14208
  return evaluatePolicy(
13083
14209
  config,
@@ -13097,7 +14223,7 @@ async function evaluatePolicy2(toolName, args, agent, cwd, opts) {
13097
14223
  // config; changes arrive via cloud sync). Unmanaged → the local file via
13098
14224
  // getCachedHosts (5s TTL + mtime), so a `node9 trust add/remove` still
13099
14225
  // reaches a long-lived in-process authorizer (the gateway) within seconds.
13100
- isTrustedHost: config.policy.trustedHostsManaged ? (host) => matchesTrustedHost(host, config.policy.trustedHosts) : isTrustedHost
14226
+ isTrustedHost: config.policy.trustedHostsManaged ? (host2) => matchesTrustedHost(host2, config.policy.trustedHosts) : isTrustedHost
13101
14227
  }
13102
14228
  );
13103
14229
  }
@@ -13106,32 +14232,32 @@ function isIgnoredTool2(toolName) {
13106
14232
  }
13107
14233
 
13108
14234
  // src/auth/state.ts
13109
- import fs9 from "fs";
13110
- import path9 from "path";
13111
- import os7 from "os";
14235
+ import fs10 from "fs";
14236
+ import path10 from "path";
14237
+ import os8 from "os";
13112
14238
 
13113
14239
  // src/utils/atomic-write.ts
13114
- import fs8 from "fs";
13115
- import path8 from "path";
14240
+ import fs9 from "fs";
14241
+ import path9 from "path";
13116
14242
  import { randomUUID } from "crypto";
13117
14243
  function atomicWriteSync(filePath, data, options) {
13118
- const dir = path8.dirname(filePath);
13119
- if (!fs8.existsSync(dir)) fs8.mkdirSync(dir, { recursive: true });
14244
+ const dir2 = path9.dirname(filePath);
14245
+ if (!fs9.existsSync(dir2)) fs9.mkdirSync(dir2, { recursive: true });
13120
14246
  const tmpPath = `${filePath}.${randomUUID()}.tmp`;
13121
14247
  try {
13122
- fs8.writeFileSync(tmpPath, data, options);
14248
+ fs9.writeFileSync(tmpPath, data, options);
13123
14249
  } catch (err) {
13124
14250
  try {
13125
- fs8.unlinkSync(tmpPath);
14251
+ fs9.unlinkSync(tmpPath);
13126
14252
  } catch {
13127
14253
  }
13128
14254
  throw err;
13129
14255
  }
13130
14256
  try {
13131
- fs8.renameSync(tmpPath, filePath);
14257
+ fs9.renameSync(tmpPath, filePath);
13132
14258
  } catch (err) {
13133
14259
  try {
13134
- fs8.unlinkSync(tmpPath);
14260
+ fs9.unlinkSync(tmpPath);
13135
14261
  } catch {
13136
14262
  }
13137
14263
  throw err;
@@ -13139,8 +14265,8 @@ function atomicWriteSync(filePath, data, options) {
13139
14265
  }
13140
14266
 
13141
14267
  // src/auth/state.ts
13142
- var PAUSED_FILE = path9.join(os7.homedir(), ".node9", "PAUSED");
13143
- var TRUST_FILE = path9.join(os7.homedir(), ".node9", "trust.json");
14268
+ var PAUSED_FILE = path10.join(os8.homedir(), ".node9", "PAUSED");
14269
+ var TRUST_FILE = path10.join(os8.homedir(), ".node9", "trust.json");
13144
14270
  function extractCommandPattern(toolName, args) {
13145
14271
  const lower2 = toolName.toLowerCase();
13146
14272
  if (lower2 !== "bash" && lower2 !== "execute_bash" && lower2 !== "shell") return void 0;
@@ -13152,11 +14278,11 @@ function extractCommandPattern(toolName, args) {
13152
14278
  }
13153
14279
  function checkPause() {
13154
14280
  try {
13155
- if (!fs9.existsSync(PAUSED_FILE)) return { paused: false };
13156
- const state = JSON.parse(fs9.readFileSync(PAUSED_FILE, "utf-8"));
14281
+ if (!fs10.existsSync(PAUSED_FILE)) return { paused: false };
14282
+ const state = JSON.parse(fs10.readFileSync(PAUSED_FILE, "utf-8"));
13157
14283
  if (state.expiry > 0 && Date.now() >= state.expiry) {
13158
14284
  try {
13159
- fs9.unlinkSync(PAUSED_FILE);
14285
+ fs10.unlinkSync(PAUSED_FILE);
13160
14286
  } catch {
13161
14287
  }
13162
14288
  return { paused: false };
@@ -13168,8 +14294,8 @@ function checkPause() {
13168
14294
  }
13169
14295
  function getActiveTrustSession(toolName, args) {
13170
14296
  try {
13171
- if (!fs9.existsSync(TRUST_FILE)) return false;
13172
- const trust = JSON.parse(fs9.readFileSync(TRUST_FILE, "utf-8"));
14297
+ if (!fs10.existsSync(TRUST_FILE)) return false;
14298
+ const trust = JSON.parse(fs10.readFileSync(TRUST_FILE, "utf-8"));
13173
14299
  const now = Date.now();
13174
14300
  const active = trust.entries.filter((e) => e.expiry > now);
13175
14301
  if (active.length !== trust.entries.length) {
@@ -13192,8 +14318,8 @@ function writeTrustSession(toolName, durationMs, args) {
13192
14318
  try {
13193
14319
  let trust = { entries: [] };
13194
14320
  try {
13195
- if (fs9.existsSync(TRUST_FILE)) {
13196
- trust = JSON.parse(fs9.readFileSync(TRUST_FILE, "utf-8"));
14321
+ if (fs10.existsSync(TRUST_FILE)) {
14322
+ trust = JSON.parse(fs10.readFileSync(TRUST_FILE, "utf-8"));
13197
14323
  }
13198
14324
  } catch {
13199
14325
  }
@@ -13215,9 +14341,9 @@ function writeTrustSession(toolName, durationMs, args) {
13215
14341
  }
13216
14342
  function getPersistentDecision(toolName) {
13217
14343
  try {
13218
- const file = path9.join(os7.homedir(), ".node9", "decisions.json");
13219
- if (!fs9.existsSync(file)) return null;
13220
- const decisions = JSON.parse(fs9.readFileSync(file, "utf-8"));
14344
+ const file2 = path10.join(os8.homedir(), ".node9", "decisions.json");
14345
+ if (!fs10.existsSync(file2)) return null;
14346
+ const decisions = JSON.parse(fs10.readFileSync(file2, "utf-8"));
13221
14347
  const d = decisions[toolName];
13222
14348
  if (d === "allow" || d === "deny") return d;
13223
14349
  } catch {
@@ -13226,11 +14352,11 @@ function getPersistentDecision(toolName) {
13226
14352
  }
13227
14353
 
13228
14354
  // src/auth/daemon.ts
13229
- import fs10 from "fs";
14355
+ import fs11 from "fs";
13230
14356
  import net from "net";
13231
- import path10 from "path";
13232
- import os8 from "os";
13233
- var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path10.join(os8.tmpdir(), "node9-activity.sock");
14357
+ import path11 from "path";
14358
+ import os9 from "os";
14359
+ var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path11.join(os9.tmpdir(), "node9-activity.sock");
13234
14360
  function notifyActivitySocket(data) {
13235
14361
  return new Promise((resolve) => {
13236
14362
  try {
@@ -13272,9 +14398,9 @@ var DAEMON_PORT = resolveDaemonPort();
13272
14398
  var DAEMON_HOST = "127.0.0.1";
13273
14399
  function getInternalToken() {
13274
14400
  try {
13275
- const pidFile = path10.join(os8.homedir(), ".node9", "daemon.pid");
13276
- if (!fs10.existsSync(pidFile)) return null;
13277
- const data = JSON.parse(fs10.readFileSync(pidFile, "utf-8"));
14401
+ const pidFile = path11.join(os9.homedir(), ".node9", "daemon.pid");
14402
+ if (!fs11.existsSync(pidFile)) return null;
14403
+ const data = JSON.parse(fs11.readFileSync(pidFile, "utf-8"));
13278
14404
  process.kill(data.pid, 0);
13279
14405
  return data.internalToken ?? null;
13280
14406
  } catch {
@@ -13282,12 +14408,12 @@ function getInternalToken() {
13282
14408
  }
13283
14409
  }
13284
14410
  function isDaemonRunning() {
13285
- const pidFile = path10.join(os8.homedir(), ".node9", "daemon.pid");
13286
- if (fs10.existsSync(pidFile)) {
14411
+ const pidFile = path11.join(os9.homedir(), ".node9", "daemon.pid");
14412
+ if (fs11.existsSync(pidFile)) {
13287
14413
  let pid;
13288
14414
  let port;
13289
14415
  try {
13290
- const data = JSON.parse(fs10.readFileSync(pidFile, "utf-8"));
14416
+ const data = JSON.parse(fs11.readFileSync(pidFile, "utf-8"));
13291
14417
  pid = data.pid;
13292
14418
  port = data.port;
13293
14419
  } catch {
@@ -13305,7 +14431,7 @@ function isDaemonRunning() {
13305
14431
  } catch (err) {
13306
14432
  if (err instanceof Error && "code" in err && err.code === "ESRCH") {
13307
14433
  try {
13308
- fs10.unlinkSync(pidFile);
14434
+ fs11.unlinkSync(pidFile);
13309
14435
  } catch {
13310
14436
  }
13311
14437
  }
@@ -13327,7 +14453,7 @@ async function daemonHasInteractiveApprover(timeoutMs = 400) {
13327
14453
  return false;
13328
14454
  }
13329
14455
  }
13330
- async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd, recoveryCommand, skipBackgroundAuth, viewOnly, localSmartRuleMatched, socketActivitySent) {
14456
+ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd, recoveryCommand, skipBackgroundAuth, viewOnly, localSmartRuleMatched, socketActivitySent, sessionId) {
13331
14457
  const base2 = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
13332
14458
  const ctrl = new AbortController();
13333
14459
  const timer = setTimeout(() => ctrl.abort(), 5e3);
@@ -13352,7 +14478,8 @@ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityI
13352
14478
  ...recoveryCommand && { recoveryCommand },
13353
14479
  ...skipBackgroundAuth && { skipBackgroundAuth: true },
13354
14480
  ...viewOnly && { viewOnly: true },
13355
- ...localSmartRuleMatched && { localSmartRuleMatched: true }
14481
+ ...localSmartRuleMatched && { localSmartRuleMatched: true },
14482
+ ...sessionId && { sessionId }
13356
14483
  }),
13357
14484
  signal: ctrl.signal
13358
14485
  });
@@ -13451,7 +14578,7 @@ async function checkSessionTaint(sessionId) {
13451
14578
  try {
13452
14579
  const res = await fetch(`${base2}/session-taint/check`, {
13453
14580
  method: "POST",
13454
- headers: { "Content-Type": "application/json" },
14581
+ headers: exposureHeaders(),
13455
14582
  body: JSON.stringify({ sessionId }),
13456
14583
  signal: AbortSignal.timeout(2e3)
13457
14584
  });
@@ -13460,6 +14587,42 @@ async function checkSessionTaint(sessionId) {
13460
14587
  return { tainted: false, daemonUnavailable: true };
13461
14588
  }
13462
14589
  }
14590
+ function exposureHeaders() {
14591
+ return {
14592
+ "Content-Type": "application/json",
14593
+ "X-Node9-Internal": getInternalToken() ?? ""
14594
+ };
14595
+ }
14596
+ async function exposurePost(route, body, timeoutMs) {
14597
+ const base2 = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
14598
+ const res = await fetch(`${base2}${route}`, {
14599
+ method: "POST",
14600
+ headers: exposureHeaders(),
14601
+ body: JSON.stringify(body),
14602
+ signal: AbortSignal.timeout(timeoutMs)
14603
+ });
14604
+ if (!res.ok) throw new Error(`session-exposure ${route}: HTTP ${res.status}`);
14605
+ return res.json();
14606
+ }
14607
+ async function checkSessionExposure(sessionId) {
14608
+ if (!sessionId || !isDaemonRunning()) return { exposed: false };
14609
+ try {
14610
+ return await exposurePost(
14611
+ "/session-exposure/check",
14612
+ { sessionId },
14613
+ 2e3
14614
+ );
14615
+ } catch {
14616
+ return { exposed: false, daemonUnavailable: true };
14617
+ }
14618
+ }
14619
+ async function approveSessionExposure(sessionId, keys) {
14620
+ if (!sessionId || keys.length === 0 || !isDaemonRunning()) return;
14621
+ try {
14622
+ await exposurePost("/session-exposure/approve", { sessionId, keys }, 1e3);
14623
+ } catch {
14624
+ }
14625
+ }
13463
14626
  async function resolveViaDaemon(id, decision, internalToken, source) {
13464
14627
  const base2 = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
13465
14628
  await fetch(`${base2}/resolve/${id}`, {
@@ -13472,13 +14635,14 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
13472
14635
 
13473
14636
  // src/auth/orchestrator.ts
13474
14637
  import { randomUUID as randomUUID2 } from "crypto";
14638
+ import os18 from "os";
13475
14639
 
13476
14640
  // src/ui/native.ts
13477
14641
  import { spawn } from "child_process";
13478
- import path12 from "path";
14642
+ import path13 from "path";
13479
14643
 
13480
14644
  // src/context-sniper.ts
13481
- import path11 from "path";
14645
+ import path12 from "path";
13482
14646
  function smartTruncate(str, maxLen = 500) {
13483
14647
  if (str.length <= maxLen) return str;
13484
14648
  const edge = Math.floor(maxLen / 2) - 3;
@@ -13546,7 +14710,7 @@ function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWo
13546
14710
  intent = "EDIT";
13547
14711
  if (obj.file_path) {
13548
14712
  editFilePath = String(obj.file_path);
13549
- editFileName = path11.basename(editFilePath);
14713
+ editFileName = path12.basename(editFilePath);
13550
14714
  }
13551
14715
  const result = extractContext(String(obj.new_string), matchedWord);
13552
14716
  contextSnippet = result.snippet;
@@ -13591,6 +14755,7 @@ function resolveNativeDecision(opts) {
13591
14755
  const { code, output, elapsedMs, locked } = opts;
13592
14756
  if (locked) return "deny";
13593
14757
  const tooFast = elapsedMs < MIN_INTERACTION_MS;
14758
+ if (output.includes("Allow & trust")) return tooFast ? "deny" : "trust_recipients";
13594
14759
  if (output.includes("Always Allow")) return tooFast ? "deny" : "always_allow";
13595
14760
  if (code === 0) return tooFast ? "deny" : "allow";
13596
14761
  return "deny";
@@ -13613,12 +14778,12 @@ function formatArgs(args, matchedField, matchedWord) {
13613
14778
  if (typeof parsed === "object" && !Array.isArray(parsed)) {
13614
14779
  const obj = parsed;
13615
14780
  if (obj.old_string !== void 0 && obj.new_string !== void 0) {
13616
- const file = obj.file_path ? path12.basename(String(obj.file_path)) : "file";
14781
+ const file2 = obj.file_path ? path13.basename(String(obj.file_path)) : "file";
13617
14782
  const oldPreview = smartTruncate(String(obj.old_string), 120);
13618
14783
  const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
13619
14784
  return {
13620
14785
  intent: "EDIT",
13621
- message: `\u{1F4DD} EDITING: ${file}
14786
+ message: `\u{1F4DD} EDITING: ${file2}
13622
14787
  \u{1F4C2} PATH: ${obj.file_path}
13623
14788
 
13624
14789
  --- REPLACING ---
@@ -13676,7 +14841,7 @@ ${smartTruncate(str, 500)}`
13676
14841
  function escapePango(text) {
13677
14842
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13678
14843
  }
13679
- function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription) {
14844
+ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription, exposure = false) {
13680
14845
  const lines = [];
13681
14846
  if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
13682
14847
  const safeAgent = safeMessage(agent ?? "AI Agent", 80);
@@ -13685,17 +14850,19 @@ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, loc
13685
14850
  if (ruleDescription) lines.push(`\u2139 ${ruleDescription}`);
13686
14851
  lines.push("");
13687
14852
  lines.push(formattedArgs);
13688
- if (allowCount >= 3) {
14853
+ if (allowCount >= 3 && !exposure) {
13689
14854
  lines.push("");
13690
14855
  lines.push(`\u{1F4A1} Approved ${allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule`);
13691
14856
  }
13692
14857
  if (!locked) {
13693
14858
  lines.push("");
13694
- lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
14859
+ lines.push(
14860
+ exposure ? '\u21B5 Enter = Allow once | \u238B Esc = Block | "Allow & trust" = this address is fine from now on' : '\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again'
14861
+ );
13695
14862
  }
13696
14863
  return lines.join("\n");
13697
14864
  }
13698
- function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription) {
14865
+ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription, exposure = false) {
13699
14866
  const lines = [];
13700
14867
  if (locked) {
13701
14868
  lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
@@ -13708,7 +14875,7 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
13708
14875
  if (ruleDescription) lines.push(`<i>\u2139 ${escapePango(ruleDescription)}</i>`);
13709
14876
  lines.push("");
13710
14877
  lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
13711
- if (allowCount >= 3) {
14878
+ if (allowCount >= 3 && !exposure) {
13712
14879
  lines.push("");
13713
14880
  lines.push(
13714
14881
  `<span foreground="#f0c040">\u{1F4A1} Approved ${allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule</span>`
@@ -13717,13 +14884,14 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
13717
14884
  if (!locked) {
13718
14885
  lines.push("");
13719
14886
  lines.push(
13720
- '<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
14887
+ exposure ? '<small>\u21B5 Enter = <b>Allow once</b> | \u238B Esc = <b>Block</b> | "Allow &amp; trust" = this address is fine from now on</small>' : '<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
13721
14888
  );
13722
14889
  }
13723
14890
  return lines.join("\n");
13724
14891
  }
13725
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1, ruleDescription) {
14892
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1, ruleDescription, exposureDestinations = []) {
13726
14893
  if (isTestEnv()) return "deny";
14894
+ const thirdButton = exposureDestinations.length > 0 ? "Allow & trust" : "Always Allow";
13727
14895
  const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
13728
14896
  const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
13729
14897
  const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
@@ -13734,7 +14902,8 @@ async function askNativePopup(toolName, args, agent, explainableLabel, locked =
13734
14902
  explainableLabel,
13735
14903
  locked,
13736
14904
  allowCount,
13737
- ruleDescription
14905
+ ruleDescription,
14906
+ exposureDestinations.length > 0
13738
14907
  );
13739
14908
  return new Promise((resolve) => {
13740
14909
  let childProcess = null;
@@ -13754,7 +14923,7 @@ async function askNativePopup(toolName, args, agent, explainableLabel, locked =
13754
14923
  }
13755
14924
  try {
13756
14925
  if (process.platform === "darwin") {
13757
- const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block \u238B", "Always Allow", "Allow \u21B5"} default button "Allow \u21B5" cancel button "Block \u238B"`;
14926
+ const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block \u238B", "${thirdButton}", "Allow \u21B5"} default button "Allow \u21B5" cancel button "Block \u238B"`;
13758
14927
  const script = `on run argv
13759
14928
  tell application "System Events"
13760
14929
  activate
@@ -13770,7 +14939,8 @@ end run`;
13770
14939
  explainableLabel,
13771
14940
  locked,
13772
14941
  allowCount,
13773
- ruleDescription
14942
+ ruleDescription,
14943
+ exposureDestinations.length > 0
13774
14944
  );
13775
14945
  const argsList = [
13776
14946
  locked ? "--info" : "--question",
@@ -13785,7 +14955,7 @@ end run`;
13785
14955
  ];
13786
14956
  if (!locked) {
13787
14957
  argsList.push("--cancel-label", "Block \u238B");
13788
- argsList.push("--extra-button", "Always Allow");
14958
+ argsList.push("--extra-button", thirdButton);
13789
14959
  }
13790
14960
  childProcess = spawn("zenity", argsList);
13791
14961
  } else if (process.platform === "win32") {
@@ -13811,24 +14981,24 @@ end run`;
13811
14981
  }
13812
14982
 
13813
14983
  // src/canary/registry.ts
13814
- import fs12 from "fs";
13815
- import os10 from "os";
13816
- import path14 from "path";
14984
+ import fs13 from "fs";
14985
+ import os11 from "os";
14986
+ import path15 from "path";
13817
14987
  init_dist();
13818
14988
 
13819
14989
  // src/shields/jail.ts
13820
- import fs11 from "fs";
13821
- import os9 from "os";
13822
- import path13 from "path";
14990
+ import fs12 from "fs";
14991
+ import os10 from "os";
14992
+ import path14 from "path";
13823
14993
  init_dist();
13824
14994
  var USER_JAIL_SHIELD = "user-jail";
13825
14995
  function jailStorePath() {
13826
- return path13.join(os9.homedir(), ".node9", "jail-paths.json");
14996
+ return path14.join(os10.homedir(), ".node9", "jail-paths.json");
13827
14997
  }
13828
14998
  function readJailPaths() {
13829
14999
  let text;
13830
15000
  try {
13831
- text = fs11.readFileSync(jailStorePath(), "utf8");
15001
+ text = fs12.readFileSync(jailStorePath(), "utf8");
13832
15002
  } catch (err) {
13833
15003
  if (err.code === "ENOENT") return [];
13834
15004
  throw err;
@@ -13857,7 +15027,7 @@ function findJailedPathIn(candidate, paths) {
13857
15027
 
13858
15028
  // src/canary/registry.ts
13859
15029
  function canaryStorePath() {
13860
- return path14.join(os10.homedir(), ".node9", "canaries.json");
15030
+ return path15.join(os11.homedir(), ".node9", "canaries.json");
13861
15031
  }
13862
15032
  function isRecord(x) {
13863
15033
  if (!x || typeof x !== "object") return false;
@@ -13868,7 +15038,7 @@ function loadCanaries(opts) {
13868
15038
  const p = canaryStorePath();
13869
15039
  let raw;
13870
15040
  try {
13871
- raw = fs12.readFileSync(p, "utf-8");
15041
+ raw = fs13.readFileSync(p, "utf-8");
13872
15042
  } catch (e) {
13873
15043
  if (e.code === "ENOENT") return [];
13874
15044
  throw e;
@@ -13894,31 +15064,390 @@ function canaryValues() {
13894
15064
  init_dist();
13895
15065
  init_dist();
13896
15066
  init_audit();
15067
+ init_dist();
15068
+
15069
+ // src/exposure/known-hosts.ts
15070
+ import fs15 from "fs";
15071
+ import path17 from "path";
15072
+
15073
+ // src/utils/git-remotes.ts
15074
+ init_dist();
15075
+ import fs14 from "fs";
15076
+ import path16 from "path";
15077
+ function readSmallFile(p, max) {
15078
+ let fd;
15079
+ try {
15080
+ fd = fs14.openSync(p, fs14.constants.O_RDONLY | (fs14.constants.O_NOFOLLOW ?? 0));
15081
+ const st = fs14.fstatSync(fd);
15082
+ if (!st.isFile() || st.size > max) return null;
15083
+ const buf = Buffer.alloc(st.size);
15084
+ const n = fs14.readSync(fd, buf, 0, st.size, 0);
15085
+ return buf.subarray(0, n).toString("utf8");
15086
+ } catch {
15087
+ return null;
15088
+ } finally {
15089
+ if (fd !== void 0) fs14.closeSync(fd);
15090
+ }
15091
+ }
15092
+ function gitRemoteHost(url) {
15093
+ const u = url.trim();
15094
+ if (!u) return null;
15095
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) {
15096
+ try {
15097
+ const h = new URL(u).hostname.toLowerCase();
15098
+ return h || null;
15099
+ } catch {
15100
+ return null;
15101
+ }
15102
+ }
15103
+ const scp = /^(?:[^@/\s]+@)?([A-Za-z0-9.-]+):(?!\/\/)/.exec(u);
15104
+ if (scp && scp[1].includes(".")) return scp[1].toLowerCase();
15105
+ return null;
15106
+ }
15107
+ function findGitConfig(dir2, walkUp = true) {
15108
+ let cur = path16.resolve(dir2);
15109
+ for (let i = 0; i < (walkUp ? 64 : 1); i++) {
15110
+ const dotGit = path16.join(cur, ".git");
15111
+ const direct = readSmallFile(path16.join(dotGit, "config"), 1e6);
15112
+ if (direct !== null) return direct;
15113
+ const pointer = readSmallFile(dotGit, 4096);
15114
+ const m = pointer && /^gitdir:\s*(.+?)\s*$/m.exec(pointer);
15115
+ if (m) {
15116
+ let gitDir = path16.resolve(cur, m[1]);
15117
+ const common = readSmallFile(path16.join(gitDir, "commondir"), 4096);
15118
+ if (common !== null) gitDir = path16.resolve(gitDir, common.trim());
15119
+ return readSmallFile(path16.join(gitDir, "config"), 1e6);
15120
+ }
15121
+ const parent = path16.dirname(cur);
15122
+ if (parent === cur) return null;
15123
+ cur = parent;
15124
+ }
15125
+ return null;
15126
+ }
15127
+ var cache2 = /* @__PURE__ */ new Map();
15128
+ function readGitRemotes(dir2, opts = {}) {
15129
+ if (!dir2) return [];
15130
+ const walkUp = opts.walkUp !== false;
15131
+ const key = `${walkUp ? "up" : "at"}:${path16.resolve(dir2)}`;
15132
+ const hit = cache2.get(key);
15133
+ if (hit) return hit;
15134
+ const out = [];
15135
+ try {
15136
+ const cfg = findGitConfig(path16.resolve(dir2), walkUp);
15137
+ if (cfg !== null) {
15138
+ let current = null;
15139
+ for (const line of cfg.split(/\r?\n/)) {
15140
+ const sec = /^\s*\[\s*([^\]\s]+)(?:\s+"([^"]*)")?\s*\]/.exec(line);
15141
+ if (sec) {
15142
+ current = sec[1].toLowerCase() === "remote" && sec[2] ? { name: sec[2], urls: [], hosts: [], repos: [] } : null;
15143
+ if (current) out.push(current);
15144
+ continue;
15145
+ }
15146
+ const url = current && /^\s*(?:push)?url\s*=\s*(.+?)\s*$/i.exec(line);
15147
+ if (url && current) {
15148
+ current.urls.push(url[1]);
15149
+ const h = gitRemoteHost(url[1]);
15150
+ if (h && !current.hosts.includes(`host:${h}`)) current.hosts.push(`host:${h}`);
15151
+ const repo = gitRepoPath(url[1]);
15152
+ if (repo && !current.repos.includes(repo)) current.repos.push(repo);
15153
+ }
15154
+ }
15155
+ }
15156
+ } catch {
15157
+ }
15158
+ cache2.set(key, out);
15159
+ return out;
15160
+ }
15161
+
15162
+ // src/exposure/known-hosts.ts
15163
+ var ECOSYSTEMS = [
15164
+ ["package.json", "host:registry.npmjs.org"],
15165
+ ["pyproject.toml", "host:upload.pypi.org"],
15166
+ ["setup.py", "host:upload.pypi.org"],
15167
+ ["Cargo.toml", "host:crates.io"],
15168
+ ["Gemfile", "host:rubygems.org"]
15169
+ ];
15170
+ var cache3 = /* @__PURE__ */ new Map();
15171
+ function manifestRoot(dir2) {
15172
+ let cur = path17.resolve(dir2);
15173
+ for (let i = 0; i < 64; i++) {
15174
+ for (const [m] of ECOSYSTEMS) {
15175
+ try {
15176
+ if (fs15.existsSync(path17.join(cur, m))) return cur;
15177
+ } catch {
15178
+ }
15179
+ }
15180
+ const parent = path17.dirname(cur);
15181
+ if (parent === cur) return null;
15182
+ cur = parent;
15183
+ }
15184
+ return null;
15185
+ }
15186
+ function knownProjectHosts(dir2) {
15187
+ const empty = { hosts: /* @__PURE__ */ new Set(), repos: /* @__PURE__ */ new Set(), remotes: /* @__PURE__ */ new Map() };
15188
+ if (!dir2) return empty;
15189
+ const key = path17.resolve(dir2);
15190
+ const hit = cache3.get(key);
15191
+ if (hit) return hit;
15192
+ const out = { hosts: /* @__PURE__ */ new Set(), repos: /* @__PURE__ */ new Set(), remotes: /* @__PURE__ */ new Map() };
15193
+ try {
15194
+ for (const r of readGitRemotes(key)) {
15195
+ out.remotes.set(r.name, r.hosts);
15196
+ for (const h of r.hosts) out.hosts.add(h);
15197
+ for (const repo of r.repos) out.repos.add(repo);
15198
+ }
15199
+ const root = manifestRoot(key);
15200
+ if (root !== null) {
15201
+ for (const [m, registry] of ECOSYSTEMS) {
15202
+ if (fs15.existsSync(path17.join(root, m))) out.hosts.add(registry);
15203
+ }
15204
+ try {
15205
+ if (fs15.readdirSync(root).some((f) => f.endsWith(".gemspec")))
15206
+ out.hosts.add("host:rubygems.org");
15207
+ } catch {
15208
+ }
15209
+ }
15210
+ } catch {
15211
+ }
15212
+ cache3.set(key, out);
15213
+ return out;
15214
+ }
15215
+
15216
+ // src/exposure/writes.ts
15217
+ init_dist();
15218
+ import fs16 from "fs";
15219
+ import os12 from "os";
15220
+ import path18 from "path";
15221
+ var WRITE_OPS = /* @__PURE__ */ new Set(["write", "create", "remove"]);
15222
+ var LIFECYCLE_RE = /"(scripts|preinstall|install|postinstall|prepare|prepublish|prepublishOnly|prepack|postpack|publish|postpublish)"\s*:/;
15223
+ function writtenContent(args) {
15224
+ if (!args || typeof args !== "object" || Array.isArray(args)) return [];
15225
+ const a = args;
15226
+ const out = [];
15227
+ for (const k of ["content", "new_string", "new_str", "file_text", "contents"]) {
15228
+ if (typeof a[k] === "string") out.push(a[k]);
15229
+ }
15230
+ if (Array.isArray(a.edits)) {
15231
+ for (const e of a.edits) {
15232
+ if (e && typeof e === "object" && typeof e.new_string === "string")
15233
+ out.push(e.new_string);
15234
+ }
15235
+ }
15236
+ return out;
15237
+ }
15238
+ function packageScriptsTouched(args, absPath, shellCommand) {
15239
+ if (shellCommand !== null) return true;
15240
+ const a = args ?? {};
15241
+ const texts = [
15242
+ ...writtenContent(args),
15243
+ ...typeof a.old_string === "string" ? [a.old_string] : [],
15244
+ ...Array.isArray(a.edits) ? a.edits.map((e) => String(e?.old_string ?? "")) : []
15245
+ ];
15246
+ if (typeof a.content === "string") {
15247
+ try {
15248
+ const next = JSON.parse(a.content);
15249
+ const prev = JSON.parse(fs16.readFileSync(absPath, "utf8"));
15250
+ return JSON.stringify(next.scripts ?? null) !== JSON.stringify(prev.scripts ?? null);
15251
+ } catch {
15252
+ return LIFECYCLE_RE.test(a.content);
15253
+ }
15254
+ }
15255
+ return texts.some((t) => LIFECYCLE_RE.test(t));
15256
+ }
15257
+ function judgeWritesAfterExposure(opts) {
15258
+ const holds = [];
15259
+ const home = opts.home ?? os12.homedir();
15260
+ const tmp = normalizeFilePath(os12.tmpdir());
15261
+ const root = opts.cwd ? normalizeFilePath(opts.cwd, "", home) : null;
15262
+ const seen = /* @__PURE__ */ new Set();
15263
+ for (const access of opts.accesses) {
15264
+ if (!WRITE_OPS.has(access.operation)) continue;
15265
+ const p = access.candidates?.[0] ?? normalizeFilePath(access.path, access.cwd ?? opts.cwd ?? "", home);
15266
+ if (seen.has(p)) continue;
15267
+ seen.add(p);
15268
+ if (p === "/dev" || p.startsWith("/dev/")) continue;
15269
+ if (root !== null && isInside(root, p)) {
15270
+ if (isAgentInstructionPath(p, root) && (path18.basename(p) !== "package.json" || packageScriptsTouched(opts.args, p, opts.shellCommand)))
15271
+ holds.push({ path: p, why: "agent-instruction" });
15272
+ continue;
15273
+ }
15274
+ if (isInside(tmp, p) && !(root !== null && isInside(tmp, root))) continue;
15275
+ holds.push({ path: p, why: "outside-project" });
15276
+ }
15277
+ const texts = opts.shellCommand !== null ? [opts.shellCommand] : writtenContent(opts.args);
15278
+ if (texts.length > 0 && opts.inContent.size > 0) {
15279
+ for (const d of extractContentDestinations(texts.join("\n"))) {
15280
+ if (opts.inContent.has(d) && !opts.isKnown(d)) {
15281
+ const target = [...seen][0] ?? "";
15282
+ if (!holds.some((h) => h.why === "content-destination" && h.destination === d))
15283
+ holds.push({ path: target, why: "content-destination", destination: d });
15284
+ }
15285
+ }
15286
+ }
15287
+ return holds;
15288
+ }
15289
+
15290
+ // src/exposure/marker.ts
15291
+ import crypto3 from "crypto";
15292
+ import fs17 from "fs";
15293
+ import os13 from "os";
15294
+ import path19 from "path";
15295
+
15296
+ // src/daemon/exposure-store.ts
15297
+ var SESSION_EXPOSURE_TTL_MS = 4 * 60 * 60 * 1e3;
15298
+
15299
+ // src/exposure/marker.ts
15300
+ function dir() {
15301
+ return path19.join(os13.homedir(), ".node9", "exposure-sessions");
15302
+ }
15303
+ function file(sessionId) {
15304
+ const id = crypto3.createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
15305
+ return path19.join(dir(), id);
15306
+ }
15307
+ function maybeSessionExposed(sessionId) {
15308
+ if (!sessionId) return false;
15309
+ try {
15310
+ const st = fs17.statSync(file(sessionId));
15311
+ return Date.now() - st.mtimeMs < SESSION_EXPOSURE_TTL_MS;
15312
+ } catch (e) {
15313
+ return e.code !== "ENOENT";
15314
+ }
15315
+ }
15316
+
15317
+ // src/exposure/roles.ts
15318
+ import fs19 from "fs";
15319
+
15320
+ // src/daemon/mcp-tools.ts
15321
+ import fs18 from "fs";
15322
+ import path20 from "path";
15323
+ import os14 from "os";
15324
+ function getMcpToolsFile() {
15325
+ return path20.join(os14.homedir(), ".node9", "mcp-tools.json");
15326
+ }
15327
+ function readMcpToolsConfig() {
15328
+ try {
15329
+ const file2 = getMcpToolsFile();
15330
+ if (!fs18.existsSync(file2)) return {};
15331
+ const raw = fs18.readFileSync(file2, "utf-8");
15332
+ return JSON.parse(raw);
15333
+ } catch {
15334
+ return {};
15335
+ }
15336
+ }
15337
+
15338
+ // src/exposure/roles.ts
15339
+ var EXPOSURE_LABEL = "\u{1F6E1}\uFE0F Node9 Exposure";
15340
+ var AGENT_INTERNAL_PREFIXES = ["ccd_", "visualize"];
15341
+ function foldServerName(name) {
15342
+ return name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase();
15343
+ }
15344
+ var inventoryCache = null;
15345
+ var inventoryStamp = null;
15346
+ function inventoryFileStamp() {
15347
+ try {
15348
+ const st = fs19.statSync(getMcpToolsFile());
15349
+ return `${st.mtimeMs}:${st.size}`;
15350
+ } catch {
15351
+ return "";
15352
+ }
15353
+ }
15354
+ function resolveToolAnnotations(server, tool) {
15355
+ const stamp = inventoryFileStamp();
15356
+ if (inventoryCache === null || stamp !== inventoryStamp) {
15357
+ inventoryStamp = stamp;
15358
+ inventoryCache = /* @__PURE__ */ new Map();
15359
+ try {
15360
+ const seen = /* @__PURE__ */ new Map();
15361
+ for (const cfg of Object.values(readMcpToolsConfig())) {
15362
+ if (!cfg?.name) continue;
15363
+ const key = foldServerName(cfg.name);
15364
+ seen.set(key, (seen.get(key) ?? 0) + 1);
15365
+ const tools = /* @__PURE__ */ new Map();
15366
+ for (const t of cfg.tools ?? []) tools.set(t.name, t.annotations ?? null);
15367
+ inventoryCache.set(key, tools);
15368
+ }
15369
+ for (const [key, n] of seen) if (n > 1) inventoryCache.delete(key);
15370
+ } catch {
15371
+ }
15372
+ }
15373
+ return inventoryCache.get(foldServerName(server))?.get(tool) ?? null;
15374
+ }
15375
+ var BUILTIN_SOURCES = /* @__PURE__ */ new Set([
15376
+ "webfetch",
15377
+ "websearch",
15378
+ "web_fetch",
15379
+ "web_search",
15380
+ "fetch",
15381
+ "navigate",
15382
+ "browser_batch",
15383
+ "get_page_text",
15384
+ "read_page",
15385
+ "find",
15386
+ "read_console_messages",
15387
+ "read_network_requests",
15388
+ // Claude Code reads MCP resources through two built-ins, not through the
15389
+ // server's own tools: an email read as a resource is still an email.
15390
+ "readmcpresourcetool",
15391
+ "listmcpresourcestool"
15392
+ ]);
15393
+ var MCP_RE = /^mcp__([^_](?:[^_]|_(?!_))*?)__(.+)$/i;
15394
+ function splitMcpToolName(toolName) {
15395
+ const m = toolName.match(MCP_RE);
15396
+ return m ? { server: m[1], bare: m[2] } : null;
15397
+ }
15398
+ function toolRole(toolName, policy) {
15399
+ const mcp = splitMcpToolName(toolName);
15400
+ const bare = (mcp ? mcp.bare : toolName).toLowerCase();
15401
+ if (mcp) {
15402
+ const configured = policy.roles[mcp.server]?.[mcp.bare] ?? policy.roles[mcp.server]?.[bare];
15403
+ if (configured === "source") return { source: true, action: false };
15404
+ if (configured === "action") return { source: false, action: true };
15405
+ if (configured === "read") return { source: false, action: false };
15406
+ const serverLower = mcp.server.toLowerCase();
15407
+ if (AGENT_INTERNAL_PREFIXES.some((p) => serverLower.startsWith(p)))
15408
+ return { source: false, action: false };
15409
+ if (BUILTIN_SOURCES.has(bare)) return { source: true, action: false };
15410
+ const ann = resolveToolAnnotations(mcp.server, mcp.bare);
15411
+ return { source: true, action: true, destructive: ann?.destructiveHint === true };
15412
+ }
15413
+ return { source: BUILTIN_SOURCES.has(bare), action: false };
15414
+ }
15415
+ function sourceWord(toolName) {
15416
+ const mcp = splitMcpToolName(toolName);
15417
+ const bare = (mcp ? mcp.bare : toolName).toLowerCase();
15418
+ const server = (mcp?.server ?? "").toLowerCase();
15419
+ if (/mail|message|inbox|thread/.test(bare) || /mail|slack|teams|chat/.test(server))
15420
+ return "email";
15421
+ if (/issue|ticket|pull|pr\b|comment/.test(bare) || /github|gitlab|jira|linear/.test(server))
15422
+ return "issue";
15423
+ if (BUILTIN_SOURCES.has(bare) || /browser|web|http|search/.test(server)) return "web page";
15424
+ return "tool result";
15425
+ }
13897
15426
 
13898
15427
  // src/auth/cloud.ts
13899
15428
  init_audit();
13900
- import fs14 from "fs";
13901
- import os11 from "os";
13902
- import path15 from "path";
15429
+ import fs21 from "fs";
15430
+ import os15 from "os";
15431
+ import path21 from "path";
13903
15432
 
13904
15433
  // src/utils/read-capped.ts
13905
- import fs13 from "fs";
13906
- function readCapped(file, maxBytes) {
15434
+ import fs20 from "fs";
15435
+ function readCapped(file2, maxBytes) {
13907
15436
  if (maxBytes < 0) return null;
13908
15437
  let fd;
13909
15438
  try {
13910
- fd = fs13.openSync(file, "r");
15439
+ fd = fs20.openSync(file2, "r");
13911
15440
  } catch {
13912
15441
  return null;
13913
15442
  }
13914
15443
  try {
13915
- const st = fs13.fstatSync(fd);
15444
+ const st = fs20.fstatSync(fd);
13916
15445
  if (!st.isFile()) return null;
13917
15446
  const want = Math.min(st.size, maxBytes + 1);
13918
15447
  const buf = Buffer.alloc(want);
13919
15448
  let read = 0;
13920
15449
  while (read < want) {
13921
- const n = fs13.readSync(fd, buf, read, want - read, read);
15450
+ const n = fs20.readSync(fd, buf, read, want - read, read);
13922
15451
  if (n <= 0) break;
13923
15452
  read += n;
13924
15453
  }
@@ -13930,13 +15459,13 @@ function readCapped(file, maxBytes) {
13930
15459
  return null;
13931
15460
  } finally {
13932
15461
  try {
13933
- fs13.closeSync(fd);
15462
+ fs20.closeSync(fd);
13934
15463
  } catch {
13935
15464
  }
13936
15465
  }
13937
15466
  }
13938
- function readCappedText(file, maxBytes) {
13939
- const r = readCapped(file, maxBytes);
15467
+ function readCappedText(file2, maxBytes) {
15468
+ const r = readCapped(file2, maxBytes);
13940
15469
  return r ? { text: r.bytes.toString("utf8"), truncated: r.truncated } : null;
13941
15470
  }
13942
15471
 
@@ -13948,7 +15477,7 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
13948
15477
  let ciContext;
13949
15478
  if (process.env.CI) {
13950
15479
  try {
13951
- const ciContextPath = path15.join(os11.homedir(), ".node9", "ci-context.json");
15480
+ const ciContextPath = path21.join(os15.homedir(), ".node9", "ci-context.json");
13952
15481
  const ci = readCappedText(ciContextPath, 1e4);
13953
15482
  if (!ci) throw new Error("ci-context.json unreadable");
13954
15483
  if (ci.truncated) throw new Error("ci-context.json exceeds 10 KB");
@@ -13985,11 +15514,21 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
13985
15514
  context: {
13986
15515
  agent: meta?.agent,
13987
15516
  mcpServer: meta?.mcpServer,
13988
- hostname: os11.hostname(),
15517
+ hostname: os15.hostname(),
13989
15518
  cwd: process.cwd(),
13990
- platform: os11.platform()
15519
+ platform: os15.platform()
15520
+ },
15521
+ // The SaaS validates riskMetadata strictly and would drop it whole on
15522
+ // an unknown key, so only fields its schema declares go on the wire.
15523
+ // exposureDestinations ships since the dashboard's 2b contract
15524
+ // (fw#318): "Approve and trust" needs them.
15525
+ ...riskMetadata && {
15526
+ riskMetadata: Object.fromEntries(
15527
+ Object.entries(riskMetadata).filter(
15528
+ ([k, v]) => !Array.isArray(v) || k === "exposureDestinations"
15529
+ )
15530
+ )
13991
15531
  },
13992
- ...riskMetadata && { riskMetadata },
13993
15532
  ...ciContext && { ciContext },
13994
15533
  ...agentPolicy && { policy: agentPolicy },
13995
15534
  ...forceReview && { forceReview: true }
@@ -14051,14 +15590,14 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
14051
15590
  });
14052
15591
  clearTimeout(timer);
14053
15592
  if (!res.ok) {
14054
- fs14.appendFileSync(
15593
+ fs21.appendFileSync(
14055
15594
  HOOK_DEBUG_LOG,
14056
15595
  `[resolve-cloud] PATCH ${safeMessage(resolveUrl, 200)} \u2192 HTTP ${res.status}
14057
15596
  `
14058
15597
  );
14059
15598
  }
14060
15599
  } catch (err) {
14061
- fs14.appendFileSync(
15600
+ fs21.appendFileSync(
14062
15601
  HOOK_DEBUG_LOG,
14063
15602
  `[resolve-cloud] PATCH failed for ${safeMessage(requestId, 64)}: ${safeMessage(err)}
14064
15603
  `
@@ -14069,16 +15608,16 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
14069
15608
  // src/loop-detector.ts
14070
15609
  init_dist();
14071
15610
  init_dist();
14072
- import fs15 from "fs";
14073
- import path16 from "path";
14074
- import os12 from "os";
15611
+ import fs22 from "fs";
15612
+ import path22 from "path";
15613
+ import os16 from "os";
14075
15614
  function loopStateFile() {
14076
- return path16.join(os12.homedir(), ".node9", "loop-state.json");
15615
+ return path22.join(os16.homedir(), ".node9", "loop-state.json");
14077
15616
  }
14078
15617
  function readState() {
14079
15618
  try {
14080
- if (!fs15.existsSync(loopStateFile())) return [];
14081
- const raw = fs15.readFileSync(loopStateFile(), "utf-8");
15619
+ if (!fs22.existsSync(loopStateFile())) return [];
15620
+ const raw = fs22.readFileSync(loopStateFile(), "utf-8");
14082
15621
  const parsed = JSON.parse(raw);
14083
15622
  if (!Array.isArray(parsed)) return [];
14084
15623
  return parsed;
@@ -14087,11 +15626,11 @@ function readState() {
14087
15626
  }
14088
15627
  }
14089
15628
  function writeState(records) {
14090
- const dir = path16.dirname(loopStateFile());
14091
- if (!fs15.existsSync(dir)) fs15.mkdirSync(dir, { recursive: true });
14092
- const tmpPath = `${loopStateFile()}.${os12.hostname()}.${process.pid}.tmp`;
14093
- fs15.writeFileSync(tmpPath, JSON.stringify(records));
14094
- fs15.renameSync(tmpPath, loopStateFile());
15629
+ const dir2 = path22.dirname(loopStateFile());
15630
+ if (!fs22.existsSync(dir2)) fs22.mkdirSync(dir2, { recursive: true });
15631
+ const tmpPath = `${loopStateFile()}.${os16.hostname()}.${process.pid}.tmp`;
15632
+ fs22.writeFileSync(tmpPath, JSON.stringify(records));
15633
+ fs22.renameSync(tmpPath, loopStateFile());
14095
15634
  }
14096
15635
  function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
14097
15636
  try {
@@ -14106,20 +15645,20 @@ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
14106
15645
  // src/supply-chain/check.ts
14107
15646
  init_dist();
14108
15647
  import picomatch2 from "picomatch";
14109
- import path19 from "path";
15648
+ import path25 from "path";
14110
15649
 
14111
15650
  // src/supply-chain/osv-index.ts
14112
15651
  init_dist();
14113
- import fs16 from "fs";
14114
- import os13 from "os";
14115
- import path17 from "path";
15652
+ import fs23 from "fs";
15653
+ import os17 from "os";
15654
+ import path23 from "path";
14116
15655
  import { createHash as createHash3 } from "crypto";
14117
15656
  var INDEX_FRESH_MS = 72 * 60 * 60 * 1e3;
14118
15657
  function osvRoot() {
14119
- return path17.join(os13.homedir(), ".node9", "osv");
15658
+ return path23.join(os17.homedir(), ".node9", "osv");
14120
15659
  }
14121
15660
  function ecoDir(eco) {
14122
- return path17.join(osvRoot(), eco);
15661
+ return path23.join(osvRoot(), eco);
14123
15662
  }
14124
15663
  function indexKey(eco, name) {
14125
15664
  return eco === "PyPI" ? normalizePyPiName(name) : name.toLowerCase();
@@ -14128,11 +15667,11 @@ function shardOf(key) {
14128
15667
  return createHash3("sha1").update(key).digest("hex").slice(0, 2);
14129
15668
  }
14130
15669
  function shardPath(eco, shard) {
14131
- return path17.join(ecoDir(eco), "shards", `${shard}.json`);
15670
+ return path23.join(ecoDir(eco), "shards", `${shard}.json`);
14132
15671
  }
14133
15672
  function readMeta(eco) {
14134
15673
  try {
14135
- const m = JSON.parse(fs16.readFileSync(path17.join(ecoDir(eco), "meta.json"), "utf8"));
15674
+ const m = JSON.parse(fs23.readFileSync(path23.join(ecoDir(eco), "meta.json"), "utf8"));
14136
15675
  if (typeof m?.syncedAt !== "string" || typeof m?.lastModifiedMs !== "number") return null;
14137
15676
  return m;
14138
15677
  } catch {
@@ -14141,7 +15680,7 @@ function readMeta(eco) {
14141
15680
  }
14142
15681
  function readShard(eco, shard) {
14143
15682
  try {
14144
- const s = JSON.parse(fs16.readFileSync(shardPath(eco, shard), "utf8"));
15683
+ const s = JSON.parse(fs23.readFileSync(shardPath(eco, shard), "utf8"));
14145
15684
  return s && typeof s === "object" && !Array.isArray(s) ? s : {};
14146
15685
  } catch {
14147
15686
  return {};
@@ -14338,19 +15877,19 @@ async function registryInfo(eco, name, version, freshWindowMs, raw) {
14338
15877
  }
14339
15878
 
14340
15879
  // src/supply-chain/local-resolve.ts
14341
- import fs17 from "fs";
14342
- import path18 from "path";
15880
+ import fs24 from "fs";
15881
+ import path24 from "path";
14343
15882
  var MAX_LEVELS = 32;
14344
15883
  var LOCAL_FIRST_MANAGERS = /* @__PURE__ */ new Set(["npx", "bunx", "npm exec"]);
14345
15884
  function resolveInstalled(name, cwd) {
14346
- if (!cwd || !path18.isAbsolute(cwd)) return null;
15885
+ if (!cwd || !path24.isAbsolute(cwd)) return null;
14347
15886
  if (!/^(?:@[^/\\]+\/)?[^/\\]+$/.test(name)) return null;
14348
- let dir = path18.resolve(cwd);
15887
+ let dir2 = path24.resolve(cwd);
14349
15888
  for (let i = 0; i < MAX_LEVELS; i++) {
14350
- const pkgDir = path18.join(dir, "node_modules", ...name.split("/"));
14351
- const manifest = path18.join(pkgDir, "package.json");
15889
+ const pkgDir = path24.join(dir2, "node_modules", ...name.split("/"));
15890
+ const manifest = path24.join(pkgDir, "package.json");
14352
15891
  try {
14353
- const raw = fs17.readFileSync(manifest, "utf8");
15892
+ const raw = fs24.readFileSync(manifest, "utf8");
14354
15893
  const parsed = JSON.parse(raw);
14355
15894
  if (typeof parsed.version === "string" && parsed.version.length > 0) {
14356
15895
  return { version: parsed.version, dir: pkgDir };
@@ -14358,9 +15897,9 @@ function resolveInstalled(name, cwd) {
14358
15897
  return null;
14359
15898
  } catch {
14360
15899
  }
14361
- const parent = path18.dirname(dir);
14362
- if (parent === dir) break;
14363
- dir = parent;
15900
+ const parent = path24.dirname(dir2);
15901
+ if (parent === dir2) break;
15902
+ dir2 = parent;
14364
15903
  }
14365
15904
  return null;
14366
15905
  }
@@ -14455,7 +15994,7 @@ function checkInstalled(p, version, misses) {
14455
15994
  }
14456
15995
  async function checkOne(p, cfg, misses, networkCapped, cwd) {
14457
15996
  if (p.ecosystem === "npm" && LOCAL_FIRST_MANAGERS.has(p.manager) && p.localCwd !== null && (p.raw === p.name || p.version)) {
14458
- const localCwd = cwd && path19.isAbsolute(cwd) ? (p.localCwd ?? []).reduce((dir, step) => path19.resolve(dir, step), cwd) : void 0;
15997
+ const localCwd = cwd && path25.isAbsolute(cwd) ? (p.localCwd ?? []).reduce((dir2, step) => path25.resolve(dir2, step), cwd) : void 0;
14459
15998
  const installed = resolveInstalled(p.name, localCwd);
14460
15999
  if (installed && (!p.version || p.version === installed.version))
14461
16000
  return checkInstalled(p, installed.version, misses);
@@ -14590,6 +16129,9 @@ function isWriteTool(toolName) {
14590
16129
  const t = toolName.toLowerCase().replace(/[^a-z_]/g, "_");
14591
16130
  return WRITE_TOOLS.has(t);
14592
16131
  }
16132
+ function isMcpToolName(toolName) {
16133
+ return /^mcp__[^_](?:[^_]|_(?!_))*?__/i.test(toolName);
16134
+ }
14593
16135
  function extractFilePaths(toolName, args, shellCommand) {
14594
16136
  const paths = [];
14595
16137
  if (!args || typeof args !== "object" || Array.isArray(args)) return paths;
@@ -14707,7 +16249,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
14707
16249
  let fileAccess;
14708
16250
  let fileFinding;
14709
16251
  try {
14710
- fileAccess = prepareFileAccesses(toolName, args, options?.cwd, config.policy.toolInspection);
16252
+ fileAccess = prepareFileAccesses(toolName, args, options?.cwd, inspectionLayers(config.policy));
14711
16253
  fileFinding = isManual ? null : evaluateProtectedFileAccess(
14712
16254
  fileAccess.accesses,
14713
16255
  fileAccess,
@@ -14726,7 +16268,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
14726
16268
  fileAccess = { cwd: options?.cwd, homeDirs: [], accesses: [], handledPaths: /* @__PURE__ */ new Set() };
14727
16269
  fileFinding = isManual ? null : incompleteFileVerdict();
14728
16270
  }
14729
- const shellInput = resolveShellInput(toolName, args, config.policy.toolInspection);
16271
+ const shellInput = resolveShellInput(toolName, args, inspectionLayers(config.policy));
14730
16272
  const networkShell = shellInput.kind === "shell" && NETWORK_COMMAND_RE.test(shellInput.command);
14731
16273
  const shellReview = !isManual && shellInput.kind === "uninspectable";
14732
16274
  if (shellReview) {
@@ -14823,13 +16365,173 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
14823
16365
  }
14824
16366
  }
14825
16367
  }
14826
- if (!taintWarning && meta?.sessionId && (networkShell || isWriteTool(toolName) || options?.sessionTaintGated === true)) {
16368
+ const mcpHookTool = options?.sessionTaintGated === void 0 && isMcpToolName(toolName);
16369
+ let daemonSaysExposed;
16370
+ if (!taintWarning && meta?.sessionId && (networkShell || isWriteTool(toolName) || options?.sessionTaintGated === true || mcpHookTool)) {
14827
16371
  const sessionTaint = await checkSessionTaint(meta.sessionId);
16372
+ if (typeof sessionTaint.exposed === "boolean") daemonSaysExposed = sessionTaint.exposed;
14828
16373
  if (sessionTaint.tainted && sessionTaint.record) {
14829
- const kind = isWriteTool(toolName) ? "write" : networkShell ? "network" : "state-changing";
16374
+ const kind = isWriteTool(toolName) ? "write" : networkShell ? "network" : mcpHookTool ? "MCP" : "state-changing";
14830
16375
  taintWarning = `\u26A0\uFE0F node9 flagged this session \u2014 earlier tool output contained ${sessionTaint.record.source}. Approve this ${kind} action before it proceeds.`;
14831
16376
  }
14832
16377
  }
16378
+ let exposureReview = null;
16379
+ let exposureKeys = [];
16380
+ let exposureDestinations = [];
16381
+ let exposureFromEid;
16382
+ const ucPolicy = config.policy.untrustedContent;
16383
+ if (ucPolicy?.enabled && !taintWarning && meta?.sessionId && !isManual) {
16384
+ const role = toolRole(toolName, ucPolicy);
16385
+ const acts = role.action || options?.sessionTaintGated === true;
16386
+ const shellExp = shellInput.kind === "shell" ? classifyShellExposure(shellInput.command) : null;
16387
+ const bareName = splitMcpToolName(toolName)?.bare;
16388
+ const mcpFileWrite = bareName !== void 0 && fileToolOperation(bareName, args) === "write";
16389
+ const writes = (mcpFileWrite ? prepareFileAccesses(bareName, args, options?.cwd, inspectionLayers(config.policy)).accesses : fileAccess.accesses).filter((a) => ["write", "create", "remove"].includes(a.operation));
16390
+ const sendsSomething = acts && !mcpFileWrite || !!shellExp?.sends;
16391
+ const mayBeExposed = daemonSaysExposed ?? (maybeSessionExposed(meta.sessionId) || writes.length > 0 && judgeWritesAfterExposure({
16392
+ accesses: writes,
16393
+ args,
16394
+ shellCommand: shellInput.kind === "shell" ? shellInput.command : null,
16395
+ cwd: options?.cwd,
16396
+ inContent: /* @__PURE__ */ new Set(),
16397
+ isKnown: () => true
16398
+ }).length > 0);
16399
+ const judged = sendsSomething || (!!shellExp?.fetches || writes.length > 0) && mayBeExposed;
16400
+ if (judged) {
16401
+ const exposure = await checkSessionExposure(meta.sessionId);
16402
+ if (exposure.exposed && exposure.record) {
16403
+ const rec = exposure.record;
16404
+ const dests = new Set(
16405
+ acts && !shellExp && !mcpFileWrite ? extractArgDestinations(args) : []
16406
+ );
16407
+ const fetchOnly = /* @__PURE__ */ new Set();
16408
+ const projectHosts = shellExp ? knownProjectHosts(options?.cwd) : null;
16409
+ let unknownRemote = false;
16410
+ if (shellExp) {
16411
+ if (shellExp.sends) {
16412
+ for (const d of shellExp.destinations) dests.add(d);
16413
+ for (const name of shellExp.gitRemotes) {
16414
+ const remotes = projectHosts.remotes;
16415
+ const hosts = name === "" ? remotes.get("origin") ?? [...remotes.values()].flat() : remotes.get(name);
16416
+ if (hosts) for (const h of hosts) dests.add(h);
16417
+ else if (projectHosts.remotes.size > 0) unknownRemote = true;
16418
+ }
16419
+ }
16420
+ if (shellExp.fetches) {
16421
+ for (const h of shellExp.fetchDestinations) if (!dests.has(h)) fetchOnly.add(h);
16422
+ }
16423
+ }
16424
+ const inContent = new Set(rec.destinations);
16425
+ const mentioned = new Set(rec.userMentioned);
16426
+ const approved = new Set(rec.approved);
16427
+ const held = [];
16428
+ const logged = [];
16429
+ const writeProjectHosts = projectHosts ?? knownProjectHosts(options?.cwd);
16430
+ const isKnown = (d) => {
16431
+ const domain = destinationDomain(d);
16432
+ const value = destinationValue(d);
16433
+ return domain !== null && ucPolicy.orgDomains.includes(domain) || mentioned.has(d) || isTrustedRecipient(d) || ucPolicy.trustedRecipients.includes(d) || destinationKind(d) === "host" && (isLoopbackHost(value) || isTrustedHost(value) || config.policy.trustedHostsManaged && matchesTrustedHost(value, config.policy.trustedHosts) || config.policy.egress.allow.some((pat) => hostMatches(value, pat)) || writeProjectHosts.hosts.has(d));
16434
+ };
16435
+ const approvedOnce = (key) => ucPolicy.afterExposure === "ask-once" && approved.has(key);
16436
+ const repos = (shellExp?.sends ? shellExp.repos : []).filter(
16437
+ (r) => writeProjectHosts.hosts.has(`host:${r.split("/")[0]}`)
16438
+ );
16439
+ const repoHosts = new Set(repos.map((r) => `host:${r.split("/")[0]}`));
16440
+ for (const r of repos) {
16441
+ if (writeProjectHosts.repos.has(r) || approvedOnce(`${toolName}|repo:${r}`))
16442
+ logged.push(`repo:${r}`);
16443
+ else held.push({ dest: "", why: "repository", repo: r });
16444
+ }
16445
+ for (const d of dests) {
16446
+ if (repoHosts.has(d)) continue;
16447
+ if (approvedOnce(`${toolName}|${d}`) || isKnown(d)) logged.push(d);
16448
+ else if (inContent.has(d)) held.push({ dest: d, why: "appeared in what was read" });
16449
+ else held.push({ dest: d, why: "not known to this session" });
16450
+ }
16451
+ for (const d of fetchOnly) {
16452
+ if (approvedOnce(`${toolName}|${d}`) || isKnown(d) || !inContent.has(d)) logged.push(d);
16453
+ else held.push({ dest: d, why: "appeared in what was read" });
16454
+ }
16455
+ if (shellExp?.sends && (shellExp.unresolved || unknownRemote || shellExp.destinations.length === 0 && shellExp.gitRemotes.length === 0) && !approvedOnce(`${toolName}|unresolved`)) {
16456
+ held.push({ dest: "", why: "unresolved" });
16457
+ }
16458
+ const bare = splitMcpToolName(toolName)?.bare ?? toolName;
16459
+ if (dests.size === 0 && acts && !mcpFileWrite && (role.destructive === true || isDestructiveToolName(bare))) {
16460
+ held.push({ dest: "", why: "destructive" });
16461
+ }
16462
+ const writeHolds = writes.length > 0 ? judgeWritesAfterExposure({
16463
+ accesses: writes,
16464
+ args,
16465
+ shellCommand: shellInput.kind === "shell" ? shellInput.command : null,
16466
+ cwd: options?.cwd,
16467
+ inContent,
16468
+ isKnown
16469
+ }).filter((h) => {
16470
+ const key = `${toolName}|${h.why === "content-destination" ? h.destination : `path:${h.path}`}`;
16471
+ if (ucPolicy.afterExposure === "ask-once" && approved.has(key)) {
16472
+ logged.push(key);
16473
+ return false;
16474
+ }
16475
+ return true;
16476
+ }) : [];
16477
+ const last = rec.sources[rec.sources.length - 1];
16478
+ const readWord = last ? last.word ?? sourceWord(last.tool) : "content";
16479
+ const when = last ? new Date(last.at).toTimeString().slice(0, 5) : "";
16480
+ const read = last ? `${/^[aeiou]/i.test(readWord) ? "an" : "a"} ${readWord}` + (last.server ? ` through ${last.server}` : "") + (when ? ` at ${when}` : "") : "outside content";
16481
+ if ((held.length > 0 || writeHolds.length > 0) && ucPolicy.afterExposure !== "log-only") {
16482
+ const noun = (d) => {
16483
+ const k = destinationKind(d);
16484
+ return k === "email" ? "an address" : k === "host" ? "a host" : "an account";
16485
+ };
16486
+ const parts = held.map(
16487
+ (h) => h.dest ? `sends to ${destinationValue(h.dest)}, ${h.why === "appeared in what was read" ? `${noun(h.dest)} that appeared in that ${last ? readWord : "content"}` : `${noun(h.dest)} this session did not know`}` : h.why === "repository" ? h.repo.endsWith("/*") ? `sends to a repository on ${h.repo.slice(0, -2)} it does not name (a gist, a new repository or an API call)` : `sends to ${h.repo}, a repository that is not one of this project's remotes` : h.why === "unresolved" ? "sends to a destination this session cannot see (built at run time, or a remote of another repository)" : `is ${bare}, a destructive call, with no known destination`
16488
+ );
16489
+ const home = os18.homedir();
16490
+ const short = (p) => p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
16491
+ for (const w of writeHolds) {
16492
+ parts.push(
16493
+ w.why === "outside-project" ? `writes outside the project, to ${short(w.path)}` : w.why === "agent-instruction" ? `writes to ${short(w.path)}, a file that runs code or instructs the agent` : `writes content carrying ${destinationValue(w.destination ?? "")}, ${noun(w.destination ?? "host:x")} that appeared in that ${readWord}`
16494
+ );
16495
+ }
16496
+ exposureReview = `\u{1F6E1}\uFE0F Node9: this session read ${read}. This call ${parts.join("; and ")}.`;
16497
+ exposureFromEid = last?.eid;
16498
+ if (exposureFromEid) meta = { ...meta, exposureFromEid };
16499
+ exposureKeys = [
16500
+ ...held.filter((h) => h.dest || h.why === "unresolved" || h.why === "repository").map(
16501
+ (h) => h.why === "repository" ? `${toolName}|repo:${h.repo}` : `${toolName}|${h.dest || "unresolved"}`
16502
+ ),
16503
+ ...writeHolds.map(
16504
+ (w) => `${toolName}|${w.why === "content-destination" ? w.destination : `path:${w.path}`}`
16505
+ )
16506
+ ];
16507
+ exposureDestinations = held.filter((h) => h.dest).map((h) => h.dest).slice(0, 10);
16508
+ if (exposureDestinations.length > 0 && options?.deferReview) {
16509
+ exposureReview += ` To trust ${exposureDestinations.length === 1 ? "this address" : "these addresses"} for good, the user runs: ` + exposureDestinations.map((d) => `node9 trust recipient add ${destinationValue(d)}`).join("; ") + ".";
16510
+ }
16511
+ } else if (held.length > 0 || writeHolds.length > 0 || logged.length > 0) {
16512
+ meta = {
16513
+ ...meta,
16514
+ exposure: { kind: "logged", destinations: logged.length },
16515
+ ...last?.eid ? { exposureFromEid: last.eid } : {}
16516
+ };
16517
+ appendToLog(HOOK_DEBUG_LOG, {
16518
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
16519
+ event: "exposure-logged",
16520
+ tool: toolName,
16521
+ held: held.length + writeHolds.length,
16522
+ known: logged.length,
16523
+ sessionId: meta.sessionId
16524
+ });
16525
+ }
16526
+ } else if (exposure.daemonUnavailable) {
16527
+ appendToLog(HOOK_DEBUG_LOG, {
16528
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
16529
+ event: "exposure-unavailable",
16530
+ tool: toolName
16531
+ });
16532
+ }
16533
+ }
16534
+ }
14833
16535
  const canaryVals = safeCanaryValues();
14834
16536
  if (canaryVals.length > 0) {
14835
16537
  const canaryHit = matchCanaryArgs(args, canaryVals);
@@ -15070,7 +16772,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
15070
16772
  }
15071
16773
  let appPermReview = null;
15072
16774
  let appPermReviewTool = null;
15073
- const callSpecificReview = () => fileReview || shellReview || dlpReviewFlagged || !!appPermReview || !!taintWarning || !!packageReview;
16775
+ const callSpecificReview = () => fileReview || shellReview || dlpReviewFlagged || !!appPermReview || !!taintWarning || !!exposureReview || !!packageReview;
15074
16776
  const standingTrustApplies = () => !callSpecificReview() && !hardBlockDowngraded;
15075
16777
  const reviewDemanded = () => callSpecificReview() || localSmartRuleMatched === true || options?.localSmartRuleMatched === true;
15076
16778
  if (meta?.serverKey) {
@@ -15315,6 +17017,12 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
15315
17017
  appPermReview ? `${taintWarning}
15316
17018
  ${appPermReview}` : taintWarning
15317
17019
  );
17020
+ } else if (exposureReview) {
17021
+ explainableLabel = EXPOSURE_LABEL;
17022
+ riskMetadata = computeRiskMetadata(args, 6, explainableLabel, void 0, void 0, "exposure");
17023
+ riskMetadata.ruleDescription = (appPermReview ? `${exposureReview}
17024
+ ${appPermReview}` : exposureReview).slice(0, 1024);
17025
+ if (exposureDestinations.length > 0) riskMetadata.exposureDestinations = exposureDestinations;
15318
17026
  } else if (appPermReview) {
15319
17027
  if (dlpReviewFlagged) {
15320
17028
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review) + \u{1F512} App Permission (Review)";
@@ -15357,7 +17065,9 @@ ${packageReview}`;
15357
17065
  // The prompt must say WHY: the taint sentence beats the bare label so
15358
17066
  // the dev sees the actual risk context inline. (No appPermReview term:
15359
17067
  // deferReview and serverKey never co-occur in production — see above.)
15360
- reason: taintWarning || packageReview || explainableLabel || "Node9 flagged this action for review.",
17068
+ reason: taintWarning || packageReview || exposureReview || explainableLabel || "Node9 flagged this action for review.",
17069
+ ...exposureReview && exposureKeys.length > 0 ? { exposureKeys } : {},
17070
+ ...exposureReview && exposureFromEid ? { exposureFromEid } : {},
15361
17071
  ruleDescription: policyRuleDescription,
15362
17072
  blockedByLabel: explainableLabel
15363
17073
  };
@@ -15451,10 +17161,14 @@ ${packageReview}`;
15451
17161
  // auto-allows. This process (the long-running gateway — appPermReview
15452
17162
  // only ever happens here) stays alive to run its own racers, so the
15453
17163
  // daemon just holds the card and waits for the human decision.
15454
- appPermReview ? true : void 0,
17164
+ // fw#318 P1.1: the same for every call-specific review (exposure,
17165
+ // session taint, DLP, file review): the re-run carries no session id
17166
+ // and cannot see these reasons, so it must never answer the card.
17167
+ appPermReview || callSpecificReview() ? true : void 0,
15455
17168
  void 0,
15456
17169
  localSmartRuleMatched || options?.localSmartRuleMatched,
15457
- options?.socketActivitySent
17170
+ options?.socketActivitySent,
17171
+ meta?.sessionId
15458
17172
  );
15459
17173
  daemonEntryId = entry.id;
15460
17174
  daemonAllowCount = entry.allowCount;
@@ -15495,8 +17209,15 @@ ${packageReview}`;
15495
17209
  policyMatchedField,
15496
17210
  policyMatchedWord,
15497
17211
  daemonAllowCount,
15498
- riskMetadata?.ruleDescription
17212
+ riskMetadata?.ruleDescription,
17213
+ exposureDestinations
15499
17214
  );
17215
+ if (decision === "trust_recipients") {
17216
+ if (config.policySource !== "workspace") {
17217
+ for (const d of exposureDestinations) addTrustedRecipient(d, "approval");
17218
+ }
17219
+ return { approved: true, checkedBy: "daemon", decisionSource: "native" };
17220
+ }
15500
17221
  if (decision === "always_allow") {
15501
17222
  if (standingTrustApplies()) {
15502
17223
  writeTrustSession(toolName, 36e5, args);
@@ -15597,6 +17318,9 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
15597
17318
  });
15598
17319
  }
15599
17320
  });
17321
+ if (finalResult.approved && exposureKeys.length > 0 && meta?.sessionId && ucPolicy?.afterExposure === "ask-once") {
17322
+ await approveSessionExposure(meta.sessionId, exposureKeys);
17323
+ }
15600
17324
  if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
15601
17325
  await resolveNode9SaaS(
15602
17326
  cloudRequestId,