@node9/proxy 1.55.0 → 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.mjs CHANGED
@@ -203,9 +203,9 @@ var init_audit = __esm({
203
203
  init_audit();
204
204
 
205
205
  // src/config/index.ts
206
- import fs3 from "fs";
207
- import path3 from "path";
208
- import os3 from "os";
206
+ import fs4 from "fs";
207
+ import path4 from "path";
208
+ import os4 from "os";
209
209
 
210
210
  // src/config-schema.ts
211
211
  import { z } from "zod";
@@ -291,6 +291,10 @@ var ConfigFileSchema = z.object({
291
291
  // approver_set). Default (unset/false): those tools refuse over MCP — a human
292
292
  // must run them from the CLI. node9's threat model is the agent itself.
293
293
  mcpAllowWeakening: z.boolean().optional(),
294
+ // Auto-wire reconciler (P3 2.6): auto-wrap new ungoverned MCP servers vs
295
+ // nudge-only (default), and the scan cadence in minutes.
296
+ mcpAutoWrap: z.boolean().optional(),
297
+ mcpReconcileIntervalMinutes: z.number().positive().optional(),
294
298
  cloudSyncIntervalHours: z.number().positive().optional(),
295
299
  // Seconds-granular override for the cloud policy sync cadence. Wins over
296
300
  // cloudSyncIntervalHours when set. Lets you opt into fast apply (e.g. 20)
@@ -3525,6 +3529,109 @@ function applyManagedDlp(local, managed, locked) {
3525
3529
  }
3526
3530
  return next;
3527
3531
  }
3532
+ function applyManagedApprovers(local, managed) {
3533
+ return {
3534
+ ...local,
3535
+ native: typeof managed.native === "boolean" ? managed.native : local.native,
3536
+ browser: typeof managed.browser === "boolean" ? managed.browser : local.browser,
3537
+ cloud: typeof managed.cloud === "boolean" ? managed.cloud : local.cloud,
3538
+ terminal: typeof managed.terminal === "boolean" ? managed.terminal : local.terminal
3539
+ };
3540
+ }
3541
+
3542
+ // src/shields/build.ts
3543
+ function escapeRegex(s) {
3544
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3545
+ }
3546
+ function slug(s) {
3547
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "rule";
3548
+ }
3549
+ var B = "[\\s/\\\\]";
3550
+ var SEP = "[/\\\\]";
3551
+ function pathToRegexFragment(rawPath) {
3552
+ const tail = rawPath.trim().replace(/^~[\\/]?/, "").replace(/^\$\{?HOME\}?[\\/]?/, "").replace(/^\/(?:home|Users)\/[^\\/]+[\\/]?/, "").replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
3553
+ const segments = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
3554
+ if (segments.length === 0) return "";
3555
+ return `(^|${B})${segments.join(SEP)}(${B}|$)`;
3556
+ }
3557
+ function pathRules(rawPath, verdict, reason) {
3558
+ const value = pathToRegexFragment(rawPath);
3559
+ if (!value) return [];
3560
+ const why = reason ?? `Accessing ${rawPath} is restricted by this shield`;
3561
+ const s = slug(rawPath);
3562
+ return [
3563
+ {
3564
+ name: `${verdict}-path-${s}-bash`,
3565
+ tool: "bash",
3566
+ conditions: [{ field: "command", op: "matches", value }],
3567
+ verdict,
3568
+ reason: why
3569
+ },
3570
+ {
3571
+ name: `${verdict}-path-${s}-anytool`,
3572
+ tool: "*",
3573
+ conditions: [{ field: "file_path", op: "matches", value }],
3574
+ verdict,
3575
+ reason: why
3576
+ }
3577
+ ];
3578
+ }
3579
+
3580
+ // src/auth/trusted-hosts.ts
3581
+ import fs3 from "fs";
3582
+ import path3 from "path";
3583
+ import os3 from "os";
3584
+ function getTrustedHostsPath() {
3585
+ return path3.join(os3.homedir(), ".node9", "trusted-hosts.json");
3586
+ }
3587
+ function readTrustedHosts() {
3588
+ try {
3589
+ const raw = fs3.readFileSync(getTrustedHostsPath(), "utf8");
3590
+ const parsed = JSON.parse(raw);
3591
+ return Array.isArray(parsed.hosts) ? parsed.hosts : [];
3592
+ } catch {
3593
+ return [];
3594
+ }
3595
+ }
3596
+ var _cache = null;
3597
+ var CACHE_TTL_MS = 5e3;
3598
+ function getFileMtime() {
3599
+ try {
3600
+ return fs3.statSync(getTrustedHostsPath()).mtimeMs;
3601
+ } catch {
3602
+ return 0;
3603
+ }
3604
+ }
3605
+ function getCachedHosts() {
3606
+ const now = Date.now();
3607
+ if (_cache && now < _cache.expiry) {
3608
+ const mtime = getFileMtime();
3609
+ if (mtime === _cache.mtime) return _cache.hosts;
3610
+ }
3611
+ const hosts = readTrustedHosts();
3612
+ _cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
3613
+ return hosts;
3614
+ }
3615
+ function normalizeHost(raw) {
3616
+ return raw.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^[^@]+@/, "").replace(/:\d+$/, "");
3617
+ }
3618
+ function matchesTrustedHost(host, list) {
3619
+ const normalized = normalizeHost(host);
3620
+ return list.some((raw) => {
3621
+ const entryHost = raw.toLowerCase();
3622
+ if (entryHost.startsWith("*.")) {
3623
+ const domain = entryHost.slice(2);
3624
+ return normalized.endsWith("." + domain);
3625
+ }
3626
+ return normalized === entryHost;
3627
+ });
3628
+ }
3629
+ function isTrustedHost(host) {
3630
+ return matchesTrustedHost(
3631
+ host,
3632
+ getCachedHosts().map((entry) => entry.host)
3633
+ );
3634
+ }
3528
3635
 
3529
3636
  // src/config/index.ts
3530
3637
  var DANGEROUS_WORDS = [
@@ -3735,7 +3842,10 @@ var DEFAULT_CONFIG = {
3735
3842
  egress: { enabled: false, mode: "review", allow: [], deny: [], allowPrivate: true },
3736
3843
  loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
3737
3844
  injectionScan: { enabled: false, minConfidence: "medium", allow: [] },
3738
- skillPinning: { enabled: false, mode: "warn", roots: [] }
3845
+ skillPinning: { enabled: false, mode: "warn", roots: [] },
3846
+ trustedHosts: [],
3847
+ trustedHostsManaged: false,
3848
+ appPermissions: {}
3739
3849
  },
3740
3850
  environments: {}
3741
3851
  };
@@ -3810,9 +3920,9 @@ function getCredentials() {
3810
3920
  };
3811
3921
  }
3812
3922
  try {
3813
- const credPath = path3.join(os3.homedir(), ".node9", "credentials.json");
3814
- if (fs3.existsSync(credPath)) {
3815
- const creds = JSON.parse(fs3.readFileSync(credPath, "utf-8"));
3923
+ const credPath = path4.join(os4.homedir(), ".node9", "credentials.json");
3924
+ if (fs4.existsSync(credPath)) {
3925
+ const creds = JSON.parse(fs4.readFileSync(credPath, "utf-8"));
3816
3926
  const profileName = process.env.NODE9_PROFILE || "default";
3817
3927
  const profile = creds[profileName];
3818
3928
  if (profile?.apiKey) {
@@ -3838,8 +3948,8 @@ function getActiveEnvironment(config) {
3838
3948
  }
3839
3949
  function getConfig(cwd) {
3840
3950
  if (!cwd && cachedConfig) return cachedConfig;
3841
- const globalPath = path3.join(os3.homedir(), ".node9", "config.json");
3842
- const projectPath = path3.join(cwd ?? process.cwd(), "node9.config.json");
3951
+ const globalPath = path4.join(os4.homedir(), ".node9", "config.json");
3952
+ const projectPath = path4.join(cwd ?? process.cwd(), "node9.config.json");
3843
3953
  const globalConfig = tryLoadConfig(globalPath);
3844
3954
  const projectConfig = tryLoadConfig(projectPath);
3845
3955
  const mergedSettings = {
@@ -3872,7 +3982,13 @@ function getConfig(cwd) {
3872
3982
  skillPinning: {
3873
3983
  ...DEFAULT_CONFIG.policy.skillPinning,
3874
3984
  roots: [...DEFAULT_CONFIG.policy.skillPinning.roots]
3875
- }
3985
+ },
3986
+ // Left empty on purpose: the local file is read fresh at policy-eval time
3987
+ // (getCachedHosts via isTrustedHost), NOT snapshotted into the frozen config
3988
+ // here. A managed list fills this below and flips trustedHostsManaged.
3989
+ trustedHosts: [],
3990
+ trustedHostsManaged: false,
3991
+ appPermissions: {}
3876
3992
  };
3877
3993
  const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
3878
3994
  const applyLayer = (source) => {
@@ -3894,6 +4010,9 @@ function getConfig(cwd) {
3894
4010
  if (s.mcpAllowWeakening !== void 0) mergedSettings.mcpAllowWeakening = s.mcpAllowWeakening;
3895
4011
  if (s.cloudSyncIntervalHours !== void 0)
3896
4012
  mergedSettings.cloudSyncIntervalHours = s.cloudSyncIntervalHours;
4013
+ if (s.mcpAutoWrap !== void 0) mergedSettings.mcpAutoWrap = s.mcpAutoWrap === true;
4014
+ if (s.mcpReconcileIntervalMinutes !== void 0)
4015
+ mergedSettings.mcpReconcileIntervalMinutes = s.mcpReconcileIntervalMinutes;
3897
4016
  if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
3898
4017
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
3899
4018
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
@@ -3974,9 +4093,9 @@ function getConfig(cwd) {
3974
4093
  applyLayer(projectConfig);
3975
4094
  let cloudManagedShields = [];
3976
4095
  {
3977
- const cacheFile = path3.join(os3.homedir(), ".node9", "rules-cache.json");
4096
+ const cacheFile = path4.join(os4.homedir(), ".node9", "rules-cache.json");
3978
4097
  try {
3979
- const raw = JSON.parse(fs3.readFileSync(cacheFile, "utf-8"));
4098
+ const raw = JSON.parse(fs4.readFileSync(cacheFile, "utf-8"));
3980
4099
  if (Array.isArray(raw.rules) && raw.rules.length > 0) {
3981
4100
  applyLayer({ policy: { smartRules: raw.rules } });
3982
4101
  }
@@ -4017,6 +4136,74 @@ function getConfig(cwd) {
4017
4136
  locked
4018
4137
  );
4019
4138
  }
4139
+ if (mc.approvers && typeof mc.approvers === "object") {
4140
+ const bool = (v) => typeof v === "boolean" ? v : void 0;
4141
+ mergedSettings.approvers = applyManagedApprovers(mergedSettings.approvers, {
4142
+ native: bool(mc.approvers.native),
4143
+ browser: bool(mc.approvers.browser),
4144
+ cloud: bool(mc.approvers.cloud),
4145
+ terminal: bool(mc.approvers.terminal)
4146
+ });
4147
+ }
4148
+ if (mc.reviewChannel === "ask" || mc.reviewChannel === "approver") {
4149
+ mergedSettings.reviewChannel = mc.reviewChannel;
4150
+ }
4151
+ if (typeof mc.approvalTimeoutMs === "number" && mc.approvalTimeoutMs > 0) {
4152
+ mergedSettings.approvalTimeoutMs = mc.approvalTimeoutMs;
4153
+ }
4154
+ if (mc.injectionScan && typeof mc.injectionScan === "object") {
4155
+ const i = mc.injectionScan;
4156
+ const cur = mergedPolicy.injectionScan;
4157
+ mergedPolicy.injectionScan = {
4158
+ enabled: typeof i.enabled === "boolean" ? i.enabled : cur.enabled,
4159
+ minConfidence: i.minConfidence === "high" || i.minConfidence === "medium" ? i.minConfidence : cur.minConfidence,
4160
+ allow: Array.isArray(i.allow) ? i.allow.filter((x) => typeof x === "string") : cur.allow
4161
+ };
4162
+ }
4163
+ if (mc.loopDetection && typeof mc.loopDetection === "object") {
4164
+ const l = mc.loopDetection;
4165
+ const cur = mergedPolicy.loopDetection;
4166
+ mergedPolicy.loopDetection = {
4167
+ enabled: typeof l.enabled === "boolean" ? l.enabled : cur.enabled,
4168
+ threshold: typeof l.threshold === "number" && Number.isFinite(l.threshold) ? l.threshold : cur.threshold,
4169
+ windowSeconds: typeof l.windowSeconds === "number" && Number.isFinite(l.windowSeconds) ? l.windowSeconds : cur.windowSeconds
4170
+ };
4171
+ }
4172
+ if (mc.skillPinning && typeof mc.skillPinning === "object") {
4173
+ const sk = mc.skillPinning;
4174
+ const cur = mergedPolicy.skillPinning;
4175
+ mergedPolicy.skillPinning = {
4176
+ enabled: typeof sk.enabled === "boolean" ? sk.enabled : cur.enabled,
4177
+ mode: sk.mode === "block" || sk.mode === "warn" ? sk.mode : cur.mode,
4178
+ roots: Array.isArray(sk.roots) ? sk.roots.filter((x) => typeof x === "string") : cur.roots
4179
+ };
4180
+ }
4181
+ if (Array.isArray(mc.jailPaths)) {
4182
+ for (const jp of mc.jailPaths) {
4183
+ const path13 = typeof jp?.path === "string" ? jp.path.trim() : "";
4184
+ if (!path13) continue;
4185
+ const verdict = jp?.verdict === "review" ? "review" : "block";
4186
+ for (const r of pathRules(path13, verdict, "org-managed jail")) {
4187
+ mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
4188
+ }
4189
+ }
4190
+ }
4191
+ if (Array.isArray(mc.trustedHosts)) {
4192
+ mergedPolicy.trustedHostsManaged = true;
4193
+ mergedPolicy.trustedHosts = mc.trustedHosts.filter((h) => typeof h === "string").map((h) => normalizeHost(h));
4194
+ }
4195
+ if (mc.appPermissions && typeof mc.appPermissions === "object" && !Array.isArray(mc.appPermissions)) {
4196
+ const coerced = {};
4197
+ for (const [srv, tools] of Object.entries(mc.appPermissions)) {
4198
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) continue;
4199
+ const m = {};
4200
+ for (const [tool, d] of Object.entries(tools)) {
4201
+ if (d === "allow" || d === "review" || d === "block") m[tool] = d;
4202
+ }
4203
+ if (Object.keys(m).length) coerced[srv] = m;
4204
+ }
4205
+ mergedPolicy.appPermissions = coerced;
4206
+ }
4020
4207
  }
4021
4208
  if (raw.panicMode === true) {
4022
4209
  mergedSettings.panicMode = true;
@@ -4068,10 +4255,10 @@ function getConfig(cwd) {
4068
4255
  return result;
4069
4256
  }
4070
4257
  function tryLoadConfig(filePath) {
4071
- if (!fs3.existsSync(filePath)) return null;
4258
+ if (!fs4.existsSync(filePath)) return null;
4072
4259
  let raw;
4073
4260
  try {
4074
- raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
4261
+ raw = JSON.parse(fs4.readFileSync(filePath, "utf-8"));
4075
4262
  } catch (err) {
4076
4263
  const msg = err instanceof Error ? err.message : String(err);
4077
4264
  process.stderr.write(
@@ -4125,18 +4312,18 @@ ${error.replace("Invalid config:\n", "")}
4125
4312
  import pm2 from "picomatch";
4126
4313
 
4127
4314
  // src/dlp.ts
4128
- import fs4 from "fs";
4129
- import path4 from "path";
4315
+ import fs5 from "fs";
4316
+ import path5 from "path";
4130
4317
  function scanFilePath(filePath, cwd = process.cwd()) {
4131
4318
  if (!filePath) return null;
4132
4319
  let resolved;
4133
4320
  try {
4134
- const absolute = path4.resolve(cwd, filePath);
4135
- resolved = fs4.realpathSync.native(absolute);
4321
+ const absolute = path5.resolve(cwd, filePath);
4322
+ resolved = fs5.realpathSync.native(absolute);
4136
4323
  } catch (err) {
4137
4324
  const code = err.code;
4138
4325
  if (code === "ENOENT" || code === "ENOTDIR") {
4139
- resolved = path4.resolve(cwd, filePath);
4326
+ resolved = path5.resolve(cwd, filePath);
4140
4327
  } else {
4141
4328
  return sensitivePathMatch(filePath);
4142
4329
  }
@@ -4145,27 +4332,27 @@ function scanFilePath(filePath, cwd = process.cwd()) {
4145
4332
  }
4146
4333
 
4147
4334
  // src/utils/provenance.ts
4148
- import fs5 from "fs";
4149
- import path5 from "path";
4150
- import os4 from "os";
4335
+ import fs6 from "fs";
4336
+ import path6 from "path";
4337
+ import os5 from "os";
4151
4338
  var SYSTEM_PREFIXES = ["/usr/bin", "/usr/sbin", "/bin", "/sbin"];
4152
4339
  var MANAGED_PREFIXES = ["/usr/local/bin", "/opt/homebrew", "/home/linuxbrew", "/nix/store"];
4153
4340
  var USER_PREFIXES = [
4154
- path5.join(os4.homedir(), "bin"),
4155
- path5.join(os4.homedir(), ".local", "bin"),
4156
- path5.join(os4.homedir(), ".cargo", "bin"),
4157
- path5.join(os4.homedir(), ".npm-global", "bin"),
4158
- path5.join(os4.homedir(), ".volta", "bin")
4341
+ path6.join(os5.homedir(), "bin"),
4342
+ path6.join(os5.homedir(), ".local", "bin"),
4343
+ path6.join(os5.homedir(), ".cargo", "bin"),
4344
+ path6.join(os5.homedir(), ".npm-global", "bin"),
4345
+ path6.join(os5.homedir(), ".volta", "bin")
4159
4346
  ];
4160
4347
  var SUSPECT_PREFIXES = ["/tmp", "/var/tmp", "/dev/shm"];
4161
4348
  function findInPath(cmd) {
4162
- if (path5.posix.isAbsolute(cmd)) return cmd;
4349
+ if (path6.posix.isAbsolute(cmd)) return cmd;
4163
4350
  const pathEnv = process.env.PATH ?? "";
4164
- for (const dir of pathEnv.split(path5.delimiter)) {
4351
+ for (const dir of pathEnv.split(path6.delimiter)) {
4165
4352
  if (!dir) continue;
4166
- const full = path5.join(dir, cmd);
4353
+ const full = path6.join(dir, cmd);
4167
4354
  try {
4168
- fs5.accessSync(full, fs5.constants.X_OK);
4355
+ fs6.accessSync(full, fs6.constants.X_OK);
4169
4356
  return full;
4170
4357
  } catch {
4171
4358
  }
@@ -4176,7 +4363,7 @@ function _classifyPath(resolved, cwd) {
4176
4363
  if (cwd && resolved.startsWith(cwd + "/")) {
4177
4364
  return { trustLevel: "user", reason: "binary in project directory" };
4178
4365
  }
4179
- const osTmp = os4.tmpdir();
4366
+ const osTmp = os5.tmpdir();
4180
4367
  const allSuspect = osTmp ? [...SUSPECT_PREFIXES, osTmp] : SUSPECT_PREFIXES;
4181
4368
  if (allSuspect.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
4182
4369
  return { trustLevel: "suspect", reason: `binary in temp directory: ${resolved}` };
@@ -4194,7 +4381,7 @@ function _classifyPath(resolved, cwd) {
4194
4381
  }
4195
4382
  function checkProvenance(cmd, cwd) {
4196
4383
  const bare = cmd.startsWith("./") ? cmd.slice(2) : cmd;
4197
- if (path5.posix.isAbsolute(bare)) {
4384
+ if (path6.posix.isAbsolute(bare)) {
4198
4385
  const early = _classifyPath(bare, cwd);
4199
4386
  if (early.trustLevel === "suspect") {
4200
4387
  return { resolvedPath: bare, ...early };
@@ -4210,7 +4397,7 @@ function checkProvenance(cmd, cwd) {
4210
4397
  reason: "binary not found in PATH"
4211
4398
  };
4212
4399
  }
4213
- resolved = fs5.realpathSync(found);
4400
+ resolved = fs6.realpathSync(found);
4214
4401
  } catch {
4215
4402
  return {
4216
4403
  resolvedPath: cmd,
@@ -4219,7 +4406,7 @@ function checkProvenance(cmd, cwd) {
4219
4406
  };
4220
4407
  }
4221
4408
  try {
4222
- const stat = fs5.statSync(resolved);
4409
+ const stat = fs6.statSync(resolved);
4223
4410
  if (stat.mode & 2) {
4224
4411
  return {
4225
4412
  resolvedPath: resolved,
@@ -4238,56 +4425,6 @@ function checkProvenance(cmd, cwd) {
4238
4425
  return { resolvedPath: resolved, ...classify };
4239
4426
  }
4240
4427
 
4241
- // src/auth/trusted-hosts.ts
4242
- import fs6 from "fs";
4243
- import path6 from "path";
4244
- import os5 from "os";
4245
- function getTrustedHostsPath() {
4246
- return path6.join(os5.homedir(), ".node9", "trusted-hosts.json");
4247
- }
4248
- function readTrustedHosts() {
4249
- try {
4250
- const raw = fs6.readFileSync(getTrustedHostsPath(), "utf8");
4251
- const parsed = JSON.parse(raw);
4252
- return Array.isArray(parsed.hosts) ? parsed.hosts : [];
4253
- } catch {
4254
- return [];
4255
- }
4256
- }
4257
- var _cache = null;
4258
- var CACHE_TTL_MS = 5e3;
4259
- function getFileMtime() {
4260
- try {
4261
- return fs6.statSync(getTrustedHostsPath()).mtimeMs;
4262
- } catch {
4263
- return 0;
4264
- }
4265
- }
4266
- function getCachedHosts() {
4267
- const now = Date.now();
4268
- if (_cache && now < _cache.expiry) {
4269
- const mtime = getFileMtime();
4270
- if (mtime === _cache.mtime) return _cache.hosts;
4271
- }
4272
- const hosts = readTrustedHosts();
4273
- _cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
4274
- return hosts;
4275
- }
4276
- function normalizeHost(raw) {
4277
- return raw.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^[^@]+@/, "").replace(/:\d+$/, "");
4278
- }
4279
- function isTrustedHost(host) {
4280
- const normalized = normalizeHost(host);
4281
- return getCachedHosts().some((entry) => {
4282
- const entryHost = entry.host.toLowerCase();
4283
- if (entryHost.startsWith("*.")) {
4284
- const domain = entryHost.slice(2);
4285
- return normalized.endsWith("." + domain);
4286
- }
4287
- return normalized === entryHost;
4288
- });
4289
- }
4290
-
4291
4428
  // src/policy/index.ts
4292
4429
  async function evaluatePolicy2(toolName, args, agent, cwd) {
4293
4430
  const config = getConfig();
@@ -4297,7 +4434,14 @@ async function evaluatePolicy2(toolName, args, agent, cwd) {
4297
4434
  toolName,
4298
4435
  args,
4299
4436
  { agent, cwd, activeEnvironment },
4300
- { checkProvenance, isTrustedHost }
4437
+ {
4438
+ checkProvenance,
4439
+ // Managed → match against the org list (frozen with the rest of managed
4440
+ // config; changes arrive via cloud sync). Unmanaged → the local file via
4441
+ // getCachedHosts (5s TTL + mtime), so a `node9 trust add/remove` still
4442
+ // reaches a long-lived in-process authorizer (the gateway) within seconds.
4443
+ isTrustedHost: config.policy.trustedHostsManaged ? (host) => matchesTrustedHost(host, config.policy.trustedHosts) : isTrustedHost
4444
+ }
4301
4445
  );
4302
4446
  }
4303
4447
  function isIgnoredTool2(toolName) {
@@ -5244,6 +5388,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5244
5388
  const isManual = meta?.agent === "Terminal";
5245
5389
  const isObserveMode = config.settings.mode === "observe";
5246
5390
  let explainableLabel = "Local Config";
5391
+ let dlpReviewFlagged = false;
5247
5392
  let policyMatchedField;
5248
5393
  let policyMatchedWord;
5249
5394
  let policyRuleDescription;
@@ -5348,6 +5493,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5348
5493
  if (!isManual)
5349
5494
  appendLocalAudit(toolName, args, "allow", "dlp-review-flagged", meta, hashAuditArgs);
5350
5495
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
5496
+ dlpReviewFlagged = true;
5351
5497
  }
5352
5498
  }
5353
5499
  if (config.policy.dlp.pii === "block" && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
@@ -5413,9 +5559,40 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5413
5559
  }
5414
5560
  return { approved: true, checkedBy: "audit" };
5415
5561
  }
5562
+ let appPermReview = null;
5563
+ let appPermReviewTool = null;
5564
+ if (meta?.serverKey) {
5565
+ const prefix = meta.mcpServer ? `mcp__${meta.mcpServer}__` : "";
5566
+ const bareTool = prefix && toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
5567
+ const decision = config.policy.appPermissions?.[meta.serverKey]?.[bareTool];
5568
+ const hardBlock = decision === "block" || decision === "review" && config.settings.panicMode === true;
5569
+ if (hardBlock) {
5570
+ if (!isManual)
5571
+ appendLocalAudit(
5572
+ toolName,
5573
+ args,
5574
+ "deny",
5575
+ "app-permission-block",
5576
+ // ruleName gives the dashboard row its "why" (rule attribution), the
5577
+ // same channel shield fires use; mcpServer (in meta) gives the app chip.
5578
+ { ...meta, ruleName: `app-permission:${bareTool}` },
5579
+ hashAuditArgs
5580
+ );
5581
+ return {
5582
+ approved: false,
5583
+ blockedBy: "local-config",
5584
+ 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.`,
5585
+ blockedByLabel: decision === "block" ? "\u{1F512} Node9 App Permission (Blocked)" : "\u{1F6A8} Panic mode (org policy)"
5586
+ };
5587
+ }
5588
+ if (decision === "review") {
5589
+ appPermReview = `App permission: "${bareTool}" requires human approval (workspace policy).`;
5590
+ appPermReviewTool = bareTool;
5591
+ }
5592
+ }
5416
5593
  if (!taintWarning && !isIgnoredTool2(toolName)) {
5417
5594
  const ld = config.policy.loopDetection;
5418
- if (ld.enabled) {
5595
+ if (ld.enabled && !appPermReview) {
5419
5596
  const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
5420
5597
  if (loopResult.looping) {
5421
5598
  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?`;
@@ -5446,7 +5623,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5446
5623
  reason: "Workspace is in panic mode \u2014 all review-verdict actions are blocked. Contact your admin to disable panic mode in the Node9 dashboard."
5447
5624
  };
5448
5625
  }
5449
- if (policyResult.decision === "allow") {
5626
+ if (policyResult.decision === "allow" && !appPermReview) {
5450
5627
  if (!isManual)
5451
5628
  appendLocalAudit(
5452
5629
  toolName,
@@ -5539,7 +5716,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5539
5716
  );
5540
5717
  if (policyRuleDescription) riskMetadata.ruleDescription = policyRuleDescription.slice(0, 200);
5541
5718
  const persistent = policyResult.ruleName ? null : getPersistentDecision(toolName);
5542
- if (persistent === "allow") {
5719
+ if (persistent === "allow" && !appPermReview) {
5543
5720
  if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta, hashAuditArgs);
5544
5721
  return { approved: true, checkedBy: "persistent" };
5545
5722
  }
@@ -5553,7 +5730,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5553
5730
  blockedByLabel: "Persistent User Rule"
5554
5731
  };
5555
5732
  }
5556
- } else if (!taintWarning) {
5733
+ } else if (!taintWarning && !appPermReview) {
5557
5734
  const toolLower = toolName.toLowerCase();
5558
5735
  const isFileTool = toolLower === "read" || toolLower === "grep" || toolLower === "glob" || toolLower === "read_file" || toolLower === "grep_search" || toolLower === "list_files";
5559
5736
  if (isFileTool && readActiveShields().includes("project-jail")) {
@@ -5571,7 +5748,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5571
5748
  return { approved: true };
5572
5749
  }
5573
5750
  }
5574
- if (!taintWarning && getActiveTrustSession(toolName, args)) {
5751
+ if (!taintWarning && !appPermReview && getActiveTrustSession(toolName, args)) {
5575
5752
  if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
5576
5753
  return { approved: true, checkedBy: "trust" };
5577
5754
  }
@@ -5583,11 +5760,35 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5583
5760
  explainableLabel,
5584
5761
  void 0,
5585
5762
  void 0,
5586
- taintWarning
5763
+ appPermReview ? `${taintWarning}
5764
+ ${appPermReview}` : taintWarning
5587
5765
  );
5766
+ } else if (appPermReview) {
5767
+ if (dlpReviewFlagged) {
5768
+ explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review) + \u{1F512} App Permission (Review)";
5769
+ riskMetadata = computeRiskMetadata(
5770
+ args,
5771
+ 6,
5772
+ explainableLabel,
5773
+ void 0,
5774
+ void 0,
5775
+ `A credential was detected in this call (DLP review).
5776
+ ${appPermReview}`
5777
+ );
5778
+ } else {
5779
+ explainableLabel = "\u{1F512} Node9 App Permission (Review)";
5780
+ riskMetadata = computeRiskMetadata(
5781
+ args,
5782
+ 5,
5783
+ explainableLabel,
5784
+ void 0,
5785
+ void 0,
5786
+ appPermReview
5787
+ );
5788
+ }
5588
5789
  }
5589
5790
  const cloudEnforcedForDefer = approvers.cloud && !!creds?.apiKey;
5590
- if (options?.deferReview && !taintWarning && !cloudEnforcedForDefer) {
5791
+ if (options?.deferReview && !taintWarning && !appPermReview && !cloudEnforcedForDefer) {
5591
5792
  return {
5592
5793
  approved: false,
5593
5794
  review: true,
@@ -5598,7 +5799,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5598
5799
  }
5599
5800
  let cloudRequestId = null;
5600
5801
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
5601
- const forceReview = localSmartRuleMatched === true || options?.localSmartRuleMatched === true || void 0;
5802
+ const forceReview = localSmartRuleMatched === true || options?.localSmartRuleMatched === true || !!appPermReview || void 0;
5602
5803
  if (cloudEnforced) {
5603
5804
  try {
5604
5805
  const initResult = await initNode9SaaS(
@@ -5611,10 +5812,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5611
5812
  forceReview
5612
5813
  );
5613
5814
  if (!initResult.pending) {
5614
- if (initResult.shadowMode) {
5815
+ if (initResult.shadowMode && !appPermReview) {
5615
5816
  return { approved: true, checkedBy: "cloud" };
5616
5817
  }
5617
- if (!localSmartRuleMatched && !options?.localSmartRuleMatched) {
5818
+ if (!localSmartRuleMatched && !options?.localSmartRuleMatched && !appPermReview) {
5618
5819
  return {
5619
5820
  approved: !!initResult.approved,
5620
5821
  reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
@@ -5625,7 +5826,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5625
5826
  }
5626
5827
  }
5627
5828
  if (initResult.pending) cloudRequestId = initResult.requestId || null;
5628
- if (!taintWarning) explainableLabel = "Organization Policy (SaaS)";
5829
+ if (!taintWarning && !appPermReview) explainableLabel = "Organization Policy (SaaS)";
5629
5830
  } catch {
5630
5831
  }
5631
5832
  }
@@ -5678,7 +5879,13 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5678
5879
  options?.activityId,
5679
5880
  options?.cwd,
5680
5881
  statefulRecoveryCommand,
5681
- void 0,
5882
+ // fix #1: for an app-perm review, tell the daemon NOT to run its own
5883
+ // background authorizeHeadless — it re-auths WITHOUT serverKey (meta
5884
+ // carries no serverKey), skips the app-perm gate, and silently
5885
+ // auto-allows. This process (the long-running gateway — appPermReview
5886
+ // only ever happens here) stays alive to run its own racers, so the
5887
+ // daemon just holds the card and waits for the human decision.
5888
+ appPermReview ? true : void 0,
5682
5889
  void 0,
5683
5890
  localSmartRuleMatched || options?.localSmartRuleMatched,
5684
5891
  options?.socketActivitySent
@@ -5725,8 +5932,11 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5725
5932
  riskMetadata?.ruleDescription
5726
5933
  );
5727
5934
  if (decision === "always_allow") {
5728
- writeTrustSession(toolName, 36e5, args);
5729
- return { approved: true, checkedBy: "trust" };
5935
+ if (!appPermReview) {
5936
+ writeTrustSession(toolName, 36e5, args);
5937
+ return { approved: true, checkedBy: "trust" };
5938
+ }
5939
+ return { approved: true, checkedBy: "daemon", decisionSource: "native" };
5730
5940
  }
5731
5941
  const isApproved = decision === "allow";
5732
5942
  return {
@@ -5764,6 +5974,15 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
5764
5974
  );
5765
5975
  }
5766
5976
  if (racePromises.length === 0) {
5977
+ if (!isManual && appPermReview)
5978
+ appendLocalAudit(
5979
+ toolName,
5980
+ args,
5981
+ "deny",
5982
+ "app-permission-review",
5983
+ { ...meta, ruleName: `app-permission:${appPermReviewTool}` },
5984
+ hashAuditArgs
5985
+ );
5767
5986
  return {
5768
5987
  approved: false,
5769
5988
  noApprovalMechanism: true,
@@ -5831,7 +6050,13 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
5831
6050
  // the BE enriches that row instead of inserting a duplicate. Matters
5832
6051
  // for EVERY racer outcome, not just cloud wins: a native-popup
5833
6052
  // decision on a cloud-pending request would otherwise count twice.
5834
- cloudRequestId ? { ...meta, cloudRequestId } : meta,
6053
+ // fix #6: carry app-perm attribution so a race-resolved approve/deny isn't
6054
+ // anonymous on the dashboard (matches the block row's ruleName).
6055
+ {
6056
+ ...meta,
6057
+ ...cloudRequestId && { cloudRequestId },
6058
+ ...appPermReview && { ruleName: `app-permission:${appPermReviewTool}` }
6059
+ },
5835
6060
  hashAuditArgs
5836
6061
  );
5837
6062
  }