@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/cli.js +3072 -1963
- package/dist/cli.mjs +3058 -1950
- package/dist/dashboard.mjs +70 -41
- package/dist/index.js +326 -101
- package/dist/index.mjs +326 -101
- package/package.json +1 -1
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
|
|
207
|
-
import
|
|
208
|
-
import
|
|
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 =
|
|
3814
|
-
if (
|
|
3815
|
-
const creds = JSON.parse(
|
|
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 =
|
|
3842
|
-
const projectPath =
|
|
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 =
|
|
4096
|
+
const cacheFile = path4.join(os4.homedir(), ".node9", "rules-cache.json");
|
|
3978
4097
|
try {
|
|
3979
|
-
const raw = JSON.parse(
|
|
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 (!
|
|
4258
|
+
if (!fs4.existsSync(filePath)) return null;
|
|
4072
4259
|
let raw;
|
|
4073
4260
|
try {
|
|
4074
|
-
raw = JSON.parse(
|
|
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
|
|
4129
|
-
import
|
|
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 =
|
|
4135
|
-
resolved =
|
|
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 =
|
|
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
|
|
4149
|
-
import
|
|
4150
|
-
import
|
|
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
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
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 (
|
|
4349
|
+
if (path6.posix.isAbsolute(cmd)) return cmd;
|
|
4163
4350
|
const pathEnv = process.env.PATH ?? "";
|
|
4164
|
-
for (const dir of pathEnv.split(
|
|
4351
|
+
for (const dir of pathEnv.split(path6.delimiter)) {
|
|
4165
4352
|
if (!dir) continue;
|
|
4166
|
-
const full =
|
|
4353
|
+
const full = path6.join(dir, cmd);
|
|
4167
4354
|
try {
|
|
4168
|
-
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
5729
|
-
|
|
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
|
-
|
|
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
|
}
|