@node9/proxy 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,13 +138,13 @@ Node9 has two layers of protection. You get Layer 1 automatically. Layer 2 is on
138
138
 
139
139
  Built into the binary. Zero configuration required. Protects the tools every developer uses.
140
140
 
141
- | What it protects | Example blocked action |
142
- | :---------------- | :------------------------------------------------------ |
143
- | **Git** | `git push --force`, `git reset --hard`, `git clean -fd` |
144
- | **Shell** | `curl ... \| bash`, `sudo` commands |
145
- | **SQL** | `DELETE` / `UPDATE` without a `WHERE` clause |
146
- | **Filesystem** | `rm -rf` targeting home directory |
147
- | **Secrets (DLP)** | AWS keys, GitHub tokens, Stripe keys, PEM private keys |
141
+ | What it protects | Example blocked action |
142
+ | :---------------- | :--------------------------------------------------------------------------------- |
143
+ | **Git** | `git push --force`, `git reset --hard`, `git clean -fd` |
144
+ | **Shell** | `curl ... \| bash`, `sudo` commands |
145
+ | **SQL** | `DELETE` / `UPDATE` without `WHERE`; `DROP TABLE`, `TRUNCATE TABLE`, `DROP COLUMN` |
146
+ | **Filesystem** | `rm -rf` targeting home directory |
147
+ | **Secrets (DLP)** | AWS keys, GitHub tokens, Stripe keys, PEM private keys |
148
148
 
149
149
  ### 🔍 DLP — Content Scanner (Always On)
150
150
 
@@ -188,12 +188,12 @@ Secrets are **never logged in full** — the audit trail stores only a redacted
188
188
 
189
189
  Shields add protection for specific infrastructure and services — only relevant if you actually use them.
190
190
 
191
- | Shield | What it protects |
192
- | :----------- | :---------------------------------------------------------------------------- |
193
- | `postgres` | Blocks `DROP TABLE`, `TRUNCATE`, `DROP COLUMN`; reviews `GRANT`/`REVOKE` |
194
- | `github` | Blocks `gh repo delete`; reviews remote branch deletion |
195
- | `aws` | Blocks S3 bucket deletion, EC2 termination; reviews IAM changes, RDS deletion |
196
- | `filesystem` | Reviews `chmod 777`, writes to `/etc/` |
191
+ | Shield | What it protects |
192
+ | :----------- | :-------------------------------------------------------------------------------------------------------------- |
193
+ | `postgres` | Hard-blocks `DROP TABLE`, `TRUNCATE`, `DROP COLUMN` (upgrades Layer 1 review → block); reviews `GRANT`/`REVOKE` |
194
+ | `github` | Blocks `gh repo delete`; reviews remote branch deletion |
195
+ | `aws` | Blocks S3 bucket deletion, EC2 termination; reviews IAM changes, RDS deletion |
196
+ | `filesystem` | Reviews `chmod 777`, writes to `/etc/` |
197
197
 
198
198
  ```bash
199
199
  node9 shield enable postgres # protect your database
package/dist/cli.js CHANGED
@@ -492,30 +492,97 @@ function getShield(name) {
492
492
  function listShields() {
493
493
  return Object.values(SHIELDS);
494
494
  }
495
- function readActiveShields() {
495
+ function isShieldVerdict(v) {
496
+ return v === "allow" || v === "review" || v === "block";
497
+ }
498
+ function validateOverrides(raw) {
499
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
500
+ const result = {};
501
+ for (const [shieldName, rules] of Object.entries(raw)) {
502
+ if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
503
+ const validRules = {};
504
+ for (const [ruleName, verdict] of Object.entries(rules)) {
505
+ if (isShieldVerdict(verdict)) {
506
+ validRules[ruleName] = verdict;
507
+ } else {
508
+ process.stderr.write(
509
+ `[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
510
+ `
511
+ );
512
+ }
513
+ }
514
+ if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
515
+ }
516
+ return result;
517
+ }
518
+ function readShieldsFile() {
496
519
  try {
497
520
  const raw = import_fs.default.readFileSync(SHIELDS_STATE_FILE, "utf-8");
498
- if (!raw.trim()) return [];
521
+ if (!raw.trim()) return { active: [] };
499
522
  const parsed = JSON.parse(raw);
500
- if (Array.isArray(parsed.active)) {
501
- return parsed.active.filter(
502
- (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
503
- );
504
- }
523
+ const active = Array.isArray(parsed.active) ? parsed.active.filter(
524
+ (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
525
+ ) : [];
526
+ return { active, overrides: validateOverrides(parsed.overrides) };
505
527
  } catch (err) {
506
528
  if (err.code !== "ENOENT") {
507
529
  process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
508
530
  `);
509
531
  }
532
+ return { active: [] };
510
533
  }
511
- return [];
512
534
  }
513
- function writeActiveShields(active) {
535
+ function writeShieldsFile(data) {
514
536
  import_fs.default.mkdirSync(import_path3.default.dirname(SHIELDS_STATE_FILE), { recursive: true });
515
537
  const tmp = `${SHIELDS_STATE_FILE}.${import_crypto.default.randomBytes(6).toString("hex")}.tmp`;
516
- import_fs.default.writeFileSync(tmp, JSON.stringify({ active }, null, 2), { mode: 384 });
538
+ const toWrite = { active: data.active };
539
+ if (data.overrides && Object.keys(data.overrides).length > 0) toWrite.overrides = data.overrides;
540
+ import_fs.default.writeFileSync(tmp, JSON.stringify(toWrite, null, 2), { mode: 384 });
517
541
  import_fs.default.renameSync(tmp, SHIELDS_STATE_FILE);
518
542
  }
543
+ function readActiveShields() {
544
+ return readShieldsFile().active;
545
+ }
546
+ function writeActiveShields(active) {
547
+ const current = readShieldsFile();
548
+ writeShieldsFile({ ...current, active });
549
+ }
550
+ function readShieldOverrides() {
551
+ return readShieldsFile().overrides ?? {};
552
+ }
553
+ function writeShieldOverride(shieldName, ruleName, verdict) {
554
+ const current = readShieldsFile();
555
+ const overrides = { ...current.overrides ?? {} };
556
+ overrides[shieldName] = { ...overrides[shieldName] ?? {}, [ruleName]: verdict };
557
+ writeShieldsFile({ ...current, overrides });
558
+ }
559
+ function clearShieldOverride(shieldName, ruleName) {
560
+ const current = readShieldsFile();
561
+ if (!current.overrides?.[shieldName]?.[ruleName]) return;
562
+ const overrides = { ...current.overrides };
563
+ const updated = { ...overrides[shieldName] };
564
+ delete updated[ruleName];
565
+ if (Object.keys(updated).length === 0) {
566
+ delete overrides[shieldName];
567
+ } else {
568
+ overrides[shieldName] = updated;
569
+ }
570
+ writeShieldsFile({ ...current, overrides });
571
+ }
572
+ function resolveShieldRule(shieldName, identifier) {
573
+ const shield = SHIELDS[shieldName];
574
+ if (!shield) return null;
575
+ const id = identifier.toLowerCase();
576
+ for (const rule of shield.smartRules) {
577
+ if (!rule.name) continue;
578
+ if (rule.name === id) return rule.name;
579
+ const withoutShieldPrefix = rule.name.replace(`shield:${shieldName}:`, "");
580
+ if (withoutShieldPrefix === id) return rule.name;
581
+ const operation = withoutShieldPrefix.replace(/^(block|review|allow)-/, "");
582
+ if (operation === id) return rule.name;
583
+ }
584
+ return null;
585
+ }
519
586
  var import_fs, import_path3, import_os, import_crypto, SHIELDS, SHIELDS_STATE_FILE;
520
587
  var init_shields = __esm({
521
588
  "src/shields.ts"() {
@@ -829,9 +896,9 @@ var init_dlp = __esm({
829
896
  /[/\\][^/\\]+\.key$/i,
830
897
  /[/\\][^/\\]+\.p12$/i,
831
898
  /[/\\][^/\\]+\.pfx$/i,
832
- /^\/etc\/passwd$/,
833
- /^\/etc\/shadow$/,
834
- /^\/etc\/sudoers$/,
899
+ /^(?:[a-zA-Z]:)?\/etc\/passwd$/,
900
+ /^(?:[a-zA-Z]:)?\/etc\/shadow$/,
901
+ /^(?:[a-zA-Z]:)?\/etc\/sudoers$/,
835
902
  /[/\\]credentials\.json$/i,
836
903
  /[/\\]id_rsa$/i,
837
904
  /[/\\]id_ed25519$/i,
@@ -1152,6 +1219,13 @@ function redactSecrets(text) {
1152
1219
  function _resetConfigCache() {
1153
1220
  cachedConfig = null;
1154
1221
  }
1222
+ function appendConfigAudit(entry) {
1223
+ appendToLog(LOCAL_AUDIT_LOG, {
1224
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1225
+ ...entry,
1226
+ hostname: import_os2.default.hostname()
1227
+ });
1228
+ }
1155
1229
  function getGlobalSettings() {
1156
1230
  try {
1157
1231
  const globalConfigPath = import_path5.default.join(import_os2.default.homedir(), ".node9", "config.json");
@@ -2167,12 +2241,19 @@ function getConfig(cwd) {
2167
2241
  };
2168
2242
  applyLayer(globalConfig);
2169
2243
  applyLayer(projectConfig);
2244
+ const shieldOverrides = readShieldOverrides();
2170
2245
  for (const shieldName of readActiveShields()) {
2171
2246
  const shield = getShield(shieldName);
2172
2247
  if (!shield) continue;
2173
2248
  const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
2249
+ const ruleOverrides = shieldOverrides[shieldName] ?? {};
2174
2250
  for (const rule of shield.smartRules) {
2175
- if (!existingRuleNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
2251
+ if (!existingRuleNames.has(rule.name)) {
2252
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
2253
+ mergedPolicy.smartRules.push(
2254
+ overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
2255
+ );
2256
+ }
2176
2257
  }
2177
2258
  const existingWords = new Set(mergedPolicy.dangerousWords);
2178
2259
  for (const word of shield.dangerousWords) {
@@ -2593,6 +2674,10 @@ var init_core = __esm({
2593
2674
  environments: {}
2594
2675
  };
2595
2676
  ADVISORY_SMART_RULES = [
2677
+ // ── rm safety ─────────────────────────────────────────────────────────────
2678
+ // tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
2679
+ // Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
2680
+ // chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
2596
2681
  {
2597
2682
  name: "allow-rm-safe-paths",
2598
2683
  tool: "*",
@@ -2615,6 +2700,34 @@ var init_core = __esm({
2615
2700
  conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
2616
2701
  verdict: "review",
2617
2702
  reason: "rm can permanently delete files \u2014 confirm the target path"
2703
+ },
2704
+ // ── SQL safety (Safe by Default) ──────────────────────────────────────────
2705
+ // These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
2706
+ // mcp__postgres__query) with a destructive SQL statement in the 'sql' field.
2707
+ // The postgres shield upgrades these from 'review' → 'block' for stricter teams;
2708
+ // without a shield, users still get a human-approval gate on every destructive op.
2709
+ {
2710
+ name: "review-drop-table-sql",
2711
+ tool: "*",
2712
+ conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
2713
+ verdict: "review",
2714
+ reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
2715
+ },
2716
+ {
2717
+ name: "review-truncate-sql",
2718
+ tool: "*",
2719
+ conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
2720
+ verdict: "review",
2721
+ reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
2722
+ },
2723
+ {
2724
+ name: "review-drop-column-sql",
2725
+ tool: "*",
2726
+ conditions: [
2727
+ { field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
2728
+ ],
2729
+ verdict: "review",
2730
+ reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
2618
2731
  }
2619
2732
  ];
2620
2733
  cachedConfig = null;
@@ -5823,7 +5936,7 @@ function openBrowserLocal() {
5823
5936
  }
5824
5937
  async function autoStartDaemonAndWait() {
5825
5938
  try {
5826
- const child = (0, import_child_process6.spawn)("node9", ["daemon"], {
5939
+ const child = (0, import_child_process6.spawn)(process.execPath, [process.argv[1], "daemon"], {
5827
5940
  detached: true,
5828
5941
  stdio: "ignore",
5829
5942
  env: { ...process.env, NODE9_AUTO_STARTED: "1" }
@@ -6479,7 +6592,10 @@ program.command("daemon").description("Run the local approval server").argument(
6479
6592
  console.log(import_chalk6.default.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
6480
6593
  process.exit(0);
6481
6594
  }
6482
- const child = (0, import_child_process6.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
6595
+ const child = (0, import_child_process6.spawn)(process.execPath, [process.argv[1], "daemon"], {
6596
+ detached: true,
6597
+ stdio: "ignore"
6598
+ });
6483
6599
  child.unref();
6484
6600
  for (let i = 0; i < 12; i++) {
6485
6601
  await new Promise((r) => setTimeout(r, 250));
@@ -6491,7 +6607,10 @@ program.command("daemon").description("Run the local approval server").argument(
6491
6607
  process.exit(0);
6492
6608
  }
6493
6609
  if (options.background) {
6494
- const child = (0, import_child_process6.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
6610
+ const child = (0, import_child_process6.spawn)(process.execPath, [process.argv[1], "daemon"], {
6611
+ detached: true,
6612
+ stdio: "ignore"
6613
+ });
6495
6614
  child.unref();
6496
6615
  console.log(import_chalk6.default.green(`
6497
6616
  \u{1F6E1}\uFE0F Node9 daemon started in background (PID ${child.pid})`));
@@ -6923,26 +7042,201 @@ shieldCmd.command("list").description("Show all available shields").action(() =>
6923
7042
  }
6924
7043
  console.log("");
6925
7044
  });
6926
- shieldCmd.command("status").description("Show which shields are currently active").action(() => {
7045
+ shieldCmd.command("status").description("Show active shields and their individual rules with verdicts").action(() => {
6927
7046
  const active = readActiveShields();
6928
7047
  if (active.length === 0) {
6929
- console.log(import_chalk6.default.yellow("\n\u2139\uFE0F No shields are active.\n"));
6930
- console.log(`Run ${import_chalk6.default.cyan("node9 shield list")} to see available shields.
7048
+ console.error(import_chalk6.default.yellow("\n\u2139\uFE0F No shields are active.\n"));
7049
+ console.error(`Run ${import_chalk6.default.cyan("node9 shield list")} to see available shields.
6931
7050
  `);
6932
7051
  return;
6933
7052
  }
6934
- console.log(import_chalk6.default.bold("\n\u{1F6E1}\uFE0F Active Shields\n"));
7053
+ const overrides = readShieldOverrides();
7054
+ console.error(import_chalk6.default.bold("\n\u{1F6E1}\uFE0F Active Shields\n"));
6935
7055
  for (const name of active) {
6936
7056
  const shield = getShield(name);
6937
7057
  if (!shield) continue;
6938
- console.log(` ${import_chalk6.default.green("\u25CF")} ${import_chalk6.default.cyan(name)}`);
6939
- console.log(
7058
+ console.error(` ${import_chalk6.default.green("\u25CF")} ${import_chalk6.default.cyan(name)} \u2014 ${shield.description}`);
7059
+ const ruleOverrides = overrides[name] ?? {};
7060
+ for (const rule of shield.smartRules) {
7061
+ const shortName = rule.name ? rule.name.replace(`shield:${name}:`, "") : "(unnamed)";
7062
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
7063
+ const effectiveVerdict = overrideVerdict ?? rule.verdict;
7064
+ const verdictLabel = effectiveVerdict === "block" ? import_chalk6.default.red("block ") : effectiveVerdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow ");
7065
+ const overrideNote = overrideVerdict ? import_chalk6.default.gray(` \u2190 overridden (was: ${rule.verdict})`) : "";
7066
+ console.error(
7067
+ ` ${verdictLabel} ${shortName.padEnd(24)} ${import_chalk6.default.gray(rule.reason ?? "")}${overrideNote}`
7068
+ );
7069
+ }
7070
+ if (shield.dangerousWords.length > 0) {
7071
+ console.error(import_chalk6.default.gray(` words: ${shield.dangerousWords.join(", ")}`));
7072
+ }
7073
+ console.error("");
7074
+ }
7075
+ if (Object.keys(overrides).length > 0) {
7076
+ console.error(
6940
7077
  import_chalk6.default.gray(
6941
- ` ${shield.smartRules.length} smart rules \xB7 ${shield.dangerousWords.length} dangerous words`
7078
+ ` Tip: run ${import_chalk6.default.cyan("node9 shield unset <shield> <rule>")} to remove an override.
7079
+ `
6942
7080
  )
6943
7081
  );
6944
7082
  }
6945
- console.log("");
7083
+ });
7084
+ shieldCmd.command("set <service> <rule> <verdict>").description("Override the verdict for a specific shield rule (block, review, or allow)").option("--force", "Required when setting verdict to allow (silences a block rule)").action((service, rule, verdict, opts) => {
7085
+ const name = resolveShieldName(service);
7086
+ if (!name) {
7087
+ console.error(import_chalk6.default.red(`
7088
+ \u274C Unknown shield: "${service}"
7089
+ `));
7090
+ console.error(`Run ${import_chalk6.default.cyan("node9 shield list")} to see available shields.
7091
+ `);
7092
+ process.exit(1);
7093
+ }
7094
+ if (!readActiveShields().includes(name)) {
7095
+ console.error(import_chalk6.default.red(`
7096
+ \u274C Shield "${name}" is not active. Enable it first:
7097
+ `));
7098
+ console.error(` ${import_chalk6.default.cyan(`node9 shield enable ${name}`)}
7099
+ `);
7100
+ process.exit(1);
7101
+ }
7102
+ if (!isShieldVerdict(verdict)) {
7103
+ console.error(import_chalk6.default.red(`
7104
+ \u274C Invalid verdict "${verdict}". Use: block, review, or allow
7105
+ `));
7106
+ process.exit(1);
7107
+ }
7108
+ if (verdict === "allow" && !opts.force) {
7109
+ console.error(
7110
+ import_chalk6.default.red(`
7111
+ \u26A0\uFE0F Setting a verdict to "allow" silences the rule entirely.
7112
+ `) + import_chalk6.default.yellow(
7113
+ ` This disables a shield protection. If you are sure, re-run with --force:
7114
+ `
7115
+ ) + import_chalk6.default.cyan(`
7116
+ node9 shield set ${service} ${rule} allow --force
7117
+ `)
7118
+ );
7119
+ process.exit(1);
7120
+ }
7121
+ const ruleName = resolveShieldRule(name, rule);
7122
+ if (!ruleName) {
7123
+ const shield = getShield(name);
7124
+ console.error(import_chalk6.default.red(`
7125
+ \u274C Unknown rule "${rule}" for shield "${name}".
7126
+ `));
7127
+ console.error(" Available rules:");
7128
+ for (const r of shield?.smartRules ?? []) {
7129
+ const short = r.name ? r.name.replace(`shield:${name}:`, "") : "";
7130
+ console.error(` ${import_chalk6.default.cyan(short)}`);
7131
+ }
7132
+ console.error("");
7133
+ process.exit(1);
7134
+ }
7135
+ writeShieldOverride(name, ruleName, verdict);
7136
+ if (verdict === "allow") {
7137
+ appendConfigAudit({ event: "shield-override-allow", shield: name, rule: ruleName });
7138
+ }
7139
+ const shortName = ruleName.replace(`shield:${name}:`, "");
7140
+ const verdictLabel = verdict === "block" ? import_chalk6.default.red("block") : verdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow");
7141
+ if (verdict === "allow") {
7142
+ console.error(
7143
+ import_chalk6.default.yellow(`
7144
+ \u26A0\uFE0F ${name}/${shortName} \u2192 ${verdictLabel}`) + import_chalk6.default.gray(" (rule silenced \u2014 use `node9 shield unset` to restore)\n")
7145
+ );
7146
+ } else {
7147
+ console.error(import_chalk6.default.green(`
7148
+ \u2705 ${name}/${shortName} \u2192 ${verdictLabel}
7149
+ `));
7150
+ }
7151
+ console.error(
7152
+ import_chalk6.default.gray(` Run ${import_chalk6.default.cyan("node9 shield status")} to see all active rules.
7153
+ `)
7154
+ );
7155
+ });
7156
+ shieldCmd.command("unset <service> <rule>").description("Remove a verdict override, restoring the shield default").action((service, rule) => {
7157
+ const name = resolveShieldName(service);
7158
+ if (!name) {
7159
+ console.error(import_chalk6.default.red(`
7160
+ \u274C Unknown shield: "${service}"
7161
+ `));
7162
+ process.exit(1);
7163
+ }
7164
+ const ruleName = resolveShieldRule(name, rule);
7165
+ if (!ruleName) {
7166
+ console.error(import_chalk6.default.red(`
7167
+ \u274C Unknown rule "${rule}" for shield "${name}".
7168
+ `));
7169
+ process.exit(1);
7170
+ }
7171
+ clearShieldOverride(name, ruleName);
7172
+ const shortName = ruleName.replace(`shield:${name}:`, "");
7173
+ console.error(
7174
+ import_chalk6.default.green(`
7175
+ \u2705 Override removed \u2014 ${name}/${shortName} restored to default.
7176
+ `)
7177
+ );
7178
+ });
7179
+ program.command("config show").description("Show the full effective runtime configuration including shields and advisory rules").action(() => {
7180
+ const config = getConfig();
7181
+ const active = readActiveShields();
7182
+ const overrides = readShieldOverrides();
7183
+ console.error(import_chalk6.default.bold("\n\u{1F50D} Node9 Effective Configuration\n"));
7184
+ const modeLabel = config.settings.mode === "audit" ? import_chalk6.default.blue("audit") : config.settings.mode === "strict" ? import_chalk6.default.red("strict") : import_chalk6.default.white("standard");
7185
+ console.error(` Mode: ${modeLabel}
7186
+ `);
7187
+ if (active.length > 0) {
7188
+ console.error(import_chalk6.default.bold(" \u2500\u2500 Active Shields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7189
+ for (const name of active) {
7190
+ const shield = getShield(name);
7191
+ if (!shield) continue;
7192
+ const ruleOverrides = overrides[name] ?? {};
7193
+ console.error(`
7194
+ ${import_chalk6.default.green("\u25CF")} ${import_chalk6.default.cyan(name)}`);
7195
+ for (const rule of shield.smartRules) {
7196
+ const shortName = rule.name ? rule.name.replace(`shield:${name}:`, "") : "(unnamed)";
7197
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
7198
+ const effectiveVerdict = overrideVerdict ?? rule.verdict;
7199
+ const vLabel = effectiveVerdict === "block" ? import_chalk6.default.red("block ") : effectiveVerdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow ");
7200
+ const note = overrideVerdict ? import_chalk6.default.gray(` \u2190 overridden`) : "";
7201
+ console.error(` ${vLabel} ${shortName}${note}`);
7202
+ }
7203
+ }
7204
+ console.error("");
7205
+ } else {
7206
+ console.error(import_chalk6.default.gray(" No shields active. Run `node9 shield list` to see options.\n"));
7207
+ }
7208
+ console.error(import_chalk6.default.bold(" \u2500\u2500 Built-in Rules (always on) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7209
+ for (const rule of config.policy.smartRules) {
7210
+ const isShieldRule = rule.name?.startsWith("shield:");
7211
+ const isAdvisory = [
7212
+ "review-rm",
7213
+ "allow-rm-safe-paths",
7214
+ "review-drop-table-sql",
7215
+ "review-truncate-sql",
7216
+ "review-drop-column-sql"
7217
+ ].includes(rule.name ?? "");
7218
+ if (isShieldRule || isAdvisory) continue;
7219
+ const vLabel = rule.verdict === "block" ? import_chalk6.default.red("block ") : rule.verdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow ");
7220
+ console.error(` ${vLabel} ${import_chalk6.default.gray(rule.name ?? rule.tool)}`);
7221
+ }
7222
+ console.error("");
7223
+ console.error(import_chalk6.default.bold(" \u2500\u2500 Safe by Default (advisory, overridable) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7224
+ const advisoryNames = /* @__PURE__ */ new Set([
7225
+ "review-rm",
7226
+ "allow-rm-safe-paths",
7227
+ "review-drop-table-sql",
7228
+ "review-truncate-sql",
7229
+ "review-drop-column-sql"
7230
+ ]);
7231
+ for (const rule of config.policy.smartRules) {
7232
+ if (!advisoryNames.has(rule.name ?? "")) continue;
7233
+ const vLabel = rule.verdict === "block" ? import_chalk6.default.red("block ") : rule.verdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow ");
7234
+ console.error(` ${vLabel} ${import_chalk6.default.gray(rule.name ?? rule.tool)}`);
7235
+ }
7236
+ console.error("");
7237
+ console.error(import_chalk6.default.bold(" \u2500\u2500 Dangerous Words \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7238
+ console.error(` ${import_chalk6.default.gray(config.policy.dangerousWords.join(", "))}
7239
+ `);
6946
7240
  });
6947
7241
  if (process.argv[2] !== "daemon") {
6948
7242
  process.on("unhandledRejection", (reason) => {
package/dist/cli.mjs CHANGED
@@ -475,30 +475,97 @@ function getShield(name) {
475
475
  function listShields() {
476
476
  return Object.values(SHIELDS);
477
477
  }
478
- function readActiveShields() {
478
+ function isShieldVerdict(v) {
479
+ return v === "allow" || v === "review" || v === "block";
480
+ }
481
+ function validateOverrides(raw) {
482
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
483
+ const result = {};
484
+ for (const [shieldName, rules] of Object.entries(raw)) {
485
+ if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
486
+ const validRules = {};
487
+ for (const [ruleName, verdict] of Object.entries(rules)) {
488
+ if (isShieldVerdict(verdict)) {
489
+ validRules[ruleName] = verdict;
490
+ } else {
491
+ process.stderr.write(
492
+ `[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
493
+ `
494
+ );
495
+ }
496
+ }
497
+ if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
498
+ }
499
+ return result;
500
+ }
501
+ function readShieldsFile() {
479
502
  try {
480
503
  const raw = fs.readFileSync(SHIELDS_STATE_FILE, "utf-8");
481
- if (!raw.trim()) return [];
504
+ if (!raw.trim()) return { active: [] };
482
505
  const parsed = JSON.parse(raw);
483
- if (Array.isArray(parsed.active)) {
484
- return parsed.active.filter(
485
- (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
486
- );
487
- }
506
+ const active = Array.isArray(parsed.active) ? parsed.active.filter(
507
+ (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
508
+ ) : [];
509
+ return { active, overrides: validateOverrides(parsed.overrides) };
488
510
  } catch (err) {
489
511
  if (err.code !== "ENOENT") {
490
512
  process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
491
513
  `);
492
514
  }
515
+ return { active: [] };
493
516
  }
494
- return [];
495
517
  }
496
- function writeActiveShields(active) {
518
+ function writeShieldsFile(data) {
497
519
  fs.mkdirSync(path3.dirname(SHIELDS_STATE_FILE), { recursive: true });
498
520
  const tmp = `${SHIELDS_STATE_FILE}.${crypto.randomBytes(6).toString("hex")}.tmp`;
499
- fs.writeFileSync(tmp, JSON.stringify({ active }, null, 2), { mode: 384 });
521
+ const toWrite = { active: data.active };
522
+ if (data.overrides && Object.keys(data.overrides).length > 0) toWrite.overrides = data.overrides;
523
+ fs.writeFileSync(tmp, JSON.stringify(toWrite, null, 2), { mode: 384 });
500
524
  fs.renameSync(tmp, SHIELDS_STATE_FILE);
501
525
  }
526
+ function readActiveShields() {
527
+ return readShieldsFile().active;
528
+ }
529
+ function writeActiveShields(active) {
530
+ const current = readShieldsFile();
531
+ writeShieldsFile({ ...current, active });
532
+ }
533
+ function readShieldOverrides() {
534
+ return readShieldsFile().overrides ?? {};
535
+ }
536
+ function writeShieldOverride(shieldName, ruleName, verdict) {
537
+ const current = readShieldsFile();
538
+ const overrides = { ...current.overrides ?? {} };
539
+ overrides[shieldName] = { ...overrides[shieldName] ?? {}, [ruleName]: verdict };
540
+ writeShieldsFile({ ...current, overrides });
541
+ }
542
+ function clearShieldOverride(shieldName, ruleName) {
543
+ const current = readShieldsFile();
544
+ if (!current.overrides?.[shieldName]?.[ruleName]) return;
545
+ const overrides = { ...current.overrides };
546
+ const updated = { ...overrides[shieldName] };
547
+ delete updated[ruleName];
548
+ if (Object.keys(updated).length === 0) {
549
+ delete overrides[shieldName];
550
+ } else {
551
+ overrides[shieldName] = updated;
552
+ }
553
+ writeShieldsFile({ ...current, overrides });
554
+ }
555
+ function resolveShieldRule(shieldName, identifier) {
556
+ const shield = SHIELDS[shieldName];
557
+ if (!shield) return null;
558
+ const id = identifier.toLowerCase();
559
+ for (const rule of shield.smartRules) {
560
+ if (!rule.name) continue;
561
+ if (rule.name === id) return rule.name;
562
+ const withoutShieldPrefix = rule.name.replace(`shield:${shieldName}:`, "");
563
+ if (withoutShieldPrefix === id) return rule.name;
564
+ const operation = withoutShieldPrefix.replace(/^(block|review|allow)-/, "");
565
+ if (operation === id) return rule.name;
566
+ }
567
+ return null;
568
+ }
502
569
  var SHIELDS, SHIELDS_STATE_FILE;
503
570
  var init_shields = __esm({
504
571
  "src/shields.ts"() {
@@ -808,9 +875,9 @@ var init_dlp = __esm({
808
875
  /[/\\][^/\\]+\.key$/i,
809
876
  /[/\\][^/\\]+\.p12$/i,
810
877
  /[/\\][^/\\]+\.pfx$/i,
811
- /^\/etc\/passwd$/,
812
- /^\/etc\/shadow$/,
813
- /^\/etc\/sudoers$/,
878
+ /^(?:[a-zA-Z]:)?\/etc\/passwd$/,
879
+ /^(?:[a-zA-Z]:)?\/etc\/shadow$/,
880
+ /^(?:[a-zA-Z]:)?\/etc\/sudoers$/,
814
881
  /[/\\]credentials\.json$/i,
815
882
  /[/\\]id_rsa$/i,
816
883
  /[/\\]id_ed25519$/i,
@@ -1142,6 +1209,13 @@ function redactSecrets(text) {
1142
1209
  function _resetConfigCache() {
1143
1210
  cachedConfig = null;
1144
1211
  }
1212
+ function appendConfigAudit(entry) {
1213
+ appendToLog(LOCAL_AUDIT_LOG, {
1214
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1215
+ ...entry,
1216
+ hostname: os2.hostname()
1217
+ });
1218
+ }
1145
1219
  function getGlobalSettings() {
1146
1220
  try {
1147
1221
  const globalConfigPath = path5.join(os2.homedir(), ".node9", "config.json");
@@ -2157,12 +2231,19 @@ function getConfig(cwd) {
2157
2231
  };
2158
2232
  applyLayer(globalConfig);
2159
2233
  applyLayer(projectConfig);
2234
+ const shieldOverrides = readShieldOverrides();
2160
2235
  for (const shieldName of readActiveShields()) {
2161
2236
  const shield = getShield(shieldName);
2162
2237
  if (!shield) continue;
2163
2238
  const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
2239
+ const ruleOverrides = shieldOverrides[shieldName] ?? {};
2164
2240
  for (const rule of shield.smartRules) {
2165
- if (!existingRuleNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
2241
+ if (!existingRuleNames.has(rule.name)) {
2242
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
2243
+ mergedPolicy.smartRules.push(
2244
+ overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
2245
+ );
2246
+ }
2166
2247
  }
2167
2248
  const existingWords = new Set(mergedPolicy.dangerousWords);
2168
2249
  for (const word of shield.dangerousWords) {
@@ -2572,6 +2653,10 @@ var init_core = __esm({
2572
2653
  environments: {}
2573
2654
  };
2574
2655
  ADVISORY_SMART_RULES = [
2656
+ // ── rm safety ─────────────────────────────────────────────────────────────
2657
+ // tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
2658
+ // Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
2659
+ // chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
2575
2660
  {
2576
2661
  name: "allow-rm-safe-paths",
2577
2662
  tool: "*",
@@ -2594,6 +2679,34 @@ var init_core = __esm({
2594
2679
  conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
2595
2680
  verdict: "review",
2596
2681
  reason: "rm can permanently delete files \u2014 confirm the target path"
2682
+ },
2683
+ // ── SQL safety (Safe by Default) ──────────────────────────────────────────
2684
+ // These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
2685
+ // mcp__postgres__query) with a destructive SQL statement in the 'sql' field.
2686
+ // The postgres shield upgrades these from 'review' → 'block' for stricter teams;
2687
+ // without a shield, users still get a human-approval gate on every destructive op.
2688
+ {
2689
+ name: "review-drop-table-sql",
2690
+ tool: "*",
2691
+ conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
2692
+ verdict: "review",
2693
+ reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
2694
+ },
2695
+ {
2696
+ name: "review-truncate-sql",
2697
+ tool: "*",
2698
+ conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
2699
+ verdict: "review",
2700
+ reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
2701
+ },
2702
+ {
2703
+ name: "review-drop-column-sql",
2704
+ tool: "*",
2705
+ conditions: [
2706
+ { field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
2707
+ ],
2708
+ verdict: "review",
2709
+ reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
2597
2710
  }
2598
2711
  ];
2599
2712
  cachedConfig = null;
@@ -5802,7 +5915,7 @@ function openBrowserLocal() {
5802
5915
  }
5803
5916
  async function autoStartDaemonAndWait() {
5804
5917
  try {
5805
- const child = spawn5("node9", ["daemon"], {
5918
+ const child = spawn5(process.execPath, [process.argv[1], "daemon"], {
5806
5919
  detached: true,
5807
5920
  stdio: "ignore",
5808
5921
  env: { ...process.env, NODE9_AUTO_STARTED: "1" }
@@ -6458,7 +6571,10 @@ program.command("daemon").description("Run the local approval server").argument(
6458
6571
  console.log(chalk6.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
6459
6572
  process.exit(0);
6460
6573
  }
6461
- const child = spawn5("node9", ["daemon"], { detached: true, stdio: "ignore" });
6574
+ const child = spawn5(process.execPath, [process.argv[1], "daemon"], {
6575
+ detached: true,
6576
+ stdio: "ignore"
6577
+ });
6462
6578
  child.unref();
6463
6579
  for (let i = 0; i < 12; i++) {
6464
6580
  await new Promise((r) => setTimeout(r, 250));
@@ -6470,7 +6586,10 @@ program.command("daemon").description("Run the local approval server").argument(
6470
6586
  process.exit(0);
6471
6587
  }
6472
6588
  if (options.background) {
6473
- const child = spawn5("node9", ["daemon"], { detached: true, stdio: "ignore" });
6589
+ const child = spawn5(process.execPath, [process.argv[1], "daemon"], {
6590
+ detached: true,
6591
+ stdio: "ignore"
6592
+ });
6474
6593
  child.unref();
6475
6594
  console.log(chalk6.green(`
6476
6595
  \u{1F6E1}\uFE0F Node9 daemon started in background (PID ${child.pid})`));
@@ -6902,26 +7021,201 @@ shieldCmd.command("list").description("Show all available shields").action(() =>
6902
7021
  }
6903
7022
  console.log("");
6904
7023
  });
6905
- shieldCmd.command("status").description("Show which shields are currently active").action(() => {
7024
+ shieldCmd.command("status").description("Show active shields and their individual rules with verdicts").action(() => {
6906
7025
  const active = readActiveShields();
6907
7026
  if (active.length === 0) {
6908
- console.log(chalk6.yellow("\n\u2139\uFE0F No shields are active.\n"));
6909
- console.log(`Run ${chalk6.cyan("node9 shield list")} to see available shields.
7027
+ console.error(chalk6.yellow("\n\u2139\uFE0F No shields are active.\n"));
7028
+ console.error(`Run ${chalk6.cyan("node9 shield list")} to see available shields.
6910
7029
  `);
6911
7030
  return;
6912
7031
  }
6913
- console.log(chalk6.bold("\n\u{1F6E1}\uFE0F Active Shields\n"));
7032
+ const overrides = readShieldOverrides();
7033
+ console.error(chalk6.bold("\n\u{1F6E1}\uFE0F Active Shields\n"));
6914
7034
  for (const name of active) {
6915
7035
  const shield = getShield(name);
6916
7036
  if (!shield) continue;
6917
- console.log(` ${chalk6.green("\u25CF")} ${chalk6.cyan(name)}`);
6918
- console.log(
7037
+ console.error(` ${chalk6.green("\u25CF")} ${chalk6.cyan(name)} \u2014 ${shield.description}`);
7038
+ const ruleOverrides = overrides[name] ?? {};
7039
+ for (const rule of shield.smartRules) {
7040
+ const shortName = rule.name ? rule.name.replace(`shield:${name}:`, "") : "(unnamed)";
7041
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
7042
+ const effectiveVerdict = overrideVerdict ?? rule.verdict;
7043
+ const verdictLabel = effectiveVerdict === "block" ? chalk6.red("block ") : effectiveVerdict === "review" ? chalk6.yellow("review") : chalk6.green("allow ");
7044
+ const overrideNote = overrideVerdict ? chalk6.gray(` \u2190 overridden (was: ${rule.verdict})`) : "";
7045
+ console.error(
7046
+ ` ${verdictLabel} ${shortName.padEnd(24)} ${chalk6.gray(rule.reason ?? "")}${overrideNote}`
7047
+ );
7048
+ }
7049
+ if (shield.dangerousWords.length > 0) {
7050
+ console.error(chalk6.gray(` words: ${shield.dangerousWords.join(", ")}`));
7051
+ }
7052
+ console.error("");
7053
+ }
7054
+ if (Object.keys(overrides).length > 0) {
7055
+ console.error(
6919
7056
  chalk6.gray(
6920
- ` ${shield.smartRules.length} smart rules \xB7 ${shield.dangerousWords.length} dangerous words`
7057
+ ` Tip: run ${chalk6.cyan("node9 shield unset <shield> <rule>")} to remove an override.
7058
+ `
6921
7059
  )
6922
7060
  );
6923
7061
  }
6924
- console.log("");
7062
+ });
7063
+ shieldCmd.command("set <service> <rule> <verdict>").description("Override the verdict for a specific shield rule (block, review, or allow)").option("--force", "Required when setting verdict to allow (silences a block rule)").action((service, rule, verdict, opts) => {
7064
+ const name = resolveShieldName(service);
7065
+ if (!name) {
7066
+ console.error(chalk6.red(`
7067
+ \u274C Unknown shield: "${service}"
7068
+ `));
7069
+ console.error(`Run ${chalk6.cyan("node9 shield list")} to see available shields.
7070
+ `);
7071
+ process.exit(1);
7072
+ }
7073
+ if (!readActiveShields().includes(name)) {
7074
+ console.error(chalk6.red(`
7075
+ \u274C Shield "${name}" is not active. Enable it first:
7076
+ `));
7077
+ console.error(` ${chalk6.cyan(`node9 shield enable ${name}`)}
7078
+ `);
7079
+ process.exit(1);
7080
+ }
7081
+ if (!isShieldVerdict(verdict)) {
7082
+ console.error(chalk6.red(`
7083
+ \u274C Invalid verdict "${verdict}". Use: block, review, or allow
7084
+ `));
7085
+ process.exit(1);
7086
+ }
7087
+ if (verdict === "allow" && !opts.force) {
7088
+ console.error(
7089
+ chalk6.red(`
7090
+ \u26A0\uFE0F Setting a verdict to "allow" silences the rule entirely.
7091
+ `) + chalk6.yellow(
7092
+ ` This disables a shield protection. If you are sure, re-run with --force:
7093
+ `
7094
+ ) + chalk6.cyan(`
7095
+ node9 shield set ${service} ${rule} allow --force
7096
+ `)
7097
+ );
7098
+ process.exit(1);
7099
+ }
7100
+ const ruleName = resolveShieldRule(name, rule);
7101
+ if (!ruleName) {
7102
+ const shield = getShield(name);
7103
+ console.error(chalk6.red(`
7104
+ \u274C Unknown rule "${rule}" for shield "${name}".
7105
+ `));
7106
+ console.error(" Available rules:");
7107
+ for (const r of shield?.smartRules ?? []) {
7108
+ const short = r.name ? r.name.replace(`shield:${name}:`, "") : "";
7109
+ console.error(` ${chalk6.cyan(short)}`);
7110
+ }
7111
+ console.error("");
7112
+ process.exit(1);
7113
+ }
7114
+ writeShieldOverride(name, ruleName, verdict);
7115
+ if (verdict === "allow") {
7116
+ appendConfigAudit({ event: "shield-override-allow", shield: name, rule: ruleName });
7117
+ }
7118
+ const shortName = ruleName.replace(`shield:${name}:`, "");
7119
+ const verdictLabel = verdict === "block" ? chalk6.red("block") : verdict === "review" ? chalk6.yellow("review") : chalk6.green("allow");
7120
+ if (verdict === "allow") {
7121
+ console.error(
7122
+ chalk6.yellow(`
7123
+ \u26A0\uFE0F ${name}/${shortName} \u2192 ${verdictLabel}`) + chalk6.gray(" (rule silenced \u2014 use `node9 shield unset` to restore)\n")
7124
+ );
7125
+ } else {
7126
+ console.error(chalk6.green(`
7127
+ \u2705 ${name}/${shortName} \u2192 ${verdictLabel}
7128
+ `));
7129
+ }
7130
+ console.error(
7131
+ chalk6.gray(` Run ${chalk6.cyan("node9 shield status")} to see all active rules.
7132
+ `)
7133
+ );
7134
+ });
7135
+ shieldCmd.command("unset <service> <rule>").description("Remove a verdict override, restoring the shield default").action((service, rule) => {
7136
+ const name = resolveShieldName(service);
7137
+ if (!name) {
7138
+ console.error(chalk6.red(`
7139
+ \u274C Unknown shield: "${service}"
7140
+ `));
7141
+ process.exit(1);
7142
+ }
7143
+ const ruleName = resolveShieldRule(name, rule);
7144
+ if (!ruleName) {
7145
+ console.error(chalk6.red(`
7146
+ \u274C Unknown rule "${rule}" for shield "${name}".
7147
+ `));
7148
+ process.exit(1);
7149
+ }
7150
+ clearShieldOverride(name, ruleName);
7151
+ const shortName = ruleName.replace(`shield:${name}:`, "");
7152
+ console.error(
7153
+ chalk6.green(`
7154
+ \u2705 Override removed \u2014 ${name}/${shortName} restored to default.
7155
+ `)
7156
+ );
7157
+ });
7158
+ program.command("config show").description("Show the full effective runtime configuration including shields and advisory rules").action(() => {
7159
+ const config = getConfig();
7160
+ const active = readActiveShields();
7161
+ const overrides = readShieldOverrides();
7162
+ console.error(chalk6.bold("\n\u{1F50D} Node9 Effective Configuration\n"));
7163
+ const modeLabel = config.settings.mode === "audit" ? chalk6.blue("audit") : config.settings.mode === "strict" ? chalk6.red("strict") : chalk6.white("standard");
7164
+ console.error(` Mode: ${modeLabel}
7165
+ `);
7166
+ if (active.length > 0) {
7167
+ console.error(chalk6.bold(" \u2500\u2500 Active Shields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7168
+ for (const name of active) {
7169
+ const shield = getShield(name);
7170
+ if (!shield) continue;
7171
+ const ruleOverrides = overrides[name] ?? {};
7172
+ console.error(`
7173
+ ${chalk6.green("\u25CF")} ${chalk6.cyan(name)}`);
7174
+ for (const rule of shield.smartRules) {
7175
+ const shortName = rule.name ? rule.name.replace(`shield:${name}:`, "") : "(unnamed)";
7176
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
7177
+ const effectiveVerdict = overrideVerdict ?? rule.verdict;
7178
+ const vLabel = effectiveVerdict === "block" ? chalk6.red("block ") : effectiveVerdict === "review" ? chalk6.yellow("review") : chalk6.green("allow ");
7179
+ const note = overrideVerdict ? chalk6.gray(` \u2190 overridden`) : "";
7180
+ console.error(` ${vLabel} ${shortName}${note}`);
7181
+ }
7182
+ }
7183
+ console.error("");
7184
+ } else {
7185
+ console.error(chalk6.gray(" No shields active. Run `node9 shield list` to see options.\n"));
7186
+ }
7187
+ console.error(chalk6.bold(" \u2500\u2500 Built-in Rules (always on) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7188
+ for (const rule of config.policy.smartRules) {
7189
+ const isShieldRule = rule.name?.startsWith("shield:");
7190
+ const isAdvisory = [
7191
+ "review-rm",
7192
+ "allow-rm-safe-paths",
7193
+ "review-drop-table-sql",
7194
+ "review-truncate-sql",
7195
+ "review-drop-column-sql"
7196
+ ].includes(rule.name ?? "");
7197
+ if (isShieldRule || isAdvisory) continue;
7198
+ const vLabel = rule.verdict === "block" ? chalk6.red("block ") : rule.verdict === "review" ? chalk6.yellow("review") : chalk6.green("allow ");
7199
+ console.error(` ${vLabel} ${chalk6.gray(rule.name ?? rule.tool)}`);
7200
+ }
7201
+ console.error("");
7202
+ console.error(chalk6.bold(" \u2500\u2500 Safe by Default (advisory, overridable) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7203
+ const advisoryNames = /* @__PURE__ */ new Set([
7204
+ "review-rm",
7205
+ "allow-rm-safe-paths",
7206
+ "review-drop-table-sql",
7207
+ "review-truncate-sql",
7208
+ "review-drop-column-sql"
7209
+ ]);
7210
+ for (const rule of config.policy.smartRules) {
7211
+ if (!advisoryNames.has(rule.name ?? "")) continue;
7212
+ const vLabel = rule.verdict === "block" ? chalk6.red("block ") : rule.verdict === "review" ? chalk6.yellow("review") : chalk6.green("allow ");
7213
+ console.error(` ${vLabel} ${chalk6.gray(rule.name ?? rule.tool)}`);
7214
+ }
7215
+ console.error("");
7216
+ console.error(chalk6.bold(" \u2500\u2500 Dangerous Words \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7217
+ console.error(` ${chalk6.gray(config.policy.dangerousWords.join(", "))}
7218
+ `);
6925
7219
  });
6926
7220
  if (process.argv[2] !== "daemon") {
6927
7221
  process.on("unhandledRejection", (reason) => {
package/dist/index.js CHANGED
@@ -660,23 +660,51 @@ function getShield(name) {
660
660
  return resolved ? SHIELDS[resolved] : null;
661
661
  }
662
662
  var SHIELDS_STATE_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "shields.json");
663
- function readActiveShields() {
663
+ function isShieldVerdict(v) {
664
+ return v === "allow" || v === "review" || v === "block";
665
+ }
666
+ function validateOverrides(raw) {
667
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
668
+ const result = {};
669
+ for (const [shieldName, rules] of Object.entries(raw)) {
670
+ if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
671
+ const validRules = {};
672
+ for (const [ruleName, verdict] of Object.entries(rules)) {
673
+ if (isShieldVerdict(verdict)) {
674
+ validRules[ruleName] = verdict;
675
+ } else {
676
+ process.stderr.write(
677
+ `[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
678
+ `
679
+ );
680
+ }
681
+ }
682
+ if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
683
+ }
684
+ return result;
685
+ }
686
+ function readShieldsFile() {
664
687
  try {
665
688
  const raw = import_fs.default.readFileSync(SHIELDS_STATE_FILE, "utf-8");
666
- if (!raw.trim()) return [];
689
+ if (!raw.trim()) return { active: [] };
667
690
  const parsed = JSON.parse(raw);
668
- if (Array.isArray(parsed.active)) {
669
- return parsed.active.filter(
670
- (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
671
- );
672
- }
691
+ const active = Array.isArray(parsed.active) ? parsed.active.filter(
692
+ (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
693
+ ) : [];
694
+ return { active, overrides: validateOverrides(parsed.overrides) };
673
695
  } catch (err) {
674
696
  if (err.code !== "ENOENT") {
675
697
  process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
676
698
  `);
677
699
  }
700
+ return { active: [] };
678
701
  }
679
- return [];
702
+ }
703
+ function readActiveShields() {
704
+ return readShieldsFile().active;
705
+ }
706
+ function readShieldOverrides() {
707
+ return readShieldsFile().overrides ?? {};
680
708
  }
681
709
 
682
710
  // src/dlp.ts
@@ -724,9 +752,9 @@ var SENSITIVE_PATH_PATTERNS = [
724
752
  /[/\\][^/\\]+\.key$/i,
725
753
  /[/\\][^/\\]+\.p12$/i,
726
754
  /[/\\][^/\\]+\.pfx$/i,
727
- /^\/etc\/passwd$/,
728
- /^\/etc\/shadow$/,
729
- /^\/etc\/sudoers$/,
755
+ /^(?:[a-zA-Z]:)?\/etc\/passwd$/,
756
+ /^(?:[a-zA-Z]:)?\/etc\/shadow$/,
757
+ /^(?:[a-zA-Z]:)?\/etc\/sudoers$/,
730
758
  /[/\\]credentials\.json$/i,
731
759
  /[/\\]id_rsa$/i,
732
760
  /[/\\]id_ed25519$/i,
@@ -1299,6 +1327,10 @@ var DEFAULT_CONFIG = {
1299
1327
  environments: {}
1300
1328
  };
1301
1329
  var ADVISORY_SMART_RULES = [
1330
+ // ── rm safety ─────────────────────────────────────────────────────────────
1331
+ // tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
1332
+ // Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
1333
+ // chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
1302
1334
  {
1303
1335
  name: "allow-rm-safe-paths",
1304
1336
  tool: "*",
@@ -1321,6 +1353,34 @@ var ADVISORY_SMART_RULES = [
1321
1353
  conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
1322
1354
  verdict: "review",
1323
1355
  reason: "rm can permanently delete files \u2014 confirm the target path"
1356
+ },
1357
+ // ── SQL safety (Safe by Default) ──────────────────────────────────────────
1358
+ // These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
1359
+ // mcp__postgres__query) with a destructive SQL statement in the 'sql' field.
1360
+ // The postgres shield upgrades these from 'review' → 'block' for stricter teams;
1361
+ // without a shield, users still get a human-approval gate on every destructive op.
1362
+ {
1363
+ name: "review-drop-table-sql",
1364
+ tool: "*",
1365
+ conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
1366
+ verdict: "review",
1367
+ reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
1368
+ },
1369
+ {
1370
+ name: "review-truncate-sql",
1371
+ tool: "*",
1372
+ conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
1373
+ verdict: "review",
1374
+ reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
1375
+ },
1376
+ {
1377
+ name: "review-drop-column-sql",
1378
+ tool: "*",
1379
+ conditions: [
1380
+ { field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
1381
+ ],
1382
+ verdict: "review",
1383
+ reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
1324
1384
  }
1325
1385
  ];
1326
1386
  var cachedConfig = null;
@@ -2058,12 +2118,19 @@ function getConfig(cwd) {
2058
2118
  };
2059
2119
  applyLayer(globalConfig);
2060
2120
  applyLayer(projectConfig);
2121
+ const shieldOverrides = readShieldOverrides();
2061
2122
  for (const shieldName of readActiveShields()) {
2062
2123
  const shield = getShield(shieldName);
2063
2124
  if (!shield) continue;
2064
2125
  const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
2126
+ const ruleOverrides = shieldOverrides[shieldName] ?? {};
2065
2127
  for (const rule of shield.smartRules) {
2066
- if (!existingRuleNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
2128
+ if (!existingRuleNames.has(rule.name)) {
2129
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
2130
+ mergedPolicy.smartRules.push(
2131
+ overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
2132
+ );
2133
+ }
2067
2134
  }
2068
2135
  const existingWords = new Set(mergedPolicy.dangerousWords);
2069
2136
  for (const word of shield.dangerousWords) {
package/dist/index.mjs CHANGED
@@ -624,23 +624,51 @@ function getShield(name) {
624
624
  return resolved ? SHIELDS[resolved] : null;
625
625
  }
626
626
  var SHIELDS_STATE_FILE = path3.join(os.homedir(), ".node9", "shields.json");
627
- function readActiveShields() {
627
+ function isShieldVerdict(v) {
628
+ return v === "allow" || v === "review" || v === "block";
629
+ }
630
+ function validateOverrides(raw) {
631
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
632
+ const result = {};
633
+ for (const [shieldName, rules] of Object.entries(raw)) {
634
+ if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
635
+ const validRules = {};
636
+ for (const [ruleName, verdict] of Object.entries(rules)) {
637
+ if (isShieldVerdict(verdict)) {
638
+ validRules[ruleName] = verdict;
639
+ } else {
640
+ process.stderr.write(
641
+ `[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
642
+ `
643
+ );
644
+ }
645
+ }
646
+ if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
647
+ }
648
+ return result;
649
+ }
650
+ function readShieldsFile() {
628
651
  try {
629
652
  const raw = fs.readFileSync(SHIELDS_STATE_FILE, "utf-8");
630
- if (!raw.trim()) return [];
653
+ if (!raw.trim()) return { active: [] };
631
654
  const parsed = JSON.parse(raw);
632
- if (Array.isArray(parsed.active)) {
633
- return parsed.active.filter(
634
- (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
635
- );
636
- }
655
+ const active = Array.isArray(parsed.active) ? parsed.active.filter(
656
+ (e) => typeof e === "string" && e.length > 0 && e in SHIELDS
657
+ ) : [];
658
+ return { active, overrides: validateOverrides(parsed.overrides) };
637
659
  } catch (err) {
638
660
  if (err.code !== "ENOENT") {
639
661
  process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
640
662
  `);
641
663
  }
664
+ return { active: [] };
642
665
  }
643
- return [];
666
+ }
667
+ function readActiveShields() {
668
+ return readShieldsFile().active;
669
+ }
670
+ function readShieldOverrides() {
671
+ return readShieldsFile().overrides ?? {};
644
672
  }
645
673
 
646
674
  // src/dlp.ts
@@ -688,9 +716,9 @@ var SENSITIVE_PATH_PATTERNS = [
688
716
  /[/\\][^/\\]+\.key$/i,
689
717
  /[/\\][^/\\]+\.p12$/i,
690
718
  /[/\\][^/\\]+\.pfx$/i,
691
- /^\/etc\/passwd$/,
692
- /^\/etc\/shadow$/,
693
- /^\/etc\/sudoers$/,
719
+ /^(?:[a-zA-Z]:)?\/etc\/passwd$/,
720
+ /^(?:[a-zA-Z]:)?\/etc\/shadow$/,
721
+ /^(?:[a-zA-Z]:)?\/etc\/sudoers$/,
694
722
  /[/\\]credentials\.json$/i,
695
723
  /[/\\]id_rsa$/i,
696
724
  /[/\\]id_ed25519$/i,
@@ -1263,6 +1291,10 @@ var DEFAULT_CONFIG = {
1263
1291
  environments: {}
1264
1292
  };
1265
1293
  var ADVISORY_SMART_RULES = [
1294
+ // ── rm safety ─────────────────────────────────────────────────────────────
1295
+ // tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
1296
+ // Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
1297
+ // chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
1266
1298
  {
1267
1299
  name: "allow-rm-safe-paths",
1268
1300
  tool: "*",
@@ -1285,6 +1317,34 @@ var ADVISORY_SMART_RULES = [
1285
1317
  conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
1286
1318
  verdict: "review",
1287
1319
  reason: "rm can permanently delete files \u2014 confirm the target path"
1320
+ },
1321
+ // ── SQL safety (Safe by Default) ──────────────────────────────────────────
1322
+ // These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
1323
+ // mcp__postgres__query) with a destructive SQL statement in the 'sql' field.
1324
+ // The postgres shield upgrades these from 'review' → 'block' for stricter teams;
1325
+ // without a shield, users still get a human-approval gate on every destructive op.
1326
+ {
1327
+ name: "review-drop-table-sql",
1328
+ tool: "*",
1329
+ conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
1330
+ verdict: "review",
1331
+ reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
1332
+ },
1333
+ {
1334
+ name: "review-truncate-sql",
1335
+ tool: "*",
1336
+ conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
1337
+ verdict: "review",
1338
+ reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
1339
+ },
1340
+ {
1341
+ name: "review-drop-column-sql",
1342
+ tool: "*",
1343
+ conditions: [
1344
+ { field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
1345
+ ],
1346
+ verdict: "review",
1347
+ reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
1288
1348
  }
1289
1349
  ];
1290
1350
  var cachedConfig = null;
@@ -2022,12 +2082,19 @@ function getConfig(cwd) {
2022
2082
  };
2023
2083
  applyLayer(globalConfig);
2024
2084
  applyLayer(projectConfig);
2085
+ const shieldOverrides = readShieldOverrides();
2025
2086
  for (const shieldName of readActiveShields()) {
2026
2087
  const shield = getShield(shieldName);
2027
2088
  if (!shield) continue;
2028
2089
  const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
2090
+ const ruleOverrides = shieldOverrides[shieldName] ?? {};
2029
2091
  for (const rule of shield.smartRules) {
2030
- if (!existingRuleNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
2092
+ if (!existingRuleNames.has(rule.name)) {
2093
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
2094
+ mergedPolicy.smartRules.push(
2095
+ overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
2096
+ );
2097
+ }
2031
2098
  }
2032
2099
  const existingWords = new Set(mergedPolicy.dangerousWords);
2033
2100
  for (const word of shield.dangerousWords) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
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",
@@ -61,9 +61,9 @@
61
61
  "test:e2e": "NODE9_TESTING=1 bash scripts/e2e.sh",
62
62
  "preuninstall": "node9 uninstall || echo 'node9 uninstall failed — remove hooks manually from ~/.claude/settings.json'",
63
63
  "prepublishOnly": "npm run validate",
64
- "test": "NODE_ENV=test vitest --run",
65
- "test:watch": "NODE_ENV=test vitest",
66
- "test:ui": "NODE_ENV=test vitest --ui"
64
+ "test": "vitest --run",
65
+ "test:watch": "vitest",
66
+ "test:ui": "vitest --ui"
67
67
  },
68
68
  "dependencies": {
69
69
  "@inquirer/prompts": "^8.3.0",