@node9/proxy 1.0.13 → 1.0.15

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.
Files changed (6) hide show
  1. package/README.md +188 -119
  2. package/dist/cli.js +2335 -1097
  3. package/dist/cli.mjs +2315 -1075
  4. package/dist/index.js +500 -125
  5. package/dist/index.mjs +500 -125
  6. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  // src/core.ts
2
2
  import chalk2 from "chalk";
3
3
  import { confirm } from "@inquirer/prompts";
4
- import fs from "fs";
5
- import path3 from "path";
6
- import os from "os";
4
+ import fs2 from "fs";
5
+ import path4 from "path";
6
+ import os2 from "os";
7
+ import net from "net";
8
+ import { randomUUID } from "crypto";
7
9
  import pm from "picomatch";
8
10
  import { parse } from "sh-syntax";
9
11
 
@@ -333,25 +335,26 @@ import { z } from "zod";
333
335
  var noNewlines = z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
334
336
  message: "Value must not contain literal newline characters (use \\n instead)"
335
337
  });
336
- var validRegex = noNewlines.refine(
337
- (s) => {
338
- try {
339
- new RegExp(s);
340
- return true;
341
- } catch {
342
- return false;
343
- }
344
- },
345
- { message: "Value must be a valid regular expression" }
346
- );
347
338
  var SmartConditionSchema = z.object({
348
339
  field: z.string().min(1, "Condition field must not be empty"),
349
- op: z.enum(["matches", "notMatches", "contains", "notContains", "exists", "notExists"], {
350
- errorMap: () => ({
351
- message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists"
352
- })
353
- }),
354
- value: validRegex.optional(),
340
+ op: z.enum(
341
+ [
342
+ "matches",
343
+ "notMatches",
344
+ "contains",
345
+ "notContains",
346
+ "exists",
347
+ "notExists",
348
+ "matchesGlob",
349
+ "notMatchesGlob"
350
+ ],
351
+ {
352
+ errorMap: () => ({
353
+ message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists, matchesGlob, notMatchesGlob"
354
+ })
355
+ }
356
+ ),
357
+ value: z.string().optional(),
355
358
  flags: z.string().optional()
356
359
  });
357
360
  var SmartRuleSchema = z.object({
@@ -364,11 +367,6 @@ var SmartRuleSchema = z.object({
364
367
  }),
365
368
  reason: z.string().optional()
366
369
  });
367
- var PolicyRuleSchema = z.object({
368
- action: z.string().min(1),
369
- allowPaths: z.array(z.string()).optional(),
370
- blockPaths: z.array(z.string()).optional()
371
- });
372
370
  var ConfigFileSchema = z.object({
373
371
  version: z.string().optional(),
374
372
  settings: z.object({
@@ -393,12 +391,15 @@ var ConfigFileSchema = z.object({
393
391
  dangerousWords: z.array(noNewlines).optional(),
394
392
  ignoredTools: z.array(z.string()).optional(),
395
393
  toolInspection: z.record(z.string()).optional(),
396
- rules: z.array(PolicyRuleSchema).optional(),
397
394
  smartRules: z.array(SmartRuleSchema).optional(),
398
395
  snapshot: z.object({
399
396
  tools: z.array(z.string()).optional(),
400
397
  onlyPaths: z.array(z.string()).optional(),
401
398
  ignorePaths: z.array(z.string()).optional()
399
+ }).optional(),
400
+ dlp: z.object({
401
+ enabled: z.boolean().optional(),
402
+ scanIgnoredTools: z.boolean().optional()
402
403
  }).optional()
403
404
  }).optional(),
404
405
  environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
@@ -420,8 +421,8 @@ function sanitizeConfig(raw) {
420
421
  }
421
422
  }
422
423
  const lines = result.error.issues.map((issue) => {
423
- const path4 = issue.path.length > 0 ? issue.path.join(".") : "root";
424
- return ` \u2022 ${path4}: ${issue.message}`;
424
+ const path5 = issue.path.length > 0 ? issue.path.join(".") : "root";
425
+ return ` \u2022 ${path5}: ${issue.message}`;
425
426
  });
426
427
  return {
427
428
  sanitized,
@@ -430,18 +431,291 @@ ${lines.join("\n")}`
430
431
  };
431
432
  }
432
433
 
434
+ // src/shields.ts
435
+ import fs from "fs";
436
+ import path3 from "path";
437
+ import os from "os";
438
+ var SHIELDS = {
439
+ postgres: {
440
+ name: "postgres",
441
+ description: "Protects PostgreSQL databases from destructive AI operations",
442
+ aliases: ["pg", "postgresql"],
443
+ smartRules: [
444
+ {
445
+ name: "shield:postgres:block-drop-table",
446
+ tool: "*",
447
+ conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
448
+ verdict: "block",
449
+ reason: "DROP TABLE is irreversible \u2014 blocked by Postgres shield"
450
+ },
451
+ {
452
+ name: "shield:postgres:block-truncate",
453
+ tool: "*",
454
+ conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
455
+ verdict: "block",
456
+ reason: "TRUNCATE is irreversible \u2014 blocked by Postgres shield"
457
+ },
458
+ {
459
+ name: "shield:postgres:block-drop-column",
460
+ tool: "*",
461
+ conditions: [
462
+ { field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
463
+ ],
464
+ verdict: "block",
465
+ reason: "DROP COLUMN is irreversible \u2014 blocked by Postgres shield"
466
+ },
467
+ {
468
+ name: "shield:postgres:review-grant-revoke",
469
+ tool: "*",
470
+ conditions: [{ field: "sql", op: "matches", value: "\\b(GRANT|REVOKE)\\b", flags: "i" }],
471
+ verdict: "review",
472
+ reason: "Permission changes require human approval (Postgres shield)"
473
+ }
474
+ ],
475
+ dangerousWords: ["dropdb", "pg_dropcluster"]
476
+ },
477
+ github: {
478
+ name: "github",
479
+ description: "Protects GitHub repositories from destructive AI operations",
480
+ aliases: ["git"],
481
+ smartRules: [
482
+ {
483
+ // Note: git branch -d/-D is already caught by the built-in review-git-destructive rule.
484
+ // This rule adds coverage for `git push --delete` which the built-in does not match.
485
+ name: "shield:github:review-delete-branch-remote",
486
+ tool: "bash",
487
+ conditions: [
488
+ {
489
+ field: "command",
490
+ op: "matches",
491
+ value: "git\\s+push\\s+.*--delete",
492
+ flags: "i"
493
+ }
494
+ ],
495
+ verdict: "review",
496
+ reason: "Remote branch deletion requires human approval (GitHub shield)"
497
+ },
498
+ {
499
+ name: "shield:github:block-delete-repo",
500
+ tool: "*",
501
+ conditions: [
502
+ { field: "command", op: "matches", value: "gh\\s+repo\\s+delete", flags: "i" }
503
+ ],
504
+ verdict: "block",
505
+ reason: "Repository deletion is irreversible \u2014 blocked by GitHub shield"
506
+ }
507
+ ],
508
+ dangerousWords: []
509
+ },
510
+ aws: {
511
+ name: "aws",
512
+ description: "Protects AWS infrastructure from destructive AI operations",
513
+ aliases: ["amazon"],
514
+ smartRules: [
515
+ {
516
+ name: "shield:aws:block-delete-s3-bucket",
517
+ tool: "*",
518
+ conditions: [
519
+ {
520
+ field: "command",
521
+ op: "matches",
522
+ value: "aws\\s+s3.*rb\\s|aws\\s+s3api\\s+delete-bucket",
523
+ flags: "i"
524
+ }
525
+ ],
526
+ verdict: "block",
527
+ reason: "S3 bucket deletion is irreversible \u2014 blocked by AWS shield"
528
+ },
529
+ {
530
+ name: "shield:aws:review-iam-changes",
531
+ tool: "*",
532
+ conditions: [
533
+ {
534
+ field: "command",
535
+ op: "matches",
536
+ value: "aws\\s+iam\\s+(create|delete|attach|detach|put|remove)",
537
+ flags: "i"
538
+ }
539
+ ],
540
+ verdict: "review",
541
+ reason: "IAM changes require human approval (AWS shield)"
542
+ },
543
+ {
544
+ name: "shield:aws:block-ec2-terminate",
545
+ tool: "*",
546
+ conditions: [
547
+ {
548
+ field: "command",
549
+ op: "matches",
550
+ value: "aws\\s+ec2\\s+terminate-instances",
551
+ flags: "i"
552
+ }
553
+ ],
554
+ verdict: "block",
555
+ reason: "EC2 instance termination is irreversible \u2014 blocked by AWS shield"
556
+ },
557
+ {
558
+ name: "shield:aws:review-rds-delete",
559
+ tool: "*",
560
+ conditions: [
561
+ { field: "command", op: "matches", value: "aws\\s+rds\\s+delete-", flags: "i" }
562
+ ],
563
+ verdict: "review",
564
+ reason: "RDS deletion requires human approval (AWS shield)"
565
+ }
566
+ ],
567
+ dangerousWords: []
568
+ },
569
+ filesystem: {
570
+ name: "filesystem",
571
+ description: "Protects the local filesystem from dangerous AI operations",
572
+ aliases: ["fs"],
573
+ smartRules: [
574
+ {
575
+ name: "shield:filesystem:review-chmod-777",
576
+ tool: "bash",
577
+ conditions: [
578
+ { field: "command", op: "matches", value: "chmod\\s+(777|a\\+rwx)", flags: "i" }
579
+ ],
580
+ verdict: "review",
581
+ reason: "chmod 777 requires human approval (filesystem shield)"
582
+ },
583
+ {
584
+ name: "shield:filesystem:review-write-etc",
585
+ tool: "bash",
586
+ conditions: [
587
+ {
588
+ field: "command",
589
+ // Narrow to write-indicative operations to avoid approval fatigue on reads.
590
+ // Matches: tee /etc/*, cp .../etc/*, mv .../etc/*, > /etc/*, install .../etc/*
591
+ op: "matches",
592
+ value: "(tee|\\bcp\\b|\\bmv\\b|install|>+)\\s+.*\\/etc\\/"
593
+ }
594
+ ],
595
+ verdict: "review",
596
+ reason: "Writing to /etc requires human approval (filesystem shield)"
597
+ }
598
+ ],
599
+ // dd removed: too common as a legitimate tool (disk imaging, file ops).
600
+ // mkfs removed: already in the built-in DANGEROUS_WORDS baseline.
601
+ // wipefs retained: rarely legitimate in an agent context and not in built-ins.
602
+ dangerousWords: ["wipefs"]
603
+ }
604
+ };
605
+ function resolveShieldName(input) {
606
+ const lower = input.toLowerCase();
607
+ if (SHIELDS[lower]) return lower;
608
+ for (const [name, def] of Object.entries(SHIELDS)) {
609
+ if (def.aliases.includes(lower)) return name;
610
+ }
611
+ return null;
612
+ }
613
+ function getShield(name) {
614
+ const resolved = resolveShieldName(name);
615
+ return resolved ? SHIELDS[resolved] : null;
616
+ }
617
+ var SHIELDS_STATE_FILE = path3.join(os.homedir(), ".node9", "shields.json");
618
+ function readActiveShields() {
619
+ try {
620
+ const raw = fs.readFileSync(SHIELDS_STATE_FILE, "utf-8");
621
+ if (!raw.trim()) return [];
622
+ const parsed = JSON.parse(raw);
623
+ if (Array.isArray(parsed.active)) {
624
+ return parsed.active.filter(
625
+ (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
626
+ );
627
+ }
628
+ } catch (err) {
629
+ if (err.code !== "ENOENT") {
630
+ process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
631
+ `);
632
+ }
633
+ }
634
+ return [];
635
+ }
636
+
637
+ // src/dlp.ts
638
+ var DLP_PATTERNS = [
639
+ { name: "AWS Access Key ID", regex: /\bAKIA[0-9A-Z]{16}\b/, severity: "block" },
640
+ { name: "GitHub Token", regex: /\bgh[pous]_[A-Za-z0-9]{36}\b/, severity: "block" },
641
+ { name: "Slack Bot Token", regex: /\bxoxb-[0-9A-Za-z-]+\b/, severity: "block" },
642
+ { name: "OpenAI API Key", regex: /\bsk-[a-zA-Z0-9_-]{20,}\b/, severity: "block" },
643
+ { name: "Stripe Secret Key", regex: /\bsk_(?:live|test)_[0-9a-zA-Z]{24}\b/, severity: "block" },
644
+ {
645
+ name: "Private Key (PEM)",
646
+ regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
647
+ severity: "block"
648
+ },
649
+ { name: "Bearer Token", regex: /Bearer\s+[a-zA-Z0-9\-._~+/]+=*/i, severity: "review" }
650
+ ];
651
+ function maskSecret(raw, pattern) {
652
+ const match = raw.match(pattern);
653
+ if (!match) return "****";
654
+ const secret = match[0];
655
+ if (secret.length < 8) return "****";
656
+ const prefix = secret.slice(0, 4);
657
+ const suffix = secret.slice(-4);
658
+ const stars = "*".repeat(Math.min(secret.length - 8, 12));
659
+ return `${prefix}${stars}${suffix}`;
660
+ }
661
+ var MAX_DEPTH = 5;
662
+ var MAX_STRING_BYTES = 1e5;
663
+ var MAX_JSON_PARSE_BYTES = 1e4;
664
+ function scanArgs(args, depth = 0, fieldPath = "args") {
665
+ if (depth > MAX_DEPTH || args === null || args === void 0) return null;
666
+ if (Array.isArray(args)) {
667
+ for (let i = 0; i < args.length; i++) {
668
+ const match = scanArgs(args[i], depth + 1, `${fieldPath}[${i}]`);
669
+ if (match) return match;
670
+ }
671
+ return null;
672
+ }
673
+ if (typeof args === "object") {
674
+ for (const [key, value] of Object.entries(args)) {
675
+ const match = scanArgs(value, depth + 1, `${fieldPath}.${key}`);
676
+ if (match) return match;
677
+ }
678
+ return null;
679
+ }
680
+ if (typeof args === "string") {
681
+ const text = args.length > MAX_STRING_BYTES ? args.slice(0, MAX_STRING_BYTES) : args;
682
+ for (const pattern of DLP_PATTERNS) {
683
+ if (pattern.regex.test(text)) {
684
+ return {
685
+ patternName: pattern.name,
686
+ fieldPath,
687
+ redactedSample: maskSecret(text, pattern.regex),
688
+ severity: pattern.severity
689
+ };
690
+ }
691
+ }
692
+ if (text.length < MAX_JSON_PARSE_BYTES) {
693
+ const trimmed = text.trim();
694
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
695
+ try {
696
+ const parsed = JSON.parse(text);
697
+ const inner = scanArgs(parsed, depth + 1, fieldPath);
698
+ if (inner) return inner;
699
+ } catch {
700
+ }
701
+ }
702
+ }
703
+ }
704
+ return null;
705
+ }
706
+
433
707
  // src/core.ts
434
- var PAUSED_FILE = path3.join(os.homedir(), ".node9", "PAUSED");
435
- var TRUST_FILE = path3.join(os.homedir(), ".node9", "trust.json");
436
- var LOCAL_AUDIT_LOG = path3.join(os.homedir(), ".node9", "audit.log");
437
- var HOOK_DEBUG_LOG = path3.join(os.homedir(), ".node9", "hook-debug.log");
708
+ var PAUSED_FILE = path4.join(os2.homedir(), ".node9", "PAUSED");
709
+ var TRUST_FILE = path4.join(os2.homedir(), ".node9", "trust.json");
710
+ var LOCAL_AUDIT_LOG = path4.join(os2.homedir(), ".node9", "audit.log");
711
+ var HOOK_DEBUG_LOG = path4.join(os2.homedir(), ".node9", "hook-debug.log");
438
712
  function checkPause() {
439
713
  try {
440
- if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
441
- const state = JSON.parse(fs.readFileSync(PAUSED_FILE, "utf-8"));
714
+ if (!fs2.existsSync(PAUSED_FILE)) return { paused: false };
715
+ const state = JSON.parse(fs2.readFileSync(PAUSED_FILE, "utf-8"));
442
716
  if (state.expiry > 0 && Date.now() >= state.expiry) {
443
717
  try {
444
- fs.unlinkSync(PAUSED_FILE);
718
+ fs2.unlinkSync(PAUSED_FILE);
445
719
  } catch {
446
720
  }
447
721
  return { paused: false };
@@ -452,20 +726,20 @@ function checkPause() {
452
726
  }
453
727
  }
454
728
  function atomicWriteSync(filePath, data, options) {
455
- const dir = path3.dirname(filePath);
456
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
457
- const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
458
- fs.writeFileSync(tmpPath, data, options);
459
- fs.renameSync(tmpPath, filePath);
729
+ const dir = path4.dirname(filePath);
730
+ if (!fs2.existsSync(dir)) fs2.mkdirSync(dir, { recursive: true });
731
+ const tmpPath = `${filePath}.${os2.hostname()}.${process.pid}.tmp`;
732
+ fs2.writeFileSync(tmpPath, data, options);
733
+ fs2.renameSync(tmpPath, filePath);
460
734
  }
461
735
  function getActiveTrustSession(toolName) {
462
736
  try {
463
- if (!fs.existsSync(TRUST_FILE)) return false;
464
- const trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
737
+ if (!fs2.existsSync(TRUST_FILE)) return false;
738
+ const trust = JSON.parse(fs2.readFileSync(TRUST_FILE, "utf-8"));
465
739
  const now = Date.now();
466
740
  const active = trust.entries.filter((e) => e.expiry > now);
467
741
  if (active.length !== trust.entries.length) {
468
- fs.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
742
+ fs2.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
469
743
  }
470
744
  return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
471
745
  } catch {
@@ -476,8 +750,8 @@ function writeTrustSession(toolName, durationMs) {
476
750
  try {
477
751
  let trust = { entries: [] };
478
752
  try {
479
- if (fs.existsSync(TRUST_FILE)) {
480
- trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
753
+ if (fs2.existsSync(TRUST_FILE)) {
754
+ trust = JSON.parse(fs2.readFileSync(TRUST_FILE, "utf-8"));
481
755
  }
482
756
  } catch {
483
757
  }
@@ -493,9 +767,9 @@ function writeTrustSession(toolName, durationMs) {
493
767
  }
494
768
  function appendToLog(logPath, entry) {
495
769
  try {
496
- const dir = path3.dirname(logPath);
497
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
498
- fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
770
+ const dir = path4.dirname(logPath);
771
+ if (!fs2.existsSync(dir)) fs2.mkdirSync(dir, { recursive: true });
772
+ fs2.appendFileSync(logPath, JSON.stringify(entry) + "\n");
499
773
  } catch {
500
774
  }
501
775
  }
@@ -507,7 +781,7 @@ function appendHookDebug(toolName, args, meta) {
507
781
  args: safeArgs,
508
782
  agent: meta?.agent,
509
783
  mcpServer: meta?.mcpServer,
510
- hostname: os.hostname(),
784
+ hostname: os2.hostname(),
511
785
  cwd: process.cwd()
512
786
  });
513
787
  }
@@ -521,7 +795,7 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
521
795
  checkedBy,
522
796
  agent: meta?.agent,
523
797
  mcpServer: meta?.mcpServer,
524
- hostname: os.hostname()
798
+ hostname: os2.hostname()
525
799
  });
526
800
  }
527
801
  function tokenize(toolName) {
@@ -537,9 +811,9 @@ function matchesPattern(text, patterns) {
537
811
  const withoutDotSlash = text.replace(/^\.\//, "");
538
812
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
539
813
  }
540
- function getNestedValue(obj, path4) {
814
+ function getNestedValue(obj, path5) {
541
815
  if (!obj || typeof obj !== "object") return null;
542
- return path4.split(".").reduce((prev, curr) => prev?.[curr], obj);
816
+ return path5.split(".").reduce((prev, curr) => prev?.[curr], obj);
543
817
  }
544
818
  function evaluateSmartConditions(args, rule) {
545
819
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -572,6 +846,10 @@ function evaluateSmartConditions(args, rule) {
572
846
  return true;
573
847
  }
574
848
  }
849
+ case "matchesGlob":
850
+ return val !== null && cond.value ? pm.isMatch(val, cond.value) : false;
851
+ case "notMatchesGlob":
852
+ return val !== null && cond.value ? !pm.isMatch(val, cond.value) : true;
575
853
  default:
576
854
  return false;
577
855
  }
@@ -735,25 +1013,27 @@ var DEFAULT_CONFIG = {
735
1013
  onlyPaths: [],
736
1014
  ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
737
1015
  },
738
- rules: [
739
- // Only use the legacy rules format for simple path-based rm control.
740
- // All other command-level enforcement lives in smartRules below.
741
- {
742
- action: "rm",
743
- allowPaths: [
744
- "**/node_modules/**",
745
- "dist/**",
746
- "build/**",
747
- ".next/**",
748
- "coverage/**",
749
- ".cache/**",
750
- "tmp/**",
751
- "temp/**",
752
- ".DS_Store"
753
- ]
754
- }
755
- ],
756
1016
  smartRules: [
1017
+ // ── rm safety (critical — always evaluated first) ──────────────────────
1018
+ {
1019
+ name: "block-rm-rf-home",
1020
+ tool: "bash",
1021
+ conditionMode: "all",
1022
+ conditions: [
1023
+ {
1024
+ field: "command",
1025
+ op: "matches",
1026
+ value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
1027
+ },
1028
+ {
1029
+ field: "command",
1030
+ op: "matches",
1031
+ value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
1032
+ }
1033
+ ],
1034
+ verdict: "block",
1035
+ reason: "Recursive delete of home directory is irreversible"
1036
+ },
757
1037
  // ── SQL safety ────────────────────────────────────────────────────────
758
1038
  {
759
1039
  name: "no-delete-without-where",
@@ -844,16 +1124,42 @@ var DEFAULT_CONFIG = {
844
1124
  verdict: "block",
845
1125
  reason: "Piping remote script into a shell is a supply-chain attack vector"
846
1126
  }
847
- ]
1127
+ ],
1128
+ dlp: { enabled: true, scanIgnoredTools: true }
848
1129
  },
849
1130
  environments: {}
850
1131
  };
1132
+ var ADVISORY_SMART_RULES = [
1133
+ {
1134
+ name: "allow-rm-safe-paths",
1135
+ tool: "*",
1136
+ conditionMode: "all",
1137
+ conditions: [
1138
+ { field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" },
1139
+ {
1140
+ field: "command",
1141
+ op: "matches",
1142
+ // Matches known-safe build artifact paths in the command.
1143
+ value: "(node_modules|\\bdist\\b|\\.next|\\bcoverage\\b|\\.cache|\\btmp\\b|\\btemp\\b|\\.DS_Store)(\\/|\\s|$)"
1144
+ }
1145
+ ],
1146
+ verdict: "allow",
1147
+ reason: "Deleting a known-safe build artifact path"
1148
+ },
1149
+ {
1150
+ name: "review-rm",
1151
+ tool: "*",
1152
+ conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
1153
+ verdict: "review",
1154
+ reason: "rm can permanently delete files \u2014 confirm the target path"
1155
+ }
1156
+ ];
851
1157
  var cachedConfig = null;
852
1158
  function getInternalToken() {
853
1159
  try {
854
- const pidFile = path3.join(os.homedir(), ".node9", "daemon.pid");
855
- if (!fs.existsSync(pidFile)) return null;
856
- const data = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
1160
+ const pidFile = path4.join(os2.homedir(), ".node9", "daemon.pid");
1161
+ if (!fs2.existsSync(pidFile)) return null;
1162
+ const data = JSON.parse(fs2.readFileSync(pidFile, "utf-8"));
857
1163
  process.kill(data.pid, 0);
858
1164
  return data.internalToken ?? null;
859
1165
  } catch {
@@ -868,7 +1174,8 @@ async function evaluatePolicy(toolName, args, agent) {
868
1174
  (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
869
1175
  );
870
1176
  if (matchedRule) {
871
- if (matchedRule.verdict === "allow") return { decision: "allow" };
1177
+ if (matchedRule.verdict === "allow")
1178
+ return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
872
1179
  return {
873
1180
  decision: matchedRule.verdict,
874
1181
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
@@ -879,13 +1186,11 @@ async function evaluatePolicy(toolName, args, agent) {
879
1186
  }
880
1187
  }
881
1188
  let allTokens = [];
882
- let actionTokens = [];
883
1189
  let pathTokens = [];
884
1190
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
885
1191
  if (shellCommand) {
886
1192
  const analyzed = await analyzeShellCommand(shellCommand);
887
1193
  allTokens = analyzed.allTokens;
888
- actionTokens = analyzed.actions;
889
1194
  pathTokens = analyzed.paths;
890
1195
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
891
1196
  if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
@@ -893,11 +1198,9 @@ async function evaluatePolicy(toolName, args, agent) {
893
1198
  }
894
1199
  if (isSqlTool(toolName, config.policy.toolInspection)) {
895
1200
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
896
- actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
897
1201
  }
898
1202
  } else {
899
1203
  allTokens = tokenize(toolName);
900
- actionTokens = [toolName];
901
1204
  if (args && typeof args === "object") {
902
1205
  const flattenedArgs = JSON.stringify(args).toLowerCase();
903
1206
  const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
@@ -920,29 +1223,6 @@ async function evaluatePolicy(toolName, args, agent) {
920
1223
  const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
921
1224
  if (allInSandbox) return { decision: "allow" };
922
1225
  }
923
- for (const action of actionTokens) {
924
- const rule = config.policy.rules.find(
925
- (r) => r.action === action || matchesPattern(action, r.action)
926
- );
927
- if (rule) {
928
- if (pathTokens.length > 0) {
929
- const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
930
- if (anyBlocked)
931
- return {
932
- decision: "review",
933
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`,
934
- tier: 5
935
- };
936
- const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
937
- if (allAllowed) return { decision: "allow" };
938
- }
939
- return {
940
- decision: "review",
941
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`,
942
- tier: 5
943
- };
944
- }
945
- }
946
1226
  let matchedDangerousWord;
947
1227
  const isDangerous = allTokens.some(
948
1228
  (token) => config.policy.dangerousWords.some((word) => {
@@ -1000,9 +1280,9 @@ var DAEMON_PORT = 7391;
1000
1280
  var DAEMON_HOST = "127.0.0.1";
1001
1281
  function isDaemonRunning() {
1002
1282
  try {
1003
- const pidFile = path3.join(os.homedir(), ".node9", "daemon.pid");
1004
- if (!fs.existsSync(pidFile)) return false;
1005
- const { pid, port } = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
1283
+ const pidFile = path4.join(os2.homedir(), ".node9", "daemon.pid");
1284
+ if (!fs2.existsSync(pidFile)) return false;
1285
+ const { pid, port } = JSON.parse(fs2.readFileSync(pidFile, "utf-8"));
1006
1286
  if (port !== DAEMON_PORT) return false;
1007
1287
  process.kill(pid, 0);
1008
1288
  return true;
@@ -1012,16 +1292,16 @@ function isDaemonRunning() {
1012
1292
  }
1013
1293
  function getPersistentDecision(toolName) {
1014
1294
  try {
1015
- const file = path3.join(os.homedir(), ".node9", "decisions.json");
1016
- if (!fs.existsSync(file)) return null;
1017
- const decisions = JSON.parse(fs.readFileSync(file, "utf-8"));
1295
+ const file = path4.join(os2.homedir(), ".node9", "decisions.json");
1296
+ if (!fs2.existsSync(file)) return null;
1297
+ const decisions = JSON.parse(fs2.readFileSync(file, "utf-8"));
1018
1298
  const d = decisions[toolName];
1019
1299
  if (d === "allow" || d === "deny") return d;
1020
1300
  } catch {
1021
1301
  }
1022
1302
  return null;
1023
1303
  }
1024
- async function askDaemon(toolName, args, meta, signal, riskMetadata) {
1304
+ async function askDaemon(toolName, args, meta, signal, riskMetadata, activityId) {
1025
1305
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1026
1306
  const checkCtrl = new AbortController();
1027
1307
  const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
@@ -1036,6 +1316,12 @@ async function askDaemon(toolName, args, meta, signal, riskMetadata) {
1036
1316
  args,
1037
1317
  agent: meta?.agent,
1038
1318
  mcpServer: meta?.mcpServer,
1319
+ fromCLI: true,
1320
+ // Pass the flight-recorder ID so the daemon uses the same UUID for
1321
+ // activity-result as the CLI used for the pending activity event.
1322
+ // Without this, the two UUIDs never match and tail.ts never resolves
1323
+ // the pending item.
1324
+ activityId,
1039
1325
  ...riskMetadata && { riskMetadata }
1040
1326
  }),
1041
1327
  signal: checkCtrl.signal
@@ -1090,7 +1376,45 @@ async function resolveViaDaemon(id, decision, internalToken) {
1090
1376
  signal: AbortSignal.timeout(3e3)
1091
1377
  });
1092
1378
  }
1379
+ var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path4.join(os2.tmpdir(), "node9-activity.sock");
1380
+ function notifyActivity(data) {
1381
+ return new Promise((resolve) => {
1382
+ try {
1383
+ const payload = JSON.stringify(data);
1384
+ const sock = net.createConnection(ACTIVITY_SOCKET_PATH);
1385
+ sock.on("connect", () => {
1386
+ sock.on("close", resolve);
1387
+ sock.end(payload);
1388
+ });
1389
+ sock.on("error", resolve);
1390
+ } catch {
1391
+ resolve();
1392
+ }
1393
+ });
1394
+ }
1093
1395
  async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta, options) {
1396
+ if (!options?.calledFromDaemon) {
1397
+ const actId = randomUUID();
1398
+ const actTs = Date.now();
1399
+ await notifyActivity({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
1400
+ const result = await _authorizeHeadlessCore(toolName, args, allowTerminalFallback, meta, {
1401
+ ...options,
1402
+ activityId: actId
1403
+ });
1404
+ if (!result.noApprovalMechanism) {
1405
+ await notifyActivity({
1406
+ id: actId,
1407
+ tool: toolName,
1408
+ ts: actTs,
1409
+ status: result.approved ? "allow" : result.blockedByLabel?.includes("DLP") ? "dlp" : "block",
1410
+ label: result.blockedByLabel
1411
+ });
1412
+ }
1413
+ return result;
1414
+ }
1415
+ return _authorizeHeadlessCore(toolName, args, allowTerminalFallback, meta, options);
1416
+ }
1417
+ async function _authorizeHeadlessCore(toolName, args, allowTerminalFallback = false, meta, options) {
1094
1418
  if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
1095
1419
  const pauseState = checkPause();
1096
1420
  if (pauseState.paused) return { approved: true, checkedBy: "paused" };
@@ -1113,6 +1437,23 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1113
1437
  let policyMatchedField;
1114
1438
  let policyMatchedWord;
1115
1439
  let riskMetadata;
1440
+ if (config.policy.dlp.enabled && (!isIgnoredTool(toolName) || config.policy.dlp.scanIgnoredTools)) {
1441
+ const dlpMatch = scanArgs(args);
1442
+ if (dlpMatch) {
1443
+ const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
1444
+ if (dlpMatch.severity === "block") {
1445
+ if (!isManual) appendLocalAudit(toolName, args, "deny", "dlp-block", meta);
1446
+ return {
1447
+ approved: false,
1448
+ reason: dlpReason,
1449
+ blockedBy: "local-config",
1450
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Secret Detected)"
1451
+ };
1452
+ }
1453
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "dlp-review-flagged", meta);
1454
+ explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
1455
+ }
1456
+ }
1116
1457
  if (config.settings.mode === "audit") {
1117
1458
  if (!isIgnoredTool(toolName)) {
1118
1459
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
@@ -1332,7 +1673,14 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1332
1673
  console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
1333
1674
  `));
1334
1675
  }
1335
- const daemonDecision = await askDaemon(toolName, args, meta, signal, riskMetadata);
1676
+ const daemonDecision = await askDaemon(
1677
+ toolName,
1678
+ args,
1679
+ meta,
1680
+ signal,
1681
+ riskMetadata,
1682
+ options?.activityId
1683
+ );
1336
1684
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
1337
1685
  const isApproved = daemonDecision === "allow";
1338
1686
  return {
@@ -1352,7 +1700,14 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1352
1700
  racePromises.push(
1353
1701
  (async () => {
1354
1702
  try {
1355
- console.log(chalk2.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
1703
+ if (explainableLabel.includes("DLP")) {
1704
+ console.log(chalk2.bgRed.white.bold(` \u{1F6A8} NODE9 DLP ALERT \u2014 CREDENTIAL DETECTED `));
1705
+ console.log(
1706
+ chalk2.red.bold(` A sensitive secret was detected in the tool arguments!`)
1707
+ );
1708
+ } else {
1709
+ console.log(chalk2.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
1710
+ }
1356
1711
  console.log(`${chalk2.bold("Action:")} ${chalk2.red(toolName)}`);
1357
1712
  console.log(`${chalk2.bold("Flagged By:")} ${chalk2.yellow(explainableLabel)}`);
1358
1713
  if (isRemoteLocked) {
@@ -1457,8 +1812,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
1457
1812
  }
1458
1813
  function getConfig() {
1459
1814
  if (cachedConfig) return cachedConfig;
1460
- const globalPath = path3.join(os.homedir(), ".node9", "config.json");
1461
- const projectPath = path3.join(process.cwd(), "node9.config.json");
1815
+ const globalPath = path4.join(os2.homedir(), ".node9", "config.json");
1816
+ const projectPath = path4.join(process.cwd(), "node9.config.json");
1462
1817
  const globalConfig = tryLoadConfig(globalPath);
1463
1818
  const projectConfig = tryLoadConfig(projectPath);
1464
1819
  const mergedSettings = {
@@ -1470,13 +1825,13 @@ function getConfig() {
1470
1825
  dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
1471
1826
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
1472
1827
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
1473
- rules: [...DEFAULT_CONFIG.policy.rules],
1474
1828
  smartRules: [...DEFAULT_CONFIG.policy.smartRules],
1475
1829
  snapshot: {
1476
1830
  tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
1477
1831
  onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
1478
1832
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
1479
- }
1833
+ },
1834
+ dlp: { ...DEFAULT_CONFIG.policy.dlp }
1480
1835
  };
1481
1836
  const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
1482
1837
  const applyLayer = (source) => {
@@ -1496,7 +1851,6 @@ function getConfig() {
1496
1851
  if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
1497
1852
  if (p.toolInspection)
1498
1853
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1499
- if (p.rules) mergedPolicy.rules.push(...p.rules);
1500
1854
  if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1501
1855
  if (p.snapshot) {
1502
1856
  const s2 = p.snapshot;
@@ -1504,6 +1858,11 @@ function getConfig() {
1504
1858
  if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
1505
1859
  if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
1506
1860
  }
1861
+ if (p.dlp) {
1862
+ const d = p.dlp;
1863
+ if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
1864
+ if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
1865
+ }
1507
1866
  const envs = source.environments || {};
1508
1867
  for (const [envName, envConfig] of Object.entries(envs)) {
1509
1868
  if (envConfig && typeof envConfig === "object") {
@@ -1518,6 +1877,22 @@ function getConfig() {
1518
1877
  };
1519
1878
  applyLayer(globalConfig);
1520
1879
  applyLayer(projectConfig);
1880
+ for (const shieldName of readActiveShields()) {
1881
+ const shield = getShield(shieldName);
1882
+ if (!shield) continue;
1883
+ const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
1884
+ for (const rule of shield.smartRules) {
1885
+ if (!existingRuleNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
1886
+ }
1887
+ const existingWords = new Set(mergedPolicy.dangerousWords);
1888
+ for (const word of shield.dangerousWords) {
1889
+ if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
1890
+ }
1891
+ }
1892
+ const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
1893
+ for (const rule of ADVISORY_SMART_RULES) {
1894
+ if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
1895
+ }
1521
1896
  if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
1522
1897
  mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
1523
1898
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
@@ -1533,10 +1908,10 @@ function getConfig() {
1533
1908
  return cachedConfig;
1534
1909
  }
1535
1910
  function tryLoadConfig(filePath) {
1536
- if (!fs.existsSync(filePath)) return null;
1911
+ if (!fs2.existsSync(filePath)) return null;
1537
1912
  let raw;
1538
1913
  try {
1539
- raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1914
+ raw = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
1540
1915
  } catch (err) {
1541
1916
  const msg = err instanceof Error ? err.message : String(err);
1542
1917
  process.stderr.write(
@@ -1598,9 +1973,9 @@ function getCredentials() {
1598
1973
  };
1599
1974
  }
1600
1975
  try {
1601
- const credPath = path3.join(os.homedir(), ".node9", "credentials.json");
1602
- if (fs.existsSync(credPath)) {
1603
- const creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
1976
+ const credPath = path4.join(os2.homedir(), ".node9", "credentials.json");
1977
+ if (fs2.existsSync(credPath)) {
1978
+ const creds = JSON.parse(fs2.readFileSync(credPath, "utf-8"));
1604
1979
  const profileName = process.env.NODE9_PROFILE || "default";
1605
1980
  const profile = creds[profileName];
1606
1981
  if (profile?.apiKey) {
@@ -1635,9 +2010,9 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1635
2010
  context: {
1636
2011
  agent: meta?.agent,
1637
2012
  mcpServer: meta?.mcpServer,
1638
- hostname: os.hostname(),
2013
+ hostname: os2.hostname(),
1639
2014
  cwd: process.cwd(),
1640
- platform: os.platform()
2015
+ platform: os2.platform()
1641
2016
  }
1642
2017
  }),
1643
2018
  signal: AbortSignal.timeout(5e3)
@@ -1658,9 +2033,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
1658
2033
  context: {
1659
2034
  agent: meta?.agent,
1660
2035
  mcpServer: meta?.mcpServer,
1661
- hostname: os.hostname(),
2036
+ hostname: os2.hostname(),
1662
2037
  cwd: process.cwd(),
1663
- platform: os.platform()
2038
+ platform: os2.platform()
1664
2039
  },
1665
2040
  ...riskMetadata && { riskMetadata }
1666
2041
  }),