@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/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
- // Require the recursive flag to be preceded by whitespace so that
502
- // filenames containing "-r" (e.g. "ai-review.yml") don't false-positive.
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
- value: "\\bgit\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
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-push",
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\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
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: "matches",
584
- value: "\\bgit\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
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
- value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
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\-._~+/]+=*/i, severity: "review" }
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 val = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
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) => e.tool === toolName || matchesPattern(toolName, e.tool));
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((e) => e.tool !== toolName && e.expiry > now);
1941
- trust.entries.push({ tool: toolName, expiry: now + durationMs });
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
- lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
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({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
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
- // Require the recursive flag to be preceded by whitespace so that
472
- // filenames containing "-r" (e.g. "ai-review.yml") don't false-positive.
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
- value: "\\bgit\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
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-push",
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\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
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: "matches",
554
- value: "\\bgit\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
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
- value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
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\-._~+/]+=*/i, severity: "review" }
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 val = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
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) => e.tool === toolName || matchesPattern(toolName, e.tool));
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((e) => e.tool !== toolName && e.expiry > now);
1911
- trust.entries.push({ tool: toolName, expiry: now + durationMs });
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
- lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
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({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
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": "base64\\s+(-d|--decode).*\\|\\s*(bash|sh|zsh)",
27
+ "value": "\\bbase64\\s+(-d|--decode)[^|;&]*\\|\\s*(bash|sh|zsh)",
28
28
  "flags": "i"
29
29
  }
30
30
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.10.3",
3
+ "version": "1.11.1",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code & MCP.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",