@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.js CHANGED
@@ -37,9 +37,11 @@ module.exports = __toCommonJS(src_exports);
37
37
  // src/core.ts
38
38
  var import_chalk2 = __toESM(require("chalk"));
39
39
  var import_prompts = require("@inquirer/prompts");
40
- var import_fs = __toESM(require("fs"));
41
- var import_path3 = __toESM(require("path"));
42
- var import_os = __toESM(require("os"));
40
+ var import_fs2 = __toESM(require("fs"));
41
+ var import_path4 = __toESM(require("path"));
42
+ var import_os2 = __toESM(require("os"));
43
+ var import_net = __toESM(require("net"));
44
+ var import_crypto = require("crypto");
43
45
  var import_picomatch = __toESM(require("picomatch"));
44
46
  var import_sh_syntax = require("sh-syntax");
45
47
 
@@ -369,25 +371,26 @@ var import_zod = require("zod");
369
371
  var noNewlines = import_zod.z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
370
372
  message: "Value must not contain literal newline characters (use \\n instead)"
371
373
  });
372
- var validRegex = noNewlines.refine(
373
- (s) => {
374
- try {
375
- new RegExp(s);
376
- return true;
377
- } catch {
378
- return false;
379
- }
380
- },
381
- { message: "Value must be a valid regular expression" }
382
- );
383
374
  var SmartConditionSchema = import_zod.z.object({
384
375
  field: import_zod.z.string().min(1, "Condition field must not be empty"),
385
- op: import_zod.z.enum(["matches", "notMatches", "contains", "notContains", "exists", "notExists"], {
386
- errorMap: () => ({
387
- message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists"
388
- })
389
- }),
390
- value: validRegex.optional(),
376
+ op: import_zod.z.enum(
377
+ [
378
+ "matches",
379
+ "notMatches",
380
+ "contains",
381
+ "notContains",
382
+ "exists",
383
+ "notExists",
384
+ "matchesGlob",
385
+ "notMatchesGlob"
386
+ ],
387
+ {
388
+ errorMap: () => ({
389
+ message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists, matchesGlob, notMatchesGlob"
390
+ })
391
+ }
392
+ ),
393
+ value: import_zod.z.string().optional(),
391
394
  flags: import_zod.z.string().optional()
392
395
  });
393
396
  var SmartRuleSchema = import_zod.z.object({
@@ -400,11 +403,6 @@ var SmartRuleSchema = import_zod.z.object({
400
403
  }),
401
404
  reason: import_zod.z.string().optional()
402
405
  });
403
- var PolicyRuleSchema = import_zod.z.object({
404
- action: import_zod.z.string().min(1),
405
- allowPaths: import_zod.z.array(import_zod.z.string()).optional(),
406
- blockPaths: import_zod.z.array(import_zod.z.string()).optional()
407
- });
408
406
  var ConfigFileSchema = import_zod.z.object({
409
407
  version: import_zod.z.string().optional(),
410
408
  settings: import_zod.z.object({
@@ -429,12 +427,15 @@ var ConfigFileSchema = import_zod.z.object({
429
427
  dangerousWords: import_zod.z.array(noNewlines).optional(),
430
428
  ignoredTools: import_zod.z.array(import_zod.z.string()).optional(),
431
429
  toolInspection: import_zod.z.record(import_zod.z.string()).optional(),
432
- rules: import_zod.z.array(PolicyRuleSchema).optional(),
433
430
  smartRules: import_zod.z.array(SmartRuleSchema).optional(),
434
431
  snapshot: import_zod.z.object({
435
432
  tools: import_zod.z.array(import_zod.z.string()).optional(),
436
433
  onlyPaths: import_zod.z.array(import_zod.z.string()).optional(),
437
434
  ignorePaths: import_zod.z.array(import_zod.z.string()).optional()
435
+ }).optional(),
436
+ dlp: import_zod.z.object({
437
+ enabled: import_zod.z.boolean().optional(),
438
+ scanIgnoredTools: import_zod.z.boolean().optional()
438
439
  }).optional()
439
440
  }).optional(),
440
441
  environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
@@ -456,8 +457,8 @@ function sanitizeConfig(raw) {
456
457
  }
457
458
  }
458
459
  const lines = result.error.issues.map((issue) => {
459
- const path4 = issue.path.length > 0 ? issue.path.join(".") : "root";
460
- return ` \u2022 ${path4}: ${issue.message}`;
460
+ const path5 = issue.path.length > 0 ? issue.path.join(".") : "root";
461
+ return ` \u2022 ${path5}: ${issue.message}`;
461
462
  });
462
463
  return {
463
464
  sanitized,
@@ -466,18 +467,291 @@ ${lines.join("\n")}`
466
467
  };
467
468
  }
468
469
 
470
+ // src/shields.ts
471
+ var import_fs = __toESM(require("fs"));
472
+ var import_path3 = __toESM(require("path"));
473
+ var import_os = __toESM(require("os"));
474
+ var SHIELDS = {
475
+ postgres: {
476
+ name: "postgres",
477
+ description: "Protects PostgreSQL databases from destructive AI operations",
478
+ aliases: ["pg", "postgresql"],
479
+ smartRules: [
480
+ {
481
+ name: "shield:postgres:block-drop-table",
482
+ tool: "*",
483
+ conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
484
+ verdict: "block",
485
+ reason: "DROP TABLE is irreversible \u2014 blocked by Postgres shield"
486
+ },
487
+ {
488
+ name: "shield:postgres:block-truncate",
489
+ tool: "*",
490
+ conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
491
+ verdict: "block",
492
+ reason: "TRUNCATE is irreversible \u2014 blocked by Postgres shield"
493
+ },
494
+ {
495
+ name: "shield:postgres:block-drop-column",
496
+ tool: "*",
497
+ conditions: [
498
+ { field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
499
+ ],
500
+ verdict: "block",
501
+ reason: "DROP COLUMN is irreversible \u2014 blocked by Postgres shield"
502
+ },
503
+ {
504
+ name: "shield:postgres:review-grant-revoke",
505
+ tool: "*",
506
+ conditions: [{ field: "sql", op: "matches", value: "\\b(GRANT|REVOKE)\\b", flags: "i" }],
507
+ verdict: "review",
508
+ reason: "Permission changes require human approval (Postgres shield)"
509
+ }
510
+ ],
511
+ dangerousWords: ["dropdb", "pg_dropcluster"]
512
+ },
513
+ github: {
514
+ name: "github",
515
+ description: "Protects GitHub repositories from destructive AI operations",
516
+ aliases: ["git"],
517
+ smartRules: [
518
+ {
519
+ // Note: git branch -d/-D is already caught by the built-in review-git-destructive rule.
520
+ // This rule adds coverage for `git push --delete` which the built-in does not match.
521
+ name: "shield:github:review-delete-branch-remote",
522
+ tool: "bash",
523
+ conditions: [
524
+ {
525
+ field: "command",
526
+ op: "matches",
527
+ value: "git\\s+push\\s+.*--delete",
528
+ flags: "i"
529
+ }
530
+ ],
531
+ verdict: "review",
532
+ reason: "Remote branch deletion requires human approval (GitHub shield)"
533
+ },
534
+ {
535
+ name: "shield:github:block-delete-repo",
536
+ tool: "*",
537
+ conditions: [
538
+ { field: "command", op: "matches", value: "gh\\s+repo\\s+delete", flags: "i" }
539
+ ],
540
+ verdict: "block",
541
+ reason: "Repository deletion is irreversible \u2014 blocked by GitHub shield"
542
+ }
543
+ ],
544
+ dangerousWords: []
545
+ },
546
+ aws: {
547
+ name: "aws",
548
+ description: "Protects AWS infrastructure from destructive AI operations",
549
+ aliases: ["amazon"],
550
+ smartRules: [
551
+ {
552
+ name: "shield:aws:block-delete-s3-bucket",
553
+ tool: "*",
554
+ conditions: [
555
+ {
556
+ field: "command",
557
+ op: "matches",
558
+ value: "aws\\s+s3.*rb\\s|aws\\s+s3api\\s+delete-bucket",
559
+ flags: "i"
560
+ }
561
+ ],
562
+ verdict: "block",
563
+ reason: "S3 bucket deletion is irreversible \u2014 blocked by AWS shield"
564
+ },
565
+ {
566
+ name: "shield:aws:review-iam-changes",
567
+ tool: "*",
568
+ conditions: [
569
+ {
570
+ field: "command",
571
+ op: "matches",
572
+ value: "aws\\s+iam\\s+(create|delete|attach|detach|put|remove)",
573
+ flags: "i"
574
+ }
575
+ ],
576
+ verdict: "review",
577
+ reason: "IAM changes require human approval (AWS shield)"
578
+ },
579
+ {
580
+ name: "shield:aws:block-ec2-terminate",
581
+ tool: "*",
582
+ conditions: [
583
+ {
584
+ field: "command",
585
+ op: "matches",
586
+ value: "aws\\s+ec2\\s+terminate-instances",
587
+ flags: "i"
588
+ }
589
+ ],
590
+ verdict: "block",
591
+ reason: "EC2 instance termination is irreversible \u2014 blocked by AWS shield"
592
+ },
593
+ {
594
+ name: "shield:aws:review-rds-delete",
595
+ tool: "*",
596
+ conditions: [
597
+ { field: "command", op: "matches", value: "aws\\s+rds\\s+delete-", flags: "i" }
598
+ ],
599
+ verdict: "review",
600
+ reason: "RDS deletion requires human approval (AWS shield)"
601
+ }
602
+ ],
603
+ dangerousWords: []
604
+ },
605
+ filesystem: {
606
+ name: "filesystem",
607
+ description: "Protects the local filesystem from dangerous AI operations",
608
+ aliases: ["fs"],
609
+ smartRules: [
610
+ {
611
+ name: "shield:filesystem:review-chmod-777",
612
+ tool: "bash",
613
+ conditions: [
614
+ { field: "command", op: "matches", value: "chmod\\s+(777|a\\+rwx)", flags: "i" }
615
+ ],
616
+ verdict: "review",
617
+ reason: "chmod 777 requires human approval (filesystem shield)"
618
+ },
619
+ {
620
+ name: "shield:filesystem:review-write-etc",
621
+ tool: "bash",
622
+ conditions: [
623
+ {
624
+ field: "command",
625
+ // Narrow to write-indicative operations to avoid approval fatigue on reads.
626
+ // Matches: tee /etc/*, cp .../etc/*, mv .../etc/*, > /etc/*, install .../etc/*
627
+ op: "matches",
628
+ value: "(tee|\\bcp\\b|\\bmv\\b|install|>+)\\s+.*\\/etc\\/"
629
+ }
630
+ ],
631
+ verdict: "review",
632
+ reason: "Writing to /etc requires human approval (filesystem shield)"
633
+ }
634
+ ],
635
+ // dd removed: too common as a legitimate tool (disk imaging, file ops).
636
+ // mkfs removed: already in the built-in DANGEROUS_WORDS baseline.
637
+ // wipefs retained: rarely legitimate in an agent context and not in built-ins.
638
+ dangerousWords: ["wipefs"]
639
+ }
640
+ };
641
+ function resolveShieldName(input) {
642
+ const lower = input.toLowerCase();
643
+ if (SHIELDS[lower]) return lower;
644
+ for (const [name, def] of Object.entries(SHIELDS)) {
645
+ if (def.aliases.includes(lower)) return name;
646
+ }
647
+ return null;
648
+ }
649
+ function getShield(name) {
650
+ const resolved = resolveShieldName(name);
651
+ return resolved ? SHIELDS[resolved] : null;
652
+ }
653
+ var SHIELDS_STATE_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "shields.json");
654
+ function readActiveShields() {
655
+ try {
656
+ const raw = import_fs.default.readFileSync(SHIELDS_STATE_FILE, "utf-8");
657
+ if (!raw.trim()) return [];
658
+ const parsed = JSON.parse(raw);
659
+ if (Array.isArray(parsed.active)) {
660
+ return parsed.active.filter(
661
+ (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
662
+ );
663
+ }
664
+ } catch (err) {
665
+ if (err.code !== "ENOENT") {
666
+ process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
667
+ `);
668
+ }
669
+ }
670
+ return [];
671
+ }
672
+
673
+ // src/dlp.ts
674
+ var DLP_PATTERNS = [
675
+ { name: "AWS Access Key ID", regex: /\bAKIA[0-9A-Z]{16}\b/, severity: "block" },
676
+ { name: "GitHub Token", regex: /\bgh[pous]_[A-Za-z0-9]{36}\b/, severity: "block" },
677
+ { name: "Slack Bot Token", regex: /\bxoxb-[0-9A-Za-z-]+\b/, severity: "block" },
678
+ { name: "OpenAI API Key", regex: /\bsk-[a-zA-Z0-9_-]{20,}\b/, severity: "block" },
679
+ { name: "Stripe Secret Key", regex: /\bsk_(?:live|test)_[0-9a-zA-Z]{24}\b/, severity: "block" },
680
+ {
681
+ name: "Private Key (PEM)",
682
+ regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
683
+ severity: "block"
684
+ },
685
+ { name: "Bearer Token", regex: /Bearer\s+[a-zA-Z0-9\-._~+/]+=*/i, severity: "review" }
686
+ ];
687
+ function maskSecret(raw, pattern) {
688
+ const match = raw.match(pattern);
689
+ if (!match) return "****";
690
+ const secret = match[0];
691
+ if (secret.length < 8) return "****";
692
+ const prefix = secret.slice(0, 4);
693
+ const suffix = secret.slice(-4);
694
+ const stars = "*".repeat(Math.min(secret.length - 8, 12));
695
+ return `${prefix}${stars}${suffix}`;
696
+ }
697
+ var MAX_DEPTH = 5;
698
+ var MAX_STRING_BYTES = 1e5;
699
+ var MAX_JSON_PARSE_BYTES = 1e4;
700
+ function scanArgs(args, depth = 0, fieldPath = "args") {
701
+ if (depth > MAX_DEPTH || args === null || args === void 0) return null;
702
+ if (Array.isArray(args)) {
703
+ for (let i = 0; i < args.length; i++) {
704
+ const match = scanArgs(args[i], depth + 1, `${fieldPath}[${i}]`);
705
+ if (match) return match;
706
+ }
707
+ return null;
708
+ }
709
+ if (typeof args === "object") {
710
+ for (const [key, value] of Object.entries(args)) {
711
+ const match = scanArgs(value, depth + 1, `${fieldPath}.${key}`);
712
+ if (match) return match;
713
+ }
714
+ return null;
715
+ }
716
+ if (typeof args === "string") {
717
+ const text = args.length > MAX_STRING_BYTES ? args.slice(0, MAX_STRING_BYTES) : args;
718
+ for (const pattern of DLP_PATTERNS) {
719
+ if (pattern.regex.test(text)) {
720
+ return {
721
+ patternName: pattern.name,
722
+ fieldPath,
723
+ redactedSample: maskSecret(text, pattern.regex),
724
+ severity: pattern.severity
725
+ };
726
+ }
727
+ }
728
+ if (text.length < MAX_JSON_PARSE_BYTES) {
729
+ const trimmed = text.trim();
730
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
731
+ try {
732
+ const parsed = JSON.parse(text);
733
+ const inner = scanArgs(parsed, depth + 1, fieldPath);
734
+ if (inner) return inner;
735
+ } catch {
736
+ }
737
+ }
738
+ }
739
+ }
740
+ return null;
741
+ }
742
+
469
743
  // src/core.ts
470
- var PAUSED_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "PAUSED");
471
- var TRUST_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "trust.json");
472
- var LOCAL_AUDIT_LOG = import_path3.default.join(import_os.default.homedir(), ".node9", "audit.log");
473
- var HOOK_DEBUG_LOG = import_path3.default.join(import_os.default.homedir(), ".node9", "hook-debug.log");
744
+ var PAUSED_FILE = import_path4.default.join(import_os2.default.homedir(), ".node9", "PAUSED");
745
+ var TRUST_FILE = import_path4.default.join(import_os2.default.homedir(), ".node9", "trust.json");
746
+ var LOCAL_AUDIT_LOG = import_path4.default.join(import_os2.default.homedir(), ".node9", "audit.log");
747
+ var HOOK_DEBUG_LOG = import_path4.default.join(import_os2.default.homedir(), ".node9", "hook-debug.log");
474
748
  function checkPause() {
475
749
  try {
476
- if (!import_fs.default.existsSync(PAUSED_FILE)) return { paused: false };
477
- const state = JSON.parse(import_fs.default.readFileSync(PAUSED_FILE, "utf-8"));
750
+ if (!import_fs2.default.existsSync(PAUSED_FILE)) return { paused: false };
751
+ const state = JSON.parse(import_fs2.default.readFileSync(PAUSED_FILE, "utf-8"));
478
752
  if (state.expiry > 0 && Date.now() >= state.expiry) {
479
753
  try {
480
- import_fs.default.unlinkSync(PAUSED_FILE);
754
+ import_fs2.default.unlinkSync(PAUSED_FILE);
481
755
  } catch {
482
756
  }
483
757
  return { paused: false };
@@ -488,20 +762,20 @@ function checkPause() {
488
762
  }
489
763
  }
490
764
  function atomicWriteSync(filePath, data, options) {
491
- const dir = import_path3.default.dirname(filePath);
492
- if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
493
- const tmpPath = `${filePath}.${import_os.default.hostname()}.${process.pid}.tmp`;
494
- import_fs.default.writeFileSync(tmpPath, data, options);
495
- import_fs.default.renameSync(tmpPath, filePath);
765
+ const dir = import_path4.default.dirname(filePath);
766
+ if (!import_fs2.default.existsSync(dir)) import_fs2.default.mkdirSync(dir, { recursive: true });
767
+ const tmpPath = `${filePath}.${import_os2.default.hostname()}.${process.pid}.tmp`;
768
+ import_fs2.default.writeFileSync(tmpPath, data, options);
769
+ import_fs2.default.renameSync(tmpPath, filePath);
496
770
  }
497
771
  function getActiveTrustSession(toolName) {
498
772
  try {
499
- if (!import_fs.default.existsSync(TRUST_FILE)) return false;
500
- const trust = JSON.parse(import_fs.default.readFileSync(TRUST_FILE, "utf-8"));
773
+ if (!import_fs2.default.existsSync(TRUST_FILE)) return false;
774
+ const trust = JSON.parse(import_fs2.default.readFileSync(TRUST_FILE, "utf-8"));
501
775
  const now = Date.now();
502
776
  const active = trust.entries.filter((e) => e.expiry > now);
503
777
  if (active.length !== trust.entries.length) {
504
- import_fs.default.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
778
+ import_fs2.default.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
505
779
  }
506
780
  return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
507
781
  } catch {
@@ -512,8 +786,8 @@ function writeTrustSession(toolName, durationMs) {
512
786
  try {
513
787
  let trust = { entries: [] };
514
788
  try {
515
- if (import_fs.default.existsSync(TRUST_FILE)) {
516
- trust = JSON.parse(import_fs.default.readFileSync(TRUST_FILE, "utf-8"));
789
+ if (import_fs2.default.existsSync(TRUST_FILE)) {
790
+ trust = JSON.parse(import_fs2.default.readFileSync(TRUST_FILE, "utf-8"));
517
791
  }
518
792
  } catch {
519
793
  }
@@ -529,9 +803,9 @@ function writeTrustSession(toolName, durationMs) {
529
803
  }
530
804
  function appendToLog(logPath, entry) {
531
805
  try {
532
- const dir = import_path3.default.dirname(logPath);
533
- if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
534
- import_fs.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
806
+ const dir = import_path4.default.dirname(logPath);
807
+ if (!import_fs2.default.existsSync(dir)) import_fs2.default.mkdirSync(dir, { recursive: true });
808
+ import_fs2.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
535
809
  } catch {
536
810
  }
537
811
  }
@@ -543,7 +817,7 @@ function appendHookDebug(toolName, args, meta) {
543
817
  args: safeArgs,
544
818
  agent: meta?.agent,
545
819
  mcpServer: meta?.mcpServer,
546
- hostname: import_os.default.hostname(),
820
+ hostname: import_os2.default.hostname(),
547
821
  cwd: process.cwd()
548
822
  });
549
823
  }
@@ -557,7 +831,7 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
557
831
  checkedBy,
558
832
  agent: meta?.agent,
559
833
  mcpServer: meta?.mcpServer,
560
- hostname: import_os.default.hostname()
834
+ hostname: import_os2.default.hostname()
561
835
  });
562
836
  }
563
837
  function tokenize(toolName) {
@@ -573,9 +847,9 @@ function matchesPattern(text, patterns) {
573
847
  const withoutDotSlash = text.replace(/^\.\//, "");
574
848
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
575
849
  }
576
- function getNestedValue(obj, path4) {
850
+ function getNestedValue(obj, path5) {
577
851
  if (!obj || typeof obj !== "object") return null;
578
- return path4.split(".").reduce((prev, curr) => prev?.[curr], obj);
852
+ return path5.split(".").reduce((prev, curr) => prev?.[curr], obj);
579
853
  }
580
854
  function evaluateSmartConditions(args, rule) {
581
855
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -608,6 +882,10 @@ function evaluateSmartConditions(args, rule) {
608
882
  return true;
609
883
  }
610
884
  }
885
+ case "matchesGlob":
886
+ return val !== null && cond.value ? import_picomatch.default.isMatch(val, cond.value) : false;
887
+ case "notMatchesGlob":
888
+ return val !== null && cond.value ? !import_picomatch.default.isMatch(val, cond.value) : true;
611
889
  default:
612
890
  return false;
613
891
  }
@@ -771,25 +1049,27 @@ var DEFAULT_CONFIG = {
771
1049
  onlyPaths: [],
772
1050
  ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
773
1051
  },
774
- rules: [
775
- // Only use the legacy rules format for simple path-based rm control.
776
- // All other command-level enforcement lives in smartRules below.
777
- {
778
- action: "rm",
779
- allowPaths: [
780
- "**/node_modules/**",
781
- "dist/**",
782
- "build/**",
783
- ".next/**",
784
- "coverage/**",
785
- ".cache/**",
786
- "tmp/**",
787
- "temp/**",
788
- ".DS_Store"
789
- ]
790
- }
791
- ],
792
1052
  smartRules: [
1053
+ // ── rm safety (critical — always evaluated first) ──────────────────────
1054
+ {
1055
+ name: "block-rm-rf-home",
1056
+ tool: "bash",
1057
+ conditionMode: "all",
1058
+ conditions: [
1059
+ {
1060
+ field: "command",
1061
+ op: "matches",
1062
+ value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
1063
+ },
1064
+ {
1065
+ field: "command",
1066
+ op: "matches",
1067
+ value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
1068
+ }
1069
+ ],
1070
+ verdict: "block",
1071
+ reason: "Recursive delete of home directory is irreversible"
1072
+ },
793
1073
  // ── SQL safety ────────────────────────────────────────────────────────
794
1074
  {
795
1075
  name: "no-delete-without-where",
@@ -880,16 +1160,42 @@ var DEFAULT_CONFIG = {
880
1160
  verdict: "block",
881
1161
  reason: "Piping remote script into a shell is a supply-chain attack vector"
882
1162
  }
883
- ]
1163
+ ],
1164
+ dlp: { enabled: true, scanIgnoredTools: true }
884
1165
  },
885
1166
  environments: {}
886
1167
  };
1168
+ var ADVISORY_SMART_RULES = [
1169
+ {
1170
+ name: "allow-rm-safe-paths",
1171
+ tool: "*",
1172
+ conditionMode: "all",
1173
+ conditions: [
1174
+ { field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" },
1175
+ {
1176
+ field: "command",
1177
+ op: "matches",
1178
+ // Matches known-safe build artifact paths in the command.
1179
+ value: "(node_modules|\\bdist\\b|\\.next|\\bcoverage\\b|\\.cache|\\btmp\\b|\\btemp\\b|\\.DS_Store)(\\/|\\s|$)"
1180
+ }
1181
+ ],
1182
+ verdict: "allow",
1183
+ reason: "Deleting a known-safe build artifact path"
1184
+ },
1185
+ {
1186
+ name: "review-rm",
1187
+ tool: "*",
1188
+ conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
1189
+ verdict: "review",
1190
+ reason: "rm can permanently delete files \u2014 confirm the target path"
1191
+ }
1192
+ ];
887
1193
  var cachedConfig = null;
888
1194
  function getInternalToken() {
889
1195
  try {
890
- const pidFile = import_path3.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
891
- if (!import_fs.default.existsSync(pidFile)) return null;
892
- const data = JSON.parse(import_fs.default.readFileSync(pidFile, "utf-8"));
1196
+ const pidFile = import_path4.default.join(import_os2.default.homedir(), ".node9", "daemon.pid");
1197
+ if (!import_fs2.default.existsSync(pidFile)) return null;
1198
+ const data = JSON.parse(import_fs2.default.readFileSync(pidFile, "utf-8"));
893
1199
  process.kill(data.pid, 0);
894
1200
  return data.internalToken ?? null;
895
1201
  } catch {
@@ -904,7 +1210,8 @@ async function evaluatePolicy(toolName, args, agent) {
904
1210
  (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
905
1211
  );
906
1212
  if (matchedRule) {
907
- if (matchedRule.verdict === "allow") return { decision: "allow" };
1213
+ if (matchedRule.verdict === "allow")
1214
+ return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
908
1215
  return {
909
1216
  decision: matchedRule.verdict,
910
1217
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
@@ -915,13 +1222,11 @@ async function evaluatePolicy(toolName, args, agent) {
915
1222
  }
916
1223
  }
917
1224
  let allTokens = [];
918
- let actionTokens = [];
919
1225
  let pathTokens = [];
920
1226
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
921
1227
  if (shellCommand) {
922
1228
  const analyzed = await analyzeShellCommand(shellCommand);
923
1229
  allTokens = analyzed.allTokens;
924
- actionTokens = analyzed.actions;
925
1230
  pathTokens = analyzed.paths;
926
1231
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
927
1232
  if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
@@ -929,11 +1234,9 @@ async function evaluatePolicy(toolName, args, agent) {
929
1234
  }
930
1235
  if (isSqlTool(toolName, config.policy.toolInspection)) {
931
1236
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
932
- actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
933
1237
  }
934
1238
  } else {
935
1239
  allTokens = tokenize(toolName);
936
- actionTokens = [toolName];
937
1240
  if (args && typeof args === "object") {
938
1241
  const flattenedArgs = JSON.stringify(args).toLowerCase();
939
1242
  const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
@@ -956,29 +1259,6 @@ async function evaluatePolicy(toolName, args, agent) {
956
1259
  const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
957
1260
  if (allInSandbox) return { decision: "allow" };
958
1261
  }
959
- for (const action of actionTokens) {
960
- const rule = config.policy.rules.find(
961
- (r) => r.action === action || matchesPattern(action, r.action)
962
- );
963
- if (rule) {
964
- if (pathTokens.length > 0) {
965
- const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
966
- if (anyBlocked)
967
- return {
968
- decision: "review",
969
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`,
970
- tier: 5
971
- };
972
- const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
973
- if (allAllowed) return { decision: "allow" };
974
- }
975
- return {
976
- decision: "review",
977
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`,
978
- tier: 5
979
- };
980
- }
981
- }
982
1262
  let matchedDangerousWord;
983
1263
  const isDangerous = allTokens.some(
984
1264
  (token) => config.policy.dangerousWords.some((word) => {
@@ -1036,9 +1316,9 @@ var DAEMON_PORT = 7391;
1036
1316
  var DAEMON_HOST = "127.0.0.1";
1037
1317
  function isDaemonRunning() {
1038
1318
  try {
1039
- const pidFile = import_path3.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
1040
- if (!import_fs.default.existsSync(pidFile)) return false;
1041
- const { pid, port } = JSON.parse(import_fs.default.readFileSync(pidFile, "utf-8"));
1319
+ const pidFile = import_path4.default.join(import_os2.default.homedir(), ".node9", "daemon.pid");
1320
+ if (!import_fs2.default.existsSync(pidFile)) return false;
1321
+ const { pid, port } = JSON.parse(import_fs2.default.readFileSync(pidFile, "utf-8"));
1042
1322
  if (port !== DAEMON_PORT) return false;
1043
1323
  process.kill(pid, 0);
1044
1324
  return true;
@@ -1048,16 +1328,16 @@ function isDaemonRunning() {
1048
1328
  }
1049
1329
  function getPersistentDecision(toolName) {
1050
1330
  try {
1051
- const file = import_path3.default.join(import_os.default.homedir(), ".node9", "decisions.json");
1052
- if (!import_fs.default.existsSync(file)) return null;
1053
- const decisions = JSON.parse(import_fs.default.readFileSync(file, "utf-8"));
1331
+ const file = import_path4.default.join(import_os2.default.homedir(), ".node9", "decisions.json");
1332
+ if (!import_fs2.default.existsSync(file)) return null;
1333
+ const decisions = JSON.parse(import_fs2.default.readFileSync(file, "utf-8"));
1054
1334
  const d = decisions[toolName];
1055
1335
  if (d === "allow" || d === "deny") return d;
1056
1336
  } catch {
1057
1337
  }
1058
1338
  return null;
1059
1339
  }
1060
- async function askDaemon(toolName, args, meta, signal, riskMetadata) {
1340
+ async function askDaemon(toolName, args, meta, signal, riskMetadata, activityId) {
1061
1341
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1062
1342
  const checkCtrl = new AbortController();
1063
1343
  const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
@@ -1072,6 +1352,12 @@ async function askDaemon(toolName, args, meta, signal, riskMetadata) {
1072
1352
  args,
1073
1353
  agent: meta?.agent,
1074
1354
  mcpServer: meta?.mcpServer,
1355
+ fromCLI: true,
1356
+ // Pass the flight-recorder ID so the daemon uses the same UUID for
1357
+ // activity-result as the CLI used for the pending activity event.
1358
+ // Without this, the two UUIDs never match and tail.ts never resolves
1359
+ // the pending item.
1360
+ activityId,
1075
1361
  ...riskMetadata && { riskMetadata }
1076
1362
  }),
1077
1363
  signal: checkCtrl.signal
@@ -1126,7 +1412,45 @@ async function resolveViaDaemon(id, decision, internalToken) {
1126
1412
  signal: AbortSignal.timeout(3e3)
1127
1413
  });
1128
1414
  }
1415
+ var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : import_path4.default.join(import_os2.default.tmpdir(), "node9-activity.sock");
1416
+ function notifyActivity(data) {
1417
+ return new Promise((resolve) => {
1418
+ try {
1419
+ const payload = JSON.stringify(data);
1420
+ const sock = import_net.default.createConnection(ACTIVITY_SOCKET_PATH);
1421
+ sock.on("connect", () => {
1422
+ sock.on("close", resolve);
1423
+ sock.end(payload);
1424
+ });
1425
+ sock.on("error", resolve);
1426
+ } catch {
1427
+ resolve();
1428
+ }
1429
+ });
1430
+ }
1129
1431
  async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta, options) {
1432
+ if (!options?.calledFromDaemon) {
1433
+ const actId = (0, import_crypto.randomUUID)();
1434
+ const actTs = Date.now();
1435
+ await notifyActivity({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
1436
+ const result = await _authorizeHeadlessCore(toolName, args, allowTerminalFallback, meta, {
1437
+ ...options,
1438
+ activityId: actId
1439
+ });
1440
+ if (!result.noApprovalMechanism) {
1441
+ await notifyActivity({
1442
+ id: actId,
1443
+ tool: toolName,
1444
+ ts: actTs,
1445
+ status: result.approved ? "allow" : result.blockedByLabel?.includes("DLP") ? "dlp" : "block",
1446
+ label: result.blockedByLabel
1447
+ });
1448
+ }
1449
+ return result;
1450
+ }
1451
+ return _authorizeHeadlessCore(toolName, args, allowTerminalFallback, meta, options);
1452
+ }
1453
+ async function _authorizeHeadlessCore(toolName, args, allowTerminalFallback = false, meta, options) {
1130
1454
  if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
1131
1455
  const pauseState = checkPause();
1132
1456
  if (pauseState.paused) return { approved: true, checkedBy: "paused" };
@@ -1149,6 +1473,23 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1149
1473
  let policyMatchedField;
1150
1474
  let policyMatchedWord;
1151
1475
  let riskMetadata;
1476
+ if (config.policy.dlp.enabled && (!isIgnoredTool(toolName) || config.policy.dlp.scanIgnoredTools)) {
1477
+ const dlpMatch = scanArgs(args);
1478
+ if (dlpMatch) {
1479
+ const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
1480
+ if (dlpMatch.severity === "block") {
1481
+ if (!isManual) appendLocalAudit(toolName, args, "deny", "dlp-block", meta);
1482
+ return {
1483
+ approved: false,
1484
+ reason: dlpReason,
1485
+ blockedBy: "local-config",
1486
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Secret Detected)"
1487
+ };
1488
+ }
1489
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "dlp-review-flagged", meta);
1490
+ explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
1491
+ }
1492
+ }
1152
1493
  if (config.settings.mode === "audit") {
1153
1494
  if (!isIgnoredTool(toolName)) {
1154
1495
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
@@ -1368,7 +1709,14 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1368
1709
  console.error(import_chalk2.default.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
1369
1710
  `));
1370
1711
  }
1371
- const daemonDecision = await askDaemon(toolName, args, meta, signal, riskMetadata);
1712
+ const daemonDecision = await askDaemon(
1713
+ toolName,
1714
+ args,
1715
+ meta,
1716
+ signal,
1717
+ riskMetadata,
1718
+ options?.activityId
1719
+ );
1372
1720
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
1373
1721
  const isApproved = daemonDecision === "allow";
1374
1722
  return {
@@ -1388,7 +1736,14 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1388
1736
  racePromises.push(
1389
1737
  (async () => {
1390
1738
  try {
1391
- console.log(import_chalk2.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
1739
+ if (explainableLabel.includes("DLP")) {
1740
+ console.log(import_chalk2.default.bgRed.white.bold(` \u{1F6A8} NODE9 DLP ALERT \u2014 CREDENTIAL DETECTED `));
1741
+ console.log(
1742
+ import_chalk2.default.red.bold(` A sensitive secret was detected in the tool arguments!`)
1743
+ );
1744
+ } else {
1745
+ console.log(import_chalk2.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
1746
+ }
1392
1747
  console.log(`${import_chalk2.default.bold("Action:")} ${import_chalk2.default.red(toolName)}`);
1393
1748
  console.log(`${import_chalk2.default.bold("Flagged By:")} ${import_chalk2.default.yellow(explainableLabel)}`);
1394
1749
  if (isRemoteLocked) {
@@ -1493,8 +1848,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
1493
1848
  }
1494
1849
  function getConfig() {
1495
1850
  if (cachedConfig) return cachedConfig;
1496
- const globalPath = import_path3.default.join(import_os.default.homedir(), ".node9", "config.json");
1497
- const projectPath = import_path3.default.join(process.cwd(), "node9.config.json");
1851
+ const globalPath = import_path4.default.join(import_os2.default.homedir(), ".node9", "config.json");
1852
+ const projectPath = import_path4.default.join(process.cwd(), "node9.config.json");
1498
1853
  const globalConfig = tryLoadConfig(globalPath);
1499
1854
  const projectConfig = tryLoadConfig(projectPath);
1500
1855
  const mergedSettings = {
@@ -1506,13 +1861,13 @@ function getConfig() {
1506
1861
  dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
1507
1862
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
1508
1863
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
1509
- rules: [...DEFAULT_CONFIG.policy.rules],
1510
1864
  smartRules: [...DEFAULT_CONFIG.policy.smartRules],
1511
1865
  snapshot: {
1512
1866
  tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
1513
1867
  onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
1514
1868
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
1515
- }
1869
+ },
1870
+ dlp: { ...DEFAULT_CONFIG.policy.dlp }
1516
1871
  };
1517
1872
  const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
1518
1873
  const applyLayer = (source) => {
@@ -1532,7 +1887,6 @@ function getConfig() {
1532
1887
  if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
1533
1888
  if (p.toolInspection)
1534
1889
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1535
- if (p.rules) mergedPolicy.rules.push(...p.rules);
1536
1890
  if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1537
1891
  if (p.snapshot) {
1538
1892
  const s2 = p.snapshot;
@@ -1540,6 +1894,11 @@ function getConfig() {
1540
1894
  if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
1541
1895
  if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
1542
1896
  }
1897
+ if (p.dlp) {
1898
+ const d = p.dlp;
1899
+ if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
1900
+ if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
1901
+ }
1543
1902
  const envs = source.environments || {};
1544
1903
  for (const [envName, envConfig] of Object.entries(envs)) {
1545
1904
  if (envConfig && typeof envConfig === "object") {
@@ -1554,6 +1913,22 @@ function getConfig() {
1554
1913
  };
1555
1914
  applyLayer(globalConfig);
1556
1915
  applyLayer(projectConfig);
1916
+ for (const shieldName of readActiveShields()) {
1917
+ const shield = getShield(shieldName);
1918
+ if (!shield) continue;
1919
+ const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
1920
+ for (const rule of shield.smartRules) {
1921
+ if (!existingRuleNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
1922
+ }
1923
+ const existingWords = new Set(mergedPolicy.dangerousWords);
1924
+ for (const word of shield.dangerousWords) {
1925
+ if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
1926
+ }
1927
+ }
1928
+ const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
1929
+ for (const rule of ADVISORY_SMART_RULES) {
1930
+ if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
1931
+ }
1557
1932
  if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
1558
1933
  mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
1559
1934
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
@@ -1569,10 +1944,10 @@ function getConfig() {
1569
1944
  return cachedConfig;
1570
1945
  }
1571
1946
  function tryLoadConfig(filePath) {
1572
- if (!import_fs.default.existsSync(filePath)) return null;
1947
+ if (!import_fs2.default.existsSync(filePath)) return null;
1573
1948
  let raw;
1574
1949
  try {
1575
- raw = JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
1950
+ raw = JSON.parse(import_fs2.default.readFileSync(filePath, "utf-8"));
1576
1951
  } catch (err) {
1577
1952
  const msg = err instanceof Error ? err.message : String(err);
1578
1953
  process.stderr.write(
@@ -1634,9 +2009,9 @@ function getCredentials() {
1634
2009
  };
1635
2010
  }
1636
2011
  try {
1637
- const credPath = import_path3.default.join(import_os.default.homedir(), ".node9", "credentials.json");
1638
- if (import_fs.default.existsSync(credPath)) {
1639
- const creds = JSON.parse(import_fs.default.readFileSync(credPath, "utf-8"));
2012
+ const credPath = import_path4.default.join(import_os2.default.homedir(), ".node9", "credentials.json");
2013
+ if (import_fs2.default.existsSync(credPath)) {
2014
+ const creds = JSON.parse(import_fs2.default.readFileSync(credPath, "utf-8"));
1640
2015
  const profileName = process.env.NODE9_PROFILE || "default";
1641
2016
  const profile = creds[profileName];
1642
2017
  if (profile?.apiKey) {
@@ -1671,9 +2046,9 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1671
2046
  context: {
1672
2047
  agent: meta?.agent,
1673
2048
  mcpServer: meta?.mcpServer,
1674
- hostname: import_os.default.hostname(),
2049
+ hostname: import_os2.default.hostname(),
1675
2050
  cwd: process.cwd(),
1676
- platform: import_os.default.platform()
2051
+ platform: import_os2.default.platform()
1677
2052
  }
1678
2053
  }),
1679
2054
  signal: AbortSignal.timeout(5e3)
@@ -1694,9 +2069,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
1694
2069
  context: {
1695
2070
  agent: meta?.agent,
1696
2071
  mcpServer: meta?.mcpServer,
1697
- hostname: import_os.default.hostname(),
2072
+ hostname: import_os2.default.hostname(),
1698
2073
  cwd: process.cwd(),
1699
- platform: import_os.default.platform()
2074
+ platform: import_os2.default.platform()
1700
2075
  },
1701
2076
  ...riskMetadata && { riskMetadata }
1702
2077
  }),