@node9/proxy 1.55.1 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -233,9 +233,9 @@ module.exports = __toCommonJS(src_exports);
233
233
  init_audit();
234
234
 
235
235
  // src/config/index.ts
236
- var import_fs3 = __toESM(require("fs"));
237
- var import_path3 = __toESM(require("path"));
238
- var import_os3 = __toESM(require("os"));
236
+ var import_fs4 = __toESM(require("fs"));
237
+ var import_path4 = __toESM(require("path"));
238
+ var import_os4 = __toESM(require("os"));
239
239
 
240
240
  // src/config-schema.ts
241
241
  var import_zod = require("zod");
@@ -321,6 +321,10 @@ var ConfigFileSchema = import_zod.z.object({
321
321
  // approver_set). Default (unset/false): those tools refuse over MCP — a human
322
322
  // must run them from the CLI. node9's threat model is the agent itself.
323
323
  mcpAllowWeakening: import_zod.z.boolean().optional(),
324
+ // Auto-wire reconciler (P3 2.6): auto-wrap new ungoverned MCP servers vs
325
+ // nudge-only (default), and the scan cadence in minutes.
326
+ mcpAutoWrap: import_zod.z.boolean().optional(),
327
+ mcpReconcileIntervalMinutes: import_zod.z.number().positive().optional(),
324
328
  cloudSyncIntervalHours: import_zod.z.number().positive().optional(),
325
329
  // Seconds-granular override for the cloud policy sync cadence. Wins over
326
330
  // cloudSyncIntervalHours when set. Lets you opt into fast apply (e.g. 20)
@@ -3555,6 +3559,109 @@ function applyManagedDlp(local, managed, locked) {
3555
3559
  }
3556
3560
  return next;
3557
3561
  }
3562
+ function applyManagedApprovers(local, managed) {
3563
+ return {
3564
+ ...local,
3565
+ native: typeof managed.native === "boolean" ? managed.native : local.native,
3566
+ browser: typeof managed.browser === "boolean" ? managed.browser : local.browser,
3567
+ cloud: typeof managed.cloud === "boolean" ? managed.cloud : local.cloud,
3568
+ terminal: typeof managed.terminal === "boolean" ? managed.terminal : local.terminal
3569
+ };
3570
+ }
3571
+
3572
+ // src/shields/build.ts
3573
+ function escapeRegex(s) {
3574
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3575
+ }
3576
+ function slug(s) {
3577
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "rule";
3578
+ }
3579
+ var B = "[\\s/\\\\]";
3580
+ var SEP = "[/\\\\]";
3581
+ function pathToRegexFragment(rawPath) {
3582
+ const tail = rawPath.trim().replace(/^~[\\/]?/, "").replace(/^\$\{?HOME\}?[\\/]?/, "").replace(/^\/(?:home|Users)\/[^\\/]+[\\/]?/, "").replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
3583
+ const segments = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
3584
+ if (segments.length === 0) return "";
3585
+ return `(^|${B})${segments.join(SEP)}(${B}|$)`;
3586
+ }
3587
+ function pathRules(rawPath, verdict, reason) {
3588
+ const value = pathToRegexFragment(rawPath);
3589
+ if (!value) return [];
3590
+ const why = reason ?? `Accessing ${rawPath} is restricted by this shield`;
3591
+ const s = slug(rawPath);
3592
+ return [
3593
+ {
3594
+ name: `${verdict}-path-${s}-bash`,
3595
+ tool: "bash",
3596
+ conditions: [{ field: "command", op: "matches", value }],
3597
+ verdict,
3598
+ reason: why
3599
+ },
3600
+ {
3601
+ name: `${verdict}-path-${s}-anytool`,
3602
+ tool: "*",
3603
+ conditions: [{ field: "file_path", op: "matches", value }],
3604
+ verdict,
3605
+ reason: why
3606
+ }
3607
+ ];
3608
+ }
3609
+
3610
+ // src/auth/trusted-hosts.ts
3611
+ var import_fs3 = __toESM(require("fs"));
3612
+ var import_path3 = __toESM(require("path"));
3613
+ var import_os3 = __toESM(require("os"));
3614
+ function getTrustedHostsPath() {
3615
+ return import_path3.default.join(import_os3.default.homedir(), ".node9", "trusted-hosts.json");
3616
+ }
3617
+ function readTrustedHosts() {
3618
+ try {
3619
+ const raw = import_fs3.default.readFileSync(getTrustedHostsPath(), "utf8");
3620
+ const parsed = JSON.parse(raw);
3621
+ return Array.isArray(parsed.hosts) ? parsed.hosts : [];
3622
+ } catch {
3623
+ return [];
3624
+ }
3625
+ }
3626
+ var _cache = null;
3627
+ var CACHE_TTL_MS = 5e3;
3628
+ function getFileMtime() {
3629
+ try {
3630
+ return import_fs3.default.statSync(getTrustedHostsPath()).mtimeMs;
3631
+ } catch {
3632
+ return 0;
3633
+ }
3634
+ }
3635
+ function getCachedHosts() {
3636
+ const now = Date.now();
3637
+ if (_cache && now < _cache.expiry) {
3638
+ const mtime = getFileMtime();
3639
+ if (mtime === _cache.mtime) return _cache.hosts;
3640
+ }
3641
+ const hosts = readTrustedHosts();
3642
+ _cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
3643
+ return hosts;
3644
+ }
3645
+ function normalizeHost(raw) {
3646
+ return raw.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^[^@]+@/, "").replace(/:\d+$/, "");
3647
+ }
3648
+ function matchesTrustedHost(host, list) {
3649
+ const normalized = normalizeHost(host);
3650
+ return list.some((raw) => {
3651
+ const entryHost = raw.toLowerCase();
3652
+ if (entryHost.startsWith("*.")) {
3653
+ const domain = entryHost.slice(2);
3654
+ return normalized.endsWith("." + domain);
3655
+ }
3656
+ return normalized === entryHost;
3657
+ });
3658
+ }
3659
+ function isTrustedHost(host) {
3660
+ return matchesTrustedHost(
3661
+ host,
3662
+ getCachedHosts().map((entry) => entry.host)
3663
+ );
3664
+ }
3558
3665
 
3559
3666
  // src/config/index.ts
3560
3667
  var DANGEROUS_WORDS = [
@@ -3765,7 +3872,10 @@ var DEFAULT_CONFIG = {
3765
3872
  egress: { enabled: false, mode: "review", allow: [], deny: [], allowPrivate: true },
3766
3873
  loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
3767
3874
  injectionScan: { enabled: false, minConfidence: "medium", allow: [] },
3768
- skillPinning: { enabled: false, mode: "warn", roots: [] }
3875
+ skillPinning: { enabled: false, mode: "warn", roots: [] },
3876
+ trustedHosts: [],
3877
+ trustedHostsManaged: false,
3878
+ appPermissions: {}
3769
3879
  },
3770
3880
  environments: {}
3771
3881
  };
@@ -3840,9 +3950,9 @@ function getCredentials() {
3840
3950
  };
3841
3951
  }
3842
3952
  try {
3843
- const credPath = import_path3.default.join(import_os3.default.homedir(), ".node9", "credentials.json");
3844
- if (import_fs3.default.existsSync(credPath)) {
3845
- const creds = JSON.parse(import_fs3.default.readFileSync(credPath, "utf-8"));
3953
+ const credPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "credentials.json");
3954
+ if (import_fs4.default.existsSync(credPath)) {
3955
+ const creds = JSON.parse(import_fs4.default.readFileSync(credPath, "utf-8"));
3846
3956
  const profileName = process.env.NODE9_PROFILE || "default";
3847
3957
  const profile = creds[profileName];
3848
3958
  if (profile?.apiKey) {
@@ -3868,8 +3978,8 @@ function getActiveEnvironment(config) {
3868
3978
  }
3869
3979
  function getConfig(cwd) {
3870
3980
  if (!cwd && cachedConfig) return cachedConfig;
3871
- const globalPath = import_path3.default.join(import_os3.default.homedir(), ".node9", "config.json");
3872
- const projectPath = import_path3.default.join(cwd ?? process.cwd(), "node9.config.json");
3981
+ const globalPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
3982
+ const projectPath = import_path4.default.join(cwd ?? process.cwd(), "node9.config.json");
3873
3983
  const globalConfig = tryLoadConfig(globalPath);
3874
3984
  const projectConfig = tryLoadConfig(projectPath);
3875
3985
  const mergedSettings = {
@@ -3902,7 +4012,13 @@ function getConfig(cwd) {
3902
4012
  skillPinning: {
3903
4013
  ...DEFAULT_CONFIG.policy.skillPinning,
3904
4014
  roots: [...DEFAULT_CONFIG.policy.skillPinning.roots]
3905
- }
4015
+ },
4016
+ // Left empty on purpose: the local file is read fresh at policy-eval time
4017
+ // (getCachedHosts via isTrustedHost), NOT snapshotted into the frozen config
4018
+ // here. A managed list fills this below and flips trustedHostsManaged.
4019
+ trustedHosts: [],
4020
+ trustedHostsManaged: false,
4021
+ appPermissions: {}
3906
4022
  };
3907
4023
  const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
3908
4024
  const applyLayer = (source) => {
@@ -3924,6 +4040,9 @@ function getConfig(cwd) {
3924
4040
  if (s.mcpAllowWeakening !== void 0) mergedSettings.mcpAllowWeakening = s.mcpAllowWeakening;
3925
4041
  if (s.cloudSyncIntervalHours !== void 0)
3926
4042
  mergedSettings.cloudSyncIntervalHours = s.cloudSyncIntervalHours;
4043
+ if (s.mcpAutoWrap !== void 0) mergedSettings.mcpAutoWrap = s.mcpAutoWrap === true;
4044
+ if (s.mcpReconcileIntervalMinutes !== void 0)
4045
+ mergedSettings.mcpReconcileIntervalMinutes = s.mcpReconcileIntervalMinutes;
3927
4046
  if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
3928
4047
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
3929
4048
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
@@ -4004,9 +4123,9 @@ function getConfig(cwd) {
4004
4123
  applyLayer(projectConfig);
4005
4124
  let cloudManagedShields = [];
4006
4125
  {
4007
- const cacheFile = import_path3.default.join(import_os3.default.homedir(), ".node9", "rules-cache.json");
4126
+ const cacheFile = import_path4.default.join(import_os4.default.homedir(), ".node9", "rules-cache.json");
4008
4127
  try {
4009
- const raw = JSON.parse(import_fs3.default.readFileSync(cacheFile, "utf-8"));
4128
+ const raw = JSON.parse(import_fs4.default.readFileSync(cacheFile, "utf-8"));
4010
4129
  if (Array.isArray(raw.rules) && raw.rules.length > 0) {
4011
4130
  applyLayer({ policy: { smartRules: raw.rules } });
4012
4131
  }
@@ -4047,6 +4166,74 @@ function getConfig(cwd) {
4047
4166
  locked
4048
4167
  );
4049
4168
  }
4169
+ if (mc.approvers && typeof mc.approvers === "object") {
4170
+ const bool = (v) => typeof v === "boolean" ? v : void 0;
4171
+ mergedSettings.approvers = applyManagedApprovers(mergedSettings.approvers, {
4172
+ native: bool(mc.approvers.native),
4173
+ browser: bool(mc.approvers.browser),
4174
+ cloud: bool(mc.approvers.cloud),
4175
+ terminal: bool(mc.approvers.terminal)
4176
+ });
4177
+ }
4178
+ if (mc.reviewChannel === "ask" || mc.reviewChannel === "approver") {
4179
+ mergedSettings.reviewChannel = mc.reviewChannel;
4180
+ }
4181
+ if (typeof mc.approvalTimeoutMs === "number" && mc.approvalTimeoutMs > 0) {
4182
+ mergedSettings.approvalTimeoutMs = mc.approvalTimeoutMs;
4183
+ }
4184
+ if (mc.injectionScan && typeof mc.injectionScan === "object") {
4185
+ const i = mc.injectionScan;
4186
+ const cur = mergedPolicy.injectionScan;
4187
+ mergedPolicy.injectionScan = {
4188
+ enabled: typeof i.enabled === "boolean" ? i.enabled : cur.enabled,
4189
+ minConfidence: i.minConfidence === "high" || i.minConfidence === "medium" ? i.minConfidence : cur.minConfidence,
4190
+ allow: Array.isArray(i.allow) ? i.allow.filter((x) => typeof x === "string") : cur.allow
4191
+ };
4192
+ }
4193
+ if (mc.loopDetection && typeof mc.loopDetection === "object") {
4194
+ const l = mc.loopDetection;
4195
+ const cur = mergedPolicy.loopDetection;
4196
+ mergedPolicy.loopDetection = {
4197
+ enabled: typeof l.enabled === "boolean" ? l.enabled : cur.enabled,
4198
+ threshold: typeof l.threshold === "number" && Number.isFinite(l.threshold) ? l.threshold : cur.threshold,
4199
+ windowSeconds: typeof l.windowSeconds === "number" && Number.isFinite(l.windowSeconds) ? l.windowSeconds : cur.windowSeconds
4200
+ };
4201
+ }
4202
+ if (mc.skillPinning && typeof mc.skillPinning === "object") {
4203
+ const sk = mc.skillPinning;
4204
+ const cur = mergedPolicy.skillPinning;
4205
+ mergedPolicy.skillPinning = {
4206
+ enabled: typeof sk.enabled === "boolean" ? sk.enabled : cur.enabled,
4207
+ mode: sk.mode === "block" || sk.mode === "warn" ? sk.mode : cur.mode,
4208
+ roots: Array.isArray(sk.roots) ? sk.roots.filter((x) => typeof x === "string") : cur.roots
4209
+ };
4210
+ }
4211
+ if (Array.isArray(mc.jailPaths)) {
4212
+ for (const jp of mc.jailPaths) {
4213
+ const path13 = typeof jp?.path === "string" ? jp.path.trim() : "";
4214
+ if (!path13) continue;
4215
+ const verdict = jp?.verdict === "review" ? "review" : "block";
4216
+ for (const r of pathRules(path13, verdict, "org-managed jail")) {
4217
+ mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
4218
+ }
4219
+ }
4220
+ }
4221
+ if (Array.isArray(mc.trustedHosts)) {
4222
+ mergedPolicy.trustedHostsManaged = true;
4223
+ mergedPolicy.trustedHosts = mc.trustedHosts.filter((h) => typeof h === "string").map((h) => normalizeHost(h));
4224
+ }
4225
+ if (mc.appPermissions && typeof mc.appPermissions === "object" && !Array.isArray(mc.appPermissions)) {
4226
+ const coerced = {};
4227
+ for (const [srv, tools] of Object.entries(mc.appPermissions)) {
4228
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) continue;
4229
+ const m = {};
4230
+ for (const [tool, d] of Object.entries(tools)) {
4231
+ if (d === "allow" || d === "review" || d === "block") m[tool] = d;
4232
+ }
4233
+ if (Object.keys(m).length) coerced[srv] = m;
4234
+ }
4235
+ mergedPolicy.appPermissions = coerced;
4236
+ }
4050
4237
  }
4051
4238
  if (raw.panicMode === true) {
4052
4239
  mergedSettings.panicMode = true;
@@ -4098,10 +4285,10 @@ function getConfig(cwd) {
4098
4285
  return result;
4099
4286
  }
4100
4287
  function tryLoadConfig(filePath) {
4101
- if (!import_fs3.default.existsSync(filePath)) return null;
4288
+ if (!import_fs4.default.existsSync(filePath)) return null;
4102
4289
  let raw;
4103
4290
  try {
4104
- raw = JSON.parse(import_fs3.default.readFileSync(filePath, "utf-8"));
4291
+ raw = JSON.parse(import_fs4.default.readFileSync(filePath, "utf-8"));
4105
4292
  } catch (err) {
4106
4293
  const msg = err instanceof Error ? err.message : String(err);
4107
4294
  process.stderr.write(
@@ -4155,18 +4342,18 @@ ${error.replace("Invalid config:\n", "")}
4155
4342
  var import_picomatch2 = __toESM(require("picomatch"));
4156
4343
 
4157
4344
  // src/dlp.ts
4158
- var import_fs4 = __toESM(require("fs"));
4159
- var import_path4 = __toESM(require("path"));
4345
+ var import_fs5 = __toESM(require("fs"));
4346
+ var import_path5 = __toESM(require("path"));
4160
4347
  function scanFilePath(filePath, cwd = process.cwd()) {
4161
4348
  if (!filePath) return null;
4162
4349
  let resolved;
4163
4350
  try {
4164
- const absolute = import_path4.default.resolve(cwd, filePath);
4165
- resolved = import_fs4.default.realpathSync.native(absolute);
4351
+ const absolute = import_path5.default.resolve(cwd, filePath);
4352
+ resolved = import_fs5.default.realpathSync.native(absolute);
4166
4353
  } catch (err) {
4167
4354
  const code = err.code;
4168
4355
  if (code === "ENOENT" || code === "ENOTDIR") {
4169
- resolved = import_path4.default.resolve(cwd, filePath);
4356
+ resolved = import_path5.default.resolve(cwd, filePath);
4170
4357
  } else {
4171
4358
  return sensitivePathMatch(filePath);
4172
4359
  }
@@ -4175,27 +4362,27 @@ function scanFilePath(filePath, cwd = process.cwd()) {
4175
4362
  }
4176
4363
 
4177
4364
  // src/utils/provenance.ts
4178
- var import_fs5 = __toESM(require("fs"));
4179
- var import_path5 = __toESM(require("path"));
4180
- var import_os4 = __toESM(require("os"));
4365
+ var import_fs6 = __toESM(require("fs"));
4366
+ var import_path6 = __toESM(require("path"));
4367
+ var import_os5 = __toESM(require("os"));
4181
4368
  var SYSTEM_PREFIXES = ["/usr/bin", "/usr/sbin", "/bin", "/sbin"];
4182
4369
  var MANAGED_PREFIXES = ["/usr/local/bin", "/opt/homebrew", "/home/linuxbrew", "/nix/store"];
4183
4370
  var USER_PREFIXES = [
4184
- import_path5.default.join(import_os4.default.homedir(), "bin"),
4185
- import_path5.default.join(import_os4.default.homedir(), ".local", "bin"),
4186
- import_path5.default.join(import_os4.default.homedir(), ".cargo", "bin"),
4187
- import_path5.default.join(import_os4.default.homedir(), ".npm-global", "bin"),
4188
- import_path5.default.join(import_os4.default.homedir(), ".volta", "bin")
4371
+ import_path6.default.join(import_os5.default.homedir(), "bin"),
4372
+ import_path6.default.join(import_os5.default.homedir(), ".local", "bin"),
4373
+ import_path6.default.join(import_os5.default.homedir(), ".cargo", "bin"),
4374
+ import_path6.default.join(import_os5.default.homedir(), ".npm-global", "bin"),
4375
+ import_path6.default.join(import_os5.default.homedir(), ".volta", "bin")
4189
4376
  ];
4190
4377
  var SUSPECT_PREFIXES = ["/tmp", "/var/tmp", "/dev/shm"];
4191
4378
  function findInPath(cmd) {
4192
- if (import_path5.default.posix.isAbsolute(cmd)) return cmd;
4379
+ if (import_path6.default.posix.isAbsolute(cmd)) return cmd;
4193
4380
  const pathEnv = process.env.PATH ?? "";
4194
- for (const dir of pathEnv.split(import_path5.default.delimiter)) {
4381
+ for (const dir of pathEnv.split(import_path6.default.delimiter)) {
4195
4382
  if (!dir) continue;
4196
- const full = import_path5.default.join(dir, cmd);
4383
+ const full = import_path6.default.join(dir, cmd);
4197
4384
  try {
4198
- import_fs5.default.accessSync(full, import_fs5.default.constants.X_OK);
4385
+ import_fs6.default.accessSync(full, import_fs6.default.constants.X_OK);
4199
4386
  return full;
4200
4387
  } catch {
4201
4388
  }
@@ -4206,7 +4393,7 @@ function _classifyPath(resolved, cwd) {
4206
4393
  if (cwd && resolved.startsWith(cwd + "/")) {
4207
4394
  return { trustLevel: "user", reason: "binary in project directory" };
4208
4395
  }
4209
- const osTmp = import_os4.default.tmpdir();
4396
+ const osTmp = import_os5.default.tmpdir();
4210
4397
  const allSuspect = osTmp ? [...SUSPECT_PREFIXES, osTmp] : SUSPECT_PREFIXES;
4211
4398
  if (allSuspect.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
4212
4399
  return { trustLevel: "suspect", reason: `binary in temp directory: ${resolved}` };
@@ -4224,7 +4411,7 @@ function _classifyPath(resolved, cwd) {
4224
4411
  }
4225
4412
  function checkProvenance(cmd, cwd) {
4226
4413
  const bare = cmd.startsWith("./") ? cmd.slice(2) : cmd;
4227
- if (import_path5.default.posix.isAbsolute(bare)) {
4414
+ if (import_path6.default.posix.isAbsolute(bare)) {
4228
4415
  const early = _classifyPath(bare, cwd);
4229
4416
  if (early.trustLevel === "suspect") {
4230
4417
  return { resolvedPath: bare, ...early };
@@ -4240,7 +4427,7 @@ function checkProvenance(cmd, cwd) {
4240
4427
  reason: "binary not found in PATH"
4241
4428
  };
4242
4429
  }
4243
- resolved = import_fs5.default.realpathSync(found);
4430
+ resolved = import_fs6.default.realpathSync(found);
4244
4431
  } catch {
4245
4432
  return {
4246
4433
  resolvedPath: cmd,
@@ -4249,7 +4436,7 @@ function checkProvenance(cmd, cwd) {
4249
4436
  };
4250
4437
  }
4251
4438
  try {
4252
- const stat = import_fs5.default.statSync(resolved);
4439
+ const stat = import_fs6.default.statSync(resolved);
4253
4440
  if (stat.mode & 2) {
4254
4441
  return {
4255
4442
  resolvedPath: resolved,
@@ -4268,56 +4455,6 @@ function checkProvenance(cmd, cwd) {
4268
4455
  return { resolvedPath: resolved, ...classify };
4269
4456
  }
4270
4457
 
4271
- // src/auth/trusted-hosts.ts
4272
- var import_fs6 = __toESM(require("fs"));
4273
- var import_path6 = __toESM(require("path"));
4274
- var import_os5 = __toESM(require("os"));
4275
- function getTrustedHostsPath() {
4276
- return import_path6.default.join(import_os5.default.homedir(), ".node9", "trusted-hosts.json");
4277
- }
4278
- function readTrustedHosts() {
4279
- try {
4280
- const raw = import_fs6.default.readFileSync(getTrustedHostsPath(), "utf8");
4281
- const parsed = JSON.parse(raw);
4282
- return Array.isArray(parsed.hosts) ? parsed.hosts : [];
4283
- } catch {
4284
- return [];
4285
- }
4286
- }
4287
- var _cache = null;
4288
- var CACHE_TTL_MS = 5e3;
4289
- function getFileMtime() {
4290
- try {
4291
- return import_fs6.default.statSync(getTrustedHostsPath()).mtimeMs;
4292
- } catch {
4293
- return 0;
4294
- }
4295
- }
4296
- function getCachedHosts() {
4297
- const now = Date.now();
4298
- if (_cache && now < _cache.expiry) {
4299
- const mtime = getFileMtime();
4300
- if (mtime === _cache.mtime) return _cache.hosts;
4301
- }
4302
- const hosts = readTrustedHosts();
4303
- _cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
4304
- return hosts;
4305
- }
4306
- function normalizeHost(raw) {
4307
- return raw.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^[^@]+@/, "").replace(/:\d+$/, "");
4308
- }
4309
- function isTrustedHost(host) {
4310
- const normalized = normalizeHost(host);
4311
- return getCachedHosts().some((entry) => {
4312
- const entryHost = entry.host.toLowerCase();
4313
- if (entryHost.startsWith("*.")) {
4314
- const domain = entryHost.slice(2);
4315
- return normalized.endsWith("." + domain);
4316
- }
4317
- return normalized === entryHost;
4318
- });
4319
- }
4320
-
4321
4458
  // src/policy/index.ts
4322
4459
  async function evaluatePolicy2(toolName, args, agent, cwd) {
4323
4460
  const config = getConfig();
@@ -4327,7 +4464,14 @@ async function evaluatePolicy2(toolName, args, agent, cwd) {
4327
4464
  toolName,
4328
4465
  args,
4329
4466
  { agent, cwd, activeEnvironment },
4330
- { checkProvenance, isTrustedHost }
4467
+ {
4468
+ checkProvenance,
4469
+ // Managed → match against the org list (frozen with the rest of managed
4470
+ // config; changes arrive via cloud sync). Unmanaged → the local file via
4471
+ // getCachedHosts (5s TTL + mtime), so a `node9 trust add/remove` still
4472
+ // reaches a long-lived in-process authorizer (the gateway) within seconds.
4473
+ isTrustedHost: config.policy.trustedHostsManaged ? (host) => matchesTrustedHost(host, config.policy.trustedHosts) : isTrustedHost
4474
+ }
4331
4475
  );
4332
4476
  }
4333
4477
  function isIgnoredTool2(toolName) {
@@ -5274,6 +5418,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5274
5418
  const isManual = meta?.agent === "Terminal";
5275
5419
  const isObserveMode = config.settings.mode === "observe";
5276
5420
  let explainableLabel = "Local Config";
5421
+ let dlpReviewFlagged = false;
5277
5422
  let policyMatchedField;
5278
5423
  let policyMatchedWord;
5279
5424
  let policyRuleDescription;
@@ -5378,6 +5523,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5378
5523
  if (!isManual)
5379
5524
  appendLocalAudit(toolName, args, "allow", "dlp-review-flagged", meta, hashAuditArgs);
5380
5525
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
5526
+ dlpReviewFlagged = true;
5381
5527
  }
5382
5528
  }
5383
5529
  if (config.policy.dlp.pii === "block" && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
@@ -5443,9 +5589,40 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5443
5589
  }
5444
5590
  return { approved: true, checkedBy: "audit" };
5445
5591
  }
5592
+ let appPermReview = null;
5593
+ let appPermReviewTool = null;
5594
+ if (meta?.serverKey) {
5595
+ const prefix = meta.mcpServer ? `mcp__${meta.mcpServer}__` : "";
5596
+ const bareTool = prefix && toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
5597
+ const decision = config.policy.appPermissions?.[meta.serverKey]?.[bareTool];
5598
+ const hardBlock = decision === "block" || decision === "review" && config.settings.panicMode === true;
5599
+ if (hardBlock) {
5600
+ if (!isManual)
5601
+ appendLocalAudit(
5602
+ toolName,
5603
+ args,
5604
+ "deny",
5605
+ "app-permission-block",
5606
+ // ruleName gives the dashboard row its "why" (rule attribution), the
5607
+ // same channel shield fires use; mcpServer (in meta) gives the app chip.
5608
+ { ...meta, ruleName: `app-permission:${bareTool}` },
5609
+ hashAuditArgs
5610
+ );
5611
+ return {
5612
+ approved: false,
5613
+ blockedBy: "local-config",
5614
+ reason: decision === "block" ? `App permission: "${bareTool}" is set to block by your workspace.` : `App permission: "${bareTool}" requires review, and the workspace is in panic mode \u2014 all review actions are blocked.`,
5615
+ blockedByLabel: decision === "block" ? "\u{1F512} Node9 App Permission (Blocked)" : "\u{1F6A8} Panic mode (org policy)"
5616
+ };
5617
+ }
5618
+ if (decision === "review") {
5619
+ appPermReview = `App permission: "${bareTool}" requires human approval (workspace policy).`;
5620
+ appPermReviewTool = bareTool;
5621
+ }
5622
+ }
5446
5623
  if (!taintWarning && !isIgnoredTool2(toolName)) {
5447
5624
  const ld = config.policy.loopDetection;
5448
- if (ld.enabled) {
5625
+ if (ld.enabled && !appPermReview) {
5449
5626
  const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
5450
5627
  if (loopResult.looping) {
5451
5628
  const reason = `It looks like you've called "${toolName}" ${loopResult.count} times with identical arguments in the last ${ld.windowSeconds}s. Are you stuck? Step back and reconsider your approach \u2014 what are you actually trying to accomplish, and is there a different way to get there?`;
@@ -5476,7 +5653,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5476
5653
  reason: "Workspace is in panic mode \u2014 all review-verdict actions are blocked. Contact your admin to disable panic mode in the Node9 dashboard."
5477
5654
  };
5478
5655
  }
5479
- if (policyResult.decision === "allow") {
5656
+ if (policyResult.decision === "allow" && !appPermReview) {
5480
5657
  if (!isManual)
5481
5658
  appendLocalAudit(
5482
5659
  toolName,
@@ -5569,7 +5746,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5569
5746
  );
5570
5747
  if (policyRuleDescription) riskMetadata.ruleDescription = policyRuleDescription.slice(0, 200);
5571
5748
  const persistent = policyResult.ruleName ? null : getPersistentDecision(toolName);
5572
- if (persistent === "allow") {
5749
+ if (persistent === "allow" && !appPermReview) {
5573
5750
  if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta, hashAuditArgs);
5574
5751
  return { approved: true, checkedBy: "persistent" };
5575
5752
  }
@@ -5583,7 +5760,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5583
5760
  blockedByLabel: "Persistent User Rule"
5584
5761
  };
5585
5762
  }
5586
- } else if (!taintWarning) {
5763
+ } else if (!taintWarning && !appPermReview) {
5587
5764
  const toolLower = toolName.toLowerCase();
5588
5765
  const isFileTool = toolLower === "read" || toolLower === "grep" || toolLower === "glob" || toolLower === "read_file" || toolLower === "grep_search" || toolLower === "list_files";
5589
5766
  if (isFileTool && readActiveShields().includes("project-jail")) {
@@ -5601,7 +5778,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5601
5778
  return { approved: true };
5602
5779
  }
5603
5780
  }
5604
- if (!taintWarning && getActiveTrustSession(toolName, args)) {
5781
+ if (!taintWarning && !appPermReview && getActiveTrustSession(toolName, args)) {
5605
5782
  if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
5606
5783
  return { approved: true, checkedBy: "trust" };
5607
5784
  }
@@ -5613,11 +5790,35 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5613
5790
  explainableLabel,
5614
5791
  void 0,
5615
5792
  void 0,
5616
- taintWarning
5793
+ appPermReview ? `${taintWarning}
5794
+ ${appPermReview}` : taintWarning
5617
5795
  );
5796
+ } else if (appPermReview) {
5797
+ if (dlpReviewFlagged) {
5798
+ explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review) + \u{1F512} App Permission (Review)";
5799
+ riskMetadata = computeRiskMetadata(
5800
+ args,
5801
+ 6,
5802
+ explainableLabel,
5803
+ void 0,
5804
+ void 0,
5805
+ `A credential was detected in this call (DLP review).
5806
+ ${appPermReview}`
5807
+ );
5808
+ } else {
5809
+ explainableLabel = "\u{1F512} Node9 App Permission (Review)";
5810
+ riskMetadata = computeRiskMetadata(
5811
+ args,
5812
+ 5,
5813
+ explainableLabel,
5814
+ void 0,
5815
+ void 0,
5816
+ appPermReview
5817
+ );
5818
+ }
5618
5819
  }
5619
5820
  const cloudEnforcedForDefer = approvers.cloud && !!creds?.apiKey;
5620
- if (options?.deferReview && !taintWarning && !cloudEnforcedForDefer) {
5821
+ if (options?.deferReview && !taintWarning && !appPermReview && !cloudEnforcedForDefer) {
5621
5822
  return {
5622
5823
  approved: false,
5623
5824
  review: true,
@@ -5628,7 +5829,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5628
5829
  }
5629
5830
  let cloudRequestId = null;
5630
5831
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
5631
- const forceReview = localSmartRuleMatched === true || options?.localSmartRuleMatched === true || void 0;
5832
+ const forceReview = localSmartRuleMatched === true || options?.localSmartRuleMatched === true || !!appPermReview || void 0;
5632
5833
  if (cloudEnforced) {
5633
5834
  try {
5634
5835
  const initResult = await initNode9SaaS(
@@ -5641,10 +5842,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5641
5842
  forceReview
5642
5843
  );
5643
5844
  if (!initResult.pending) {
5644
- if (initResult.shadowMode) {
5845
+ if (initResult.shadowMode && !appPermReview) {
5645
5846
  return { approved: true, checkedBy: "cloud" };
5646
5847
  }
5647
- if (!localSmartRuleMatched && !options?.localSmartRuleMatched) {
5848
+ if (!localSmartRuleMatched && !options?.localSmartRuleMatched && !appPermReview) {
5648
5849
  return {
5649
5850
  approved: !!initResult.approved,
5650
5851
  reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
@@ -5655,7 +5856,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5655
5856
  }
5656
5857
  }
5657
5858
  if (initResult.pending) cloudRequestId = initResult.requestId || null;
5658
- if (!taintWarning) explainableLabel = "Organization Policy (SaaS)";
5859
+ if (!taintWarning && !appPermReview) explainableLabel = "Organization Policy (SaaS)";
5659
5860
  } catch {
5660
5861
  }
5661
5862
  }
@@ -5708,7 +5909,13 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5708
5909
  options?.activityId,
5709
5910
  options?.cwd,
5710
5911
  statefulRecoveryCommand,
5711
- void 0,
5912
+ // fix #1: for an app-perm review, tell the daemon NOT to run its own
5913
+ // background authorizeHeadless — it re-auths WITHOUT serverKey (meta
5914
+ // carries no serverKey), skips the app-perm gate, and silently
5915
+ // auto-allows. This process (the long-running gateway — appPermReview
5916
+ // only ever happens here) stays alive to run its own racers, so the
5917
+ // daemon just holds the card and waits for the human decision.
5918
+ appPermReview ? true : void 0,
5712
5919
  void 0,
5713
5920
  localSmartRuleMatched || options?.localSmartRuleMatched,
5714
5921
  options?.socketActivitySent
@@ -5755,8 +5962,11 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5755
5962
  riskMetadata?.ruleDescription
5756
5963
  );
5757
5964
  if (decision === "always_allow") {
5758
- writeTrustSession(toolName, 36e5, args);
5759
- return { approved: true, checkedBy: "trust" };
5965
+ if (!appPermReview) {
5966
+ writeTrustSession(toolName, 36e5, args);
5967
+ return { approved: true, checkedBy: "trust" };
5968
+ }
5969
+ return { approved: true, checkedBy: "daemon", decisionSource: "native" };
5760
5970
  }
5761
5971
  const isApproved = decision === "allow";
5762
5972
  return {
@@ -5794,6 +6004,15 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5794
6004
  );
5795
6005
  }
5796
6006
  if (racePromises.length === 0) {
6007
+ if (!isManual && appPermReview)
6008
+ appendLocalAudit(
6009
+ toolName,
6010
+ args,
6011
+ "deny",
6012
+ "app-permission-review",
6013
+ { ...meta, ruleName: `app-permission:${appPermReviewTool}` },
6014
+ hashAuditArgs
6015
+ );
5797
6016
  return {
5798
6017
  approved: false,
5799
6018
  noApprovalMechanism: true,
@@ -5861,7 +6080,13 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
5861
6080
  // the BE enriches that row instead of inserting a duplicate. Matters
5862
6081
  // for EVERY racer outcome, not just cloud wins: a native-popup
5863
6082
  // decision on a cloud-pending request would otherwise count twice.
5864
- cloudRequestId ? { ...meta, cloudRequestId } : meta,
6083
+ // fix #6: carry app-perm attribution so a race-resolved approve/deny isn't
6084
+ // anonymous on the dashboard (matches the block row's ruleName).
6085
+ {
6086
+ ...meta,
6087
+ ...cloudRequestId && { cloudRequestId },
6088
+ ...appPermReview && { ruleName: `app-permission:${appPermReviewTool}` }
6089
+ },
5865
6090
  hashAuditArgs
5866
6091
  );
5867
6092
  }