@node9/proxy 1.10.3 → 1.11.1
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/README.md +57 -0
- package/dist/cli.js +1447 -474
- package/dist/cli.mjs +1443 -470
- package/dist/index.js +106 -38
- package/dist/index.mjs +106 -38
- package/dist/shields/builtin/bash-safe.json +2 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -261,6 +261,11 @@ var ConfigFileSchema = import_zod.z.object({
|
|
|
261
261
|
enabled: import_zod.z.boolean().optional(),
|
|
262
262
|
threshold: import_zod.z.number().min(2).optional(),
|
|
263
263
|
windowSeconds: import_zod.z.number().min(10).optional()
|
|
264
|
+
}).optional(),
|
|
265
|
+
skillPinning: import_zod.z.object({
|
|
266
|
+
enabled: import_zod.z.boolean().optional(),
|
|
267
|
+
mode: import_zod.z.enum(["warn", "block"]).optional(),
|
|
268
|
+
roots: import_zod.z.array(import_zod.z.string()).optional()
|
|
264
269
|
}).optional()
|
|
265
270
|
}).optional(),
|
|
266
271
|
environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
|
|
@@ -498,9 +503,8 @@ var DEFAULT_CONFIG = {
|
|
|
498
503
|
{
|
|
499
504
|
field: "command",
|
|
500
505
|
op: "matches",
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
value: "rm\\b.*\\s(-[rRfF]*[rR][rRfF]*|--recursive)(\\s|$)"
|
|
506
|
+
// Anchor rm as a shell command (not inside a string arg like a git commit message).
|
|
507
|
+
value: "(^|&&|\\|\\||;)\\s*rm\\b[^;&|]*\\s(-[rRfF]*[rR][rRfF]*|--recursive)(\\s|$)"
|
|
504
508
|
},
|
|
505
509
|
{
|
|
506
510
|
field: "command",
|
|
@@ -529,6 +533,13 @@ var DEFAULT_CONFIG = {
|
|
|
529
533
|
name: "review-drop-truncate-shell",
|
|
530
534
|
tool: "bash",
|
|
531
535
|
conditions: [
|
|
536
|
+
{
|
|
537
|
+
field: "command",
|
|
538
|
+
op: "matches",
|
|
539
|
+
// Require a DB CLI in the command so grep/cat/echo of SQL strings don't trigger.
|
|
540
|
+
value: "(^|&&|\\|\\||;|\\|)\\s*(psql|mysql|sqlite3|sqlplus|cockroach|clickhouse-client|mongo)\\b",
|
|
541
|
+
flags: "i"
|
|
542
|
+
},
|
|
532
543
|
{
|
|
533
544
|
field: "command",
|
|
534
545
|
op: "matches",
|
|
@@ -549,7 +560,9 @@ var DEFAULT_CONFIG = {
|
|
|
549
560
|
{
|
|
550
561
|
field: "command",
|
|
551
562
|
op: "matches",
|
|
552
|
-
|
|
563
|
+
// Anchor git as a shell command so node -e / python -c scripts containing
|
|
564
|
+
// "git push --force" as a string don't false-positive.
|
|
565
|
+
value: "(^|&&|\\|\\||;)\\s*git\\s+push[^;&|]*(--force|--force-with-lease|-f\\b)",
|
|
553
566
|
flags: "i"
|
|
554
567
|
}
|
|
555
568
|
],
|
|
@@ -559,29 +572,20 @@ var DEFAULT_CONFIG = {
|
|
|
559
572
|
description: "The AI wants to force push to a remote git branch. This rewrites shared history and can permanently destroy commits that teammates have already pulled."
|
|
560
573
|
},
|
|
561
574
|
{
|
|
562
|
-
name: "review-git-
|
|
575
|
+
name: "review-git-destructive",
|
|
563
576
|
tool: "bash",
|
|
564
577
|
conditions: [
|
|
565
578
|
{
|
|
566
579
|
field: "command",
|
|
567
580
|
op: "matches",
|
|
568
|
-
value: "\\bgit\\
|
|
581
|
+
value: "\\bgit\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
569
582
|
flags: "i"
|
|
570
|
-
}
|
|
571
|
-
],
|
|
572
|
-
conditionMode: "all",
|
|
573
|
-
verdict: "review",
|
|
574
|
-
reason: "git push sends changes to a shared remote",
|
|
575
|
-
description: "The AI wants to push commits to a remote repository. Once pushed, those changes are visible to everyone with access."
|
|
576
|
-
},
|
|
577
|
-
{
|
|
578
|
-
name: "review-git-destructive",
|
|
579
|
-
tool: "bash",
|
|
580
|
-
conditions: [
|
|
583
|
+
},
|
|
581
584
|
{
|
|
582
585
|
field: "command",
|
|
583
|
-
op: "
|
|
584
|
-
|
|
586
|
+
op: "notMatches",
|
|
587
|
+
// Exclude recovery ops — these resolve a conflict, not start a destructive action.
|
|
588
|
+
value: "\\bgit\\s+rebase\\s+--(abort|continue|skip)\\b",
|
|
585
589
|
flags: "i"
|
|
586
590
|
}
|
|
587
591
|
],
|
|
@@ -607,7 +611,9 @@ var DEFAULT_CONFIG = {
|
|
|
607
611
|
{
|
|
608
612
|
field: "command",
|
|
609
613
|
op: "matches",
|
|
610
|
-
|
|
614
|
+
// Anchor curl/wget as a shell command so node -e scripts testing this
|
|
615
|
+
// regex pattern don't self-match as a false positive.
|
|
616
|
+
value: "(^|&&|\\|\\||;)\\s*(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
611
617
|
flags: "i"
|
|
612
618
|
}
|
|
613
619
|
],
|
|
@@ -618,7 +624,8 @@ var DEFAULT_CONFIG = {
|
|
|
618
624
|
}
|
|
619
625
|
],
|
|
620
626
|
dlp: { enabled: true, scanIgnoredTools: true },
|
|
621
|
-
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 }
|
|
627
|
+
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
|
|
628
|
+
skillPinning: { enabled: false, mode: "warn", roots: [] }
|
|
622
629
|
},
|
|
623
630
|
environments: {}
|
|
624
631
|
};
|
|
@@ -741,7 +748,11 @@ function getConfig(cwd) {
|
|
|
741
748
|
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
742
749
|
},
|
|
743
750
|
dlp: { ...DEFAULT_CONFIG.policy.dlp },
|
|
744
|
-
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection }
|
|
751
|
+
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
|
|
752
|
+
skillPinning: {
|
|
753
|
+
...DEFAULT_CONFIG.policy.skillPinning,
|
|
754
|
+
roots: [...DEFAULT_CONFIG.policy.skillPinning.roots]
|
|
755
|
+
}
|
|
745
756
|
};
|
|
746
757
|
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
747
758
|
const applyLayer = (source) => {
|
|
@@ -794,6 +805,16 @@ function getConfig(cwd) {
|
|
|
794
805
|
if (ld.windowSeconds !== void 0)
|
|
795
806
|
mergedPolicy.loopDetection.windowSeconds = ld.windowSeconds;
|
|
796
807
|
}
|
|
808
|
+
if (p.skillPinning && typeof p.skillPinning === "object") {
|
|
809
|
+
const sp = p.skillPinning;
|
|
810
|
+
if (sp.enabled !== void 0) mergedPolicy.skillPinning.enabled = sp.enabled;
|
|
811
|
+
if (sp.mode !== void 0) mergedPolicy.skillPinning.mode = sp.mode;
|
|
812
|
+
if (Array.isArray(sp.roots)) {
|
|
813
|
+
for (const r of sp.roots) {
|
|
814
|
+
if (typeof r === "string" && r.length > 0) mergedPolicy.skillPinning.roots.push(r);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
797
818
|
const envs = source.environments || {};
|
|
798
819
|
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
799
820
|
if (envConfig && typeof envConfig === "object") {
|
|
@@ -845,6 +866,7 @@ function getConfig(cwd) {
|
|
|
845
866
|
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
846
867
|
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
847
868
|
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
869
|
+
mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
|
|
848
870
|
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
849
871
|
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
850
872
|
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
@@ -992,7 +1014,7 @@ var DLP_PATTERNS = [
|
|
|
992
1014
|
regex: /_authToken\s*=\s*[A-Za-z0-9_\-]{20,}/,
|
|
993
1015
|
severity: "block"
|
|
994
1016
|
},
|
|
995
|
-
{ name: "Bearer Token", regex: /Bearer\s+[a-zA-Z0-9\-._~+/]
|
|
1017
|
+
{ name: "Bearer Token", regex: /Bearer\s+[a-zA-Z0-9\-._~+/]{20,}=*/i, severity: "review" }
|
|
996
1018
|
];
|
|
997
1019
|
var SENSITIVE_PATH_PATTERNS = [
|
|
998
1020
|
/[/\\]\.ssh[/\\]/i,
|
|
@@ -1582,12 +1604,25 @@ function getNestedValue(obj, path15) {
|
|
|
1582
1604
|
if (!obj || typeof obj !== "object") return null;
|
|
1583
1605
|
return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
1584
1606
|
}
|
|
1607
|
+
function stripStringArguments(cmd) {
|
|
1608
|
+
let result = cmd;
|
|
1609
|
+
result = result.replace(
|
|
1610
|
+
/\b(node|python3?|ruby|perl|php|deno)\s+(-[ecr]|eval)\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/gi,
|
|
1611
|
+
'$1 $2 ""'
|
|
1612
|
+
);
|
|
1613
|
+
result = result.replace(
|
|
1614
|
+
/\s(-m|--message|--body|--title|--description)\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
|
|
1615
|
+
' $1 ""'
|
|
1616
|
+
);
|
|
1617
|
+
return result;
|
|
1618
|
+
}
|
|
1585
1619
|
function evaluateSmartConditions(args, rule) {
|
|
1586
1620
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
1587
1621
|
const mode = rule.conditionMode ?? "all";
|
|
1588
1622
|
const results = rule.conditions.map((cond) => {
|
|
1589
1623
|
const rawVal = getNestedValue(args, cond.field);
|
|
1590
|
-
const
|
|
1624
|
+
const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
|
|
1625
|
+
const val = cond.field === "command" && normalized !== null ? stripStringArguments(normalized) : normalized;
|
|
1591
1626
|
switch (cond.op) {
|
|
1592
1627
|
case "exists":
|
|
1593
1628
|
return val !== null && val !== "";
|
|
@@ -1890,6 +1925,15 @@ var import_path9 = __toESM(require("path"));
|
|
|
1890
1925
|
var import_os6 = __toESM(require("os"));
|
|
1891
1926
|
var PAUSED_FILE = import_path9.default.join(import_os6.default.homedir(), ".node9", "PAUSED");
|
|
1892
1927
|
var TRUST_FILE = import_path9.default.join(import_os6.default.homedir(), ".node9", "trust.json");
|
|
1928
|
+
function extractCommandPattern(toolName, args) {
|
|
1929
|
+
const lower = toolName.toLowerCase();
|
|
1930
|
+
if (lower !== "bash" && lower !== "execute_bash" && lower !== "shell") return void 0;
|
|
1931
|
+
const a = args;
|
|
1932
|
+
const cmd = typeof a?.["command"] === "string" ? a["command"].trim() : "";
|
|
1933
|
+
if (!cmd) return void 0;
|
|
1934
|
+
const words = cmd.split(/\s+/);
|
|
1935
|
+
return words.slice(0, 2).join(" ");
|
|
1936
|
+
}
|
|
1893
1937
|
function checkPause() {
|
|
1894
1938
|
try {
|
|
1895
1939
|
if (!import_fs7.default.existsSync(PAUSED_FILE)) return { paused: false };
|
|
@@ -1913,7 +1957,7 @@ function atomicWriteSync(filePath, data, options) {
|
|
|
1913
1957
|
import_fs7.default.writeFileSync(tmpPath, data, options);
|
|
1914
1958
|
import_fs7.default.renameSync(tmpPath, filePath);
|
|
1915
1959
|
}
|
|
1916
|
-
function getActiveTrustSession(toolName) {
|
|
1960
|
+
function getActiveTrustSession(toolName, args) {
|
|
1917
1961
|
try {
|
|
1918
1962
|
if (!import_fs7.default.existsSync(TRUST_FILE)) return false;
|
|
1919
1963
|
const trust = JSON.parse(import_fs7.default.readFileSync(TRUST_FILE, "utf-8"));
|
|
@@ -1922,12 +1966,20 @@ function getActiveTrustSession(toolName) {
|
|
|
1922
1966
|
if (active.length !== trust.entries.length) {
|
|
1923
1967
|
import_fs7.default.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
|
|
1924
1968
|
}
|
|
1925
|
-
return active.some((e) =>
|
|
1969
|
+
return active.some((e) => {
|
|
1970
|
+
if (!(e.tool === toolName || matchesPattern(toolName, e.tool))) return false;
|
|
1971
|
+
if (e.commandPattern) {
|
|
1972
|
+
const actual = extractCommandPattern(toolName, args) ?? "";
|
|
1973
|
+
return actual === e.commandPattern || actual.startsWith(e.commandPattern + " ");
|
|
1974
|
+
}
|
|
1975
|
+
return true;
|
|
1976
|
+
});
|
|
1926
1977
|
} catch {
|
|
1927
1978
|
return false;
|
|
1928
1979
|
}
|
|
1929
1980
|
}
|
|
1930
|
-
function writeTrustSession(toolName, durationMs) {
|
|
1981
|
+
function writeTrustSession(toolName, durationMs, args) {
|
|
1982
|
+
const commandPattern = extractCommandPattern(toolName, args);
|
|
1931
1983
|
try {
|
|
1932
1984
|
let trust = { entries: [] };
|
|
1933
1985
|
try {
|
|
@@ -1937,8 +1989,14 @@ function writeTrustSession(toolName, durationMs) {
|
|
|
1937
1989
|
} catch {
|
|
1938
1990
|
}
|
|
1939
1991
|
const now = Date.now();
|
|
1940
|
-
trust.entries = trust.entries.filter(
|
|
1941
|
-
|
|
1992
|
+
trust.entries = trust.entries.filter(
|
|
1993
|
+
(e) => !(e.tool === toolName && e.commandPattern === commandPattern) && e.expiry > now
|
|
1994
|
+
);
|
|
1995
|
+
trust.entries.push({
|
|
1996
|
+
tool: toolName,
|
|
1997
|
+
...commandPattern && { commandPattern },
|
|
1998
|
+
expiry: now + durationMs
|
|
1999
|
+
});
|
|
1942
2000
|
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
1943
2001
|
} catch (err) {
|
|
1944
2002
|
if (process.env.NODE9_DEBUG === "1") {
|
|
@@ -2369,7 +2427,8 @@ function escapePango(text) {
|
|
|
2369
2427
|
function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription) {
|
|
2370
2428
|
const lines = [];
|
|
2371
2429
|
if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
|
|
2372
|
-
|
|
2430
|
+
const safeAgent = (agent ?? "AI Agent").replace(/\x1b(?:\[[0-9;?]*[a-zA-Z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])/g, "").slice(0, 80);
|
|
2431
|
+
lines.push(`\u{1F916} ${safeAgent} | \u{1F527} ${toolName}`);
|
|
2373
2432
|
lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
|
|
2374
2433
|
if (ruleDescription) lines.push(`\u2139 ${ruleDescription}`);
|
|
2375
2434
|
lines.push("");
|
|
@@ -2746,7 +2805,16 @@ async function authorizeHeadless(toolName, args, meta, options) {
|
|
|
2746
2805
|
if (!options?.calledFromDaemon) {
|
|
2747
2806
|
const actId = (0, import_crypto3.randomUUID)();
|
|
2748
2807
|
const actTs = Date.now();
|
|
2749
|
-
await notifyActivity({
|
|
2808
|
+
await notifyActivity({
|
|
2809
|
+
id: actId,
|
|
2810
|
+
ts: actTs,
|
|
2811
|
+
tool: toolName,
|
|
2812
|
+
args,
|
|
2813
|
+
status: "pending",
|
|
2814
|
+
// Strip ANSI escape sequences — agent name comes from caller-supplied metadata
|
|
2815
|
+
// and may be displayed in a terminal (node9 tail/watch), enabling injection.
|
|
2816
|
+
agent: meta?.agent ? meta.agent.replace(/\x1b(?:\[[0-9;?]*[a-zA-Z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])/g, "").slice(0, 80) : void 0
|
|
2817
|
+
});
|
|
2750
2818
|
const result = await _authorizeHeadlessCore(toolName, args, meta, {
|
|
2751
2819
|
...options,
|
|
2752
2820
|
activityId: actId
|
|
@@ -2900,12 +2968,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2900
2968
|
};
|
|
2901
2969
|
}
|
|
2902
2970
|
}
|
|
2903
|
-
if (getActiveTrustSession(toolName)) {
|
|
2904
|
-
if (approvers.cloud && creds?.apiKey)
|
|
2905
|
-
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
2906
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
|
|
2907
|
-
return { approved: true, checkedBy: "trust" };
|
|
2908
|
-
}
|
|
2909
2971
|
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
2910
2972
|
if (policyResult.decision === "allow") {
|
|
2911
2973
|
if (approvers.cloud && creds?.apiKey)
|
|
@@ -2987,6 +3049,12 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2987
3049
|
if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta, hashAuditArgs);
|
|
2988
3050
|
return { approved: true };
|
|
2989
3051
|
}
|
|
3052
|
+
if (!taintWarning && getActiveTrustSession(toolName, args)) {
|
|
3053
|
+
if (approvers.cloud && creds?.apiKey)
|
|
3054
|
+
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
3055
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
|
|
3056
|
+
return { approved: true, checkedBy: "trust" };
|
|
3057
|
+
}
|
|
2990
3058
|
if (taintWarning) {
|
|
2991
3059
|
explainableLabel = "\u{1F534} Node9 Taint (Exfiltration Prevention)";
|
|
2992
3060
|
riskMetadata = computeRiskMetadata(
|
|
@@ -3119,7 +3187,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3119
3187
|
riskMetadata?.ruleDescription
|
|
3120
3188
|
);
|
|
3121
3189
|
if (decision === "always_allow") {
|
|
3122
|
-
writeTrustSession(toolName, 36e5);
|
|
3190
|
+
writeTrustSession(toolName, 36e5, args);
|
|
3123
3191
|
return { approved: true, checkedBy: "trust" };
|
|
3124
3192
|
}
|
|
3125
3193
|
const isApproved = decision === "allow";
|
package/dist/index.mjs
CHANGED
|
@@ -231,6 +231,11 @@ var ConfigFileSchema = z.object({
|
|
|
231
231
|
enabled: z.boolean().optional(),
|
|
232
232
|
threshold: z.number().min(2).optional(),
|
|
233
233
|
windowSeconds: z.number().min(10).optional()
|
|
234
|
+
}).optional(),
|
|
235
|
+
skillPinning: z.object({
|
|
236
|
+
enabled: z.boolean().optional(),
|
|
237
|
+
mode: z.enum(["warn", "block"]).optional(),
|
|
238
|
+
roots: z.array(z.string()).optional()
|
|
234
239
|
}).optional()
|
|
235
240
|
}).optional(),
|
|
236
241
|
environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
|
|
@@ -468,9 +473,8 @@ var DEFAULT_CONFIG = {
|
|
|
468
473
|
{
|
|
469
474
|
field: "command",
|
|
470
475
|
op: "matches",
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
value: "rm\\b.*\\s(-[rRfF]*[rR][rRfF]*|--recursive)(\\s|$)"
|
|
476
|
+
// Anchor rm as a shell command (not inside a string arg like a git commit message).
|
|
477
|
+
value: "(^|&&|\\|\\||;)\\s*rm\\b[^;&|]*\\s(-[rRfF]*[rR][rRfF]*|--recursive)(\\s|$)"
|
|
474
478
|
},
|
|
475
479
|
{
|
|
476
480
|
field: "command",
|
|
@@ -499,6 +503,13 @@ var DEFAULT_CONFIG = {
|
|
|
499
503
|
name: "review-drop-truncate-shell",
|
|
500
504
|
tool: "bash",
|
|
501
505
|
conditions: [
|
|
506
|
+
{
|
|
507
|
+
field: "command",
|
|
508
|
+
op: "matches",
|
|
509
|
+
// Require a DB CLI in the command so grep/cat/echo of SQL strings don't trigger.
|
|
510
|
+
value: "(^|&&|\\|\\||;|\\|)\\s*(psql|mysql|sqlite3|sqlplus|cockroach|clickhouse-client|mongo)\\b",
|
|
511
|
+
flags: "i"
|
|
512
|
+
},
|
|
502
513
|
{
|
|
503
514
|
field: "command",
|
|
504
515
|
op: "matches",
|
|
@@ -519,7 +530,9 @@ var DEFAULT_CONFIG = {
|
|
|
519
530
|
{
|
|
520
531
|
field: "command",
|
|
521
532
|
op: "matches",
|
|
522
|
-
|
|
533
|
+
// Anchor git as a shell command so node -e / python -c scripts containing
|
|
534
|
+
// "git push --force" as a string don't false-positive.
|
|
535
|
+
value: "(^|&&|\\|\\||;)\\s*git\\s+push[^;&|]*(--force|--force-with-lease|-f\\b)",
|
|
523
536
|
flags: "i"
|
|
524
537
|
}
|
|
525
538
|
],
|
|
@@ -529,29 +542,20 @@ var DEFAULT_CONFIG = {
|
|
|
529
542
|
description: "The AI wants to force push to a remote git branch. This rewrites shared history and can permanently destroy commits that teammates have already pulled."
|
|
530
543
|
},
|
|
531
544
|
{
|
|
532
|
-
name: "review-git-
|
|
545
|
+
name: "review-git-destructive",
|
|
533
546
|
tool: "bash",
|
|
534
547
|
conditions: [
|
|
535
548
|
{
|
|
536
549
|
field: "command",
|
|
537
550
|
op: "matches",
|
|
538
|
-
value: "\\bgit\\
|
|
551
|
+
value: "\\bgit\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
539
552
|
flags: "i"
|
|
540
|
-
}
|
|
541
|
-
],
|
|
542
|
-
conditionMode: "all",
|
|
543
|
-
verdict: "review",
|
|
544
|
-
reason: "git push sends changes to a shared remote",
|
|
545
|
-
description: "The AI wants to push commits to a remote repository. Once pushed, those changes are visible to everyone with access."
|
|
546
|
-
},
|
|
547
|
-
{
|
|
548
|
-
name: "review-git-destructive",
|
|
549
|
-
tool: "bash",
|
|
550
|
-
conditions: [
|
|
553
|
+
},
|
|
551
554
|
{
|
|
552
555
|
field: "command",
|
|
553
|
-
op: "
|
|
554
|
-
|
|
556
|
+
op: "notMatches",
|
|
557
|
+
// Exclude recovery ops — these resolve a conflict, not start a destructive action.
|
|
558
|
+
value: "\\bgit\\s+rebase\\s+--(abort|continue|skip)\\b",
|
|
555
559
|
flags: "i"
|
|
556
560
|
}
|
|
557
561
|
],
|
|
@@ -577,7 +581,9 @@ var DEFAULT_CONFIG = {
|
|
|
577
581
|
{
|
|
578
582
|
field: "command",
|
|
579
583
|
op: "matches",
|
|
580
|
-
|
|
584
|
+
// Anchor curl/wget as a shell command so node -e scripts testing this
|
|
585
|
+
// regex pattern don't self-match as a false positive.
|
|
586
|
+
value: "(^|&&|\\|\\||;)\\s*(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
581
587
|
flags: "i"
|
|
582
588
|
}
|
|
583
589
|
],
|
|
@@ -588,7 +594,8 @@ var DEFAULT_CONFIG = {
|
|
|
588
594
|
}
|
|
589
595
|
],
|
|
590
596
|
dlp: { enabled: true, scanIgnoredTools: true },
|
|
591
|
-
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 }
|
|
597
|
+
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
|
|
598
|
+
skillPinning: { enabled: false, mode: "warn", roots: [] }
|
|
592
599
|
},
|
|
593
600
|
environments: {}
|
|
594
601
|
};
|
|
@@ -711,7 +718,11 @@ function getConfig(cwd) {
|
|
|
711
718
|
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
712
719
|
},
|
|
713
720
|
dlp: { ...DEFAULT_CONFIG.policy.dlp },
|
|
714
|
-
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection }
|
|
721
|
+
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
|
|
722
|
+
skillPinning: {
|
|
723
|
+
...DEFAULT_CONFIG.policy.skillPinning,
|
|
724
|
+
roots: [...DEFAULT_CONFIG.policy.skillPinning.roots]
|
|
725
|
+
}
|
|
715
726
|
};
|
|
716
727
|
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
717
728
|
const applyLayer = (source) => {
|
|
@@ -764,6 +775,16 @@ function getConfig(cwd) {
|
|
|
764
775
|
if (ld.windowSeconds !== void 0)
|
|
765
776
|
mergedPolicy.loopDetection.windowSeconds = ld.windowSeconds;
|
|
766
777
|
}
|
|
778
|
+
if (p.skillPinning && typeof p.skillPinning === "object") {
|
|
779
|
+
const sp = p.skillPinning;
|
|
780
|
+
if (sp.enabled !== void 0) mergedPolicy.skillPinning.enabled = sp.enabled;
|
|
781
|
+
if (sp.mode !== void 0) mergedPolicy.skillPinning.mode = sp.mode;
|
|
782
|
+
if (Array.isArray(sp.roots)) {
|
|
783
|
+
for (const r of sp.roots) {
|
|
784
|
+
if (typeof r === "string" && r.length > 0) mergedPolicy.skillPinning.roots.push(r);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
767
788
|
const envs = source.environments || {};
|
|
768
789
|
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
769
790
|
if (envConfig && typeof envConfig === "object") {
|
|
@@ -815,6 +836,7 @@ function getConfig(cwd) {
|
|
|
815
836
|
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
816
837
|
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
817
838
|
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
839
|
+
mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
|
|
818
840
|
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
819
841
|
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
820
842
|
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
@@ -962,7 +984,7 @@ var DLP_PATTERNS = [
|
|
|
962
984
|
regex: /_authToken\s*=\s*[A-Za-z0-9_\-]{20,}/,
|
|
963
985
|
severity: "block"
|
|
964
986
|
},
|
|
965
|
-
{ name: "Bearer Token", regex: /Bearer\s+[a-zA-Z0-9\-._~+/]
|
|
987
|
+
{ name: "Bearer Token", regex: /Bearer\s+[a-zA-Z0-9\-._~+/]{20,}=*/i, severity: "review" }
|
|
966
988
|
];
|
|
967
989
|
var SENSITIVE_PATH_PATTERNS = [
|
|
968
990
|
/[/\\]\.ssh[/\\]/i,
|
|
@@ -1552,12 +1574,25 @@ function getNestedValue(obj, path15) {
|
|
|
1552
1574
|
if (!obj || typeof obj !== "object") return null;
|
|
1553
1575
|
return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
1554
1576
|
}
|
|
1577
|
+
function stripStringArguments(cmd) {
|
|
1578
|
+
let result = cmd;
|
|
1579
|
+
result = result.replace(
|
|
1580
|
+
/\b(node|python3?|ruby|perl|php|deno)\s+(-[ecr]|eval)\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/gi,
|
|
1581
|
+
'$1 $2 ""'
|
|
1582
|
+
);
|
|
1583
|
+
result = result.replace(
|
|
1584
|
+
/\s(-m|--message|--body|--title|--description)\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
|
|
1585
|
+
' $1 ""'
|
|
1586
|
+
);
|
|
1587
|
+
return result;
|
|
1588
|
+
}
|
|
1555
1589
|
function evaluateSmartConditions(args, rule) {
|
|
1556
1590
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
1557
1591
|
const mode = rule.conditionMode ?? "all";
|
|
1558
1592
|
const results = rule.conditions.map((cond) => {
|
|
1559
1593
|
const rawVal = getNestedValue(args, cond.field);
|
|
1560
|
-
const
|
|
1594
|
+
const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
|
|
1595
|
+
const val = cond.field === "command" && normalized !== null ? stripStringArguments(normalized) : normalized;
|
|
1561
1596
|
switch (cond.op) {
|
|
1562
1597
|
case "exists":
|
|
1563
1598
|
return val !== null && val !== "";
|
|
@@ -1860,6 +1895,15 @@ import path9 from "path";
|
|
|
1860
1895
|
import os6 from "os";
|
|
1861
1896
|
var PAUSED_FILE = path9.join(os6.homedir(), ".node9", "PAUSED");
|
|
1862
1897
|
var TRUST_FILE = path9.join(os6.homedir(), ".node9", "trust.json");
|
|
1898
|
+
function extractCommandPattern(toolName, args) {
|
|
1899
|
+
const lower = toolName.toLowerCase();
|
|
1900
|
+
if (lower !== "bash" && lower !== "execute_bash" && lower !== "shell") return void 0;
|
|
1901
|
+
const a = args;
|
|
1902
|
+
const cmd = typeof a?.["command"] === "string" ? a["command"].trim() : "";
|
|
1903
|
+
if (!cmd) return void 0;
|
|
1904
|
+
const words = cmd.split(/\s+/);
|
|
1905
|
+
return words.slice(0, 2).join(" ");
|
|
1906
|
+
}
|
|
1863
1907
|
function checkPause() {
|
|
1864
1908
|
try {
|
|
1865
1909
|
if (!fs7.existsSync(PAUSED_FILE)) return { paused: false };
|
|
@@ -1883,7 +1927,7 @@ function atomicWriteSync(filePath, data, options) {
|
|
|
1883
1927
|
fs7.writeFileSync(tmpPath, data, options);
|
|
1884
1928
|
fs7.renameSync(tmpPath, filePath);
|
|
1885
1929
|
}
|
|
1886
|
-
function getActiveTrustSession(toolName) {
|
|
1930
|
+
function getActiveTrustSession(toolName, args) {
|
|
1887
1931
|
try {
|
|
1888
1932
|
if (!fs7.existsSync(TRUST_FILE)) return false;
|
|
1889
1933
|
const trust = JSON.parse(fs7.readFileSync(TRUST_FILE, "utf-8"));
|
|
@@ -1892,12 +1936,20 @@ function getActiveTrustSession(toolName) {
|
|
|
1892
1936
|
if (active.length !== trust.entries.length) {
|
|
1893
1937
|
fs7.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
|
|
1894
1938
|
}
|
|
1895
|
-
return active.some((e) =>
|
|
1939
|
+
return active.some((e) => {
|
|
1940
|
+
if (!(e.tool === toolName || matchesPattern(toolName, e.tool))) return false;
|
|
1941
|
+
if (e.commandPattern) {
|
|
1942
|
+
const actual = extractCommandPattern(toolName, args) ?? "";
|
|
1943
|
+
return actual === e.commandPattern || actual.startsWith(e.commandPattern + " ");
|
|
1944
|
+
}
|
|
1945
|
+
return true;
|
|
1946
|
+
});
|
|
1896
1947
|
} catch {
|
|
1897
1948
|
return false;
|
|
1898
1949
|
}
|
|
1899
1950
|
}
|
|
1900
|
-
function writeTrustSession(toolName, durationMs) {
|
|
1951
|
+
function writeTrustSession(toolName, durationMs, args) {
|
|
1952
|
+
const commandPattern = extractCommandPattern(toolName, args);
|
|
1901
1953
|
try {
|
|
1902
1954
|
let trust = { entries: [] };
|
|
1903
1955
|
try {
|
|
@@ -1907,8 +1959,14 @@ function writeTrustSession(toolName, durationMs) {
|
|
|
1907
1959
|
} catch {
|
|
1908
1960
|
}
|
|
1909
1961
|
const now = Date.now();
|
|
1910
|
-
trust.entries = trust.entries.filter(
|
|
1911
|
-
|
|
1962
|
+
trust.entries = trust.entries.filter(
|
|
1963
|
+
(e) => !(e.tool === toolName && e.commandPattern === commandPattern) && e.expiry > now
|
|
1964
|
+
);
|
|
1965
|
+
trust.entries.push({
|
|
1966
|
+
tool: toolName,
|
|
1967
|
+
...commandPattern && { commandPattern },
|
|
1968
|
+
expiry: now + durationMs
|
|
1969
|
+
});
|
|
1912
1970
|
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
1913
1971
|
} catch (err) {
|
|
1914
1972
|
if (process.env.NODE9_DEBUG === "1") {
|
|
@@ -2339,7 +2397,8 @@ function escapePango(text) {
|
|
|
2339
2397
|
function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription) {
|
|
2340
2398
|
const lines = [];
|
|
2341
2399
|
if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
|
|
2342
|
-
|
|
2400
|
+
const safeAgent = (agent ?? "AI Agent").replace(/\x1b(?:\[[0-9;?]*[a-zA-Z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])/g, "").slice(0, 80);
|
|
2401
|
+
lines.push(`\u{1F916} ${safeAgent} | \u{1F527} ${toolName}`);
|
|
2343
2402
|
lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
|
|
2344
2403
|
if (ruleDescription) lines.push(`\u2139 ${ruleDescription}`);
|
|
2345
2404
|
lines.push("");
|
|
@@ -2716,7 +2775,16 @@ async function authorizeHeadless(toolName, args, meta, options) {
|
|
|
2716
2775
|
if (!options?.calledFromDaemon) {
|
|
2717
2776
|
const actId = randomUUID();
|
|
2718
2777
|
const actTs = Date.now();
|
|
2719
|
-
await notifyActivity({
|
|
2778
|
+
await notifyActivity({
|
|
2779
|
+
id: actId,
|
|
2780
|
+
ts: actTs,
|
|
2781
|
+
tool: toolName,
|
|
2782
|
+
args,
|
|
2783
|
+
status: "pending",
|
|
2784
|
+
// Strip ANSI escape sequences — agent name comes from caller-supplied metadata
|
|
2785
|
+
// and may be displayed in a terminal (node9 tail/watch), enabling injection.
|
|
2786
|
+
agent: meta?.agent ? meta.agent.replace(/\x1b(?:\[[0-9;?]*[a-zA-Z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])/g, "").slice(0, 80) : void 0
|
|
2787
|
+
});
|
|
2720
2788
|
const result = await _authorizeHeadlessCore(toolName, args, meta, {
|
|
2721
2789
|
...options,
|
|
2722
2790
|
activityId: actId
|
|
@@ -2870,12 +2938,6 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2870
2938
|
};
|
|
2871
2939
|
}
|
|
2872
2940
|
}
|
|
2873
|
-
if (getActiveTrustSession(toolName)) {
|
|
2874
|
-
if (approvers.cloud && creds?.apiKey)
|
|
2875
|
-
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
2876
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
|
|
2877
|
-
return { approved: true, checkedBy: "trust" };
|
|
2878
|
-
}
|
|
2879
2941
|
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
2880
2942
|
if (policyResult.decision === "allow") {
|
|
2881
2943
|
if (approvers.cloud && creds?.apiKey)
|
|
@@ -2957,6 +3019,12 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2957
3019
|
if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta, hashAuditArgs);
|
|
2958
3020
|
return { approved: true };
|
|
2959
3021
|
}
|
|
3022
|
+
if (!taintWarning && getActiveTrustSession(toolName, args)) {
|
|
3023
|
+
if (approvers.cloud && creds?.apiKey)
|
|
3024
|
+
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
3025
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
|
|
3026
|
+
return { approved: true, checkedBy: "trust" };
|
|
3027
|
+
}
|
|
2960
3028
|
if (taintWarning) {
|
|
2961
3029
|
explainableLabel = "\u{1F534} Node9 Taint (Exfiltration Prevention)";
|
|
2962
3030
|
riskMetadata = computeRiskMetadata(
|
|
@@ -3089,7 +3157,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3089
3157
|
riskMetadata?.ruleDescription
|
|
3090
3158
|
);
|
|
3091
3159
|
if (decision === "always_allow") {
|
|
3092
|
-
writeTrustSession(toolName, 36e5);
|
|
3160
|
+
writeTrustSession(toolName, 36e5, args);
|
|
3093
3161
|
return { approved: true, checkedBy: "trust" };
|
|
3094
3162
|
}
|
|
3095
3163
|
const isApproved = decision === "allow";
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"field": "command",
|
|
12
12
|
"op": "matches",
|
|
13
|
-
"value": "(curl|wget)\\s+[^|]*\\|\\s*(?:(bash|sh|zsh|fish)|(python3?|ruby|perl|node)\\b(?!\\s+-[cem]\\b))",
|
|
13
|
+
"value": "(^|&&|\\|\\||;)\\s*(curl|wget)\\s+[^|]*\\|\\s*(?:(bash|sh|zsh|fish)|(python3?|ruby|perl|node)\\b(?!\\s+-[cem]\\b))",
|
|
14
14
|
"flags": "i"
|
|
15
15
|
}
|
|
16
16
|
],
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
{
|
|
25
25
|
"field": "command",
|
|
26
26
|
"op": "matches",
|
|
27
|
-
"value": "
|
|
27
|
+
"value": "\\bbase64\\s+(-d|--decode)[^|;&]*\\|\\s*(bash|sh|zsh)",
|
|
28
28
|
"flags": "i"
|
|
29
29
|
}
|
|
30
30
|
],
|