@node9/proxy 1.1.3 → 1.1.5

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"() {
@@ -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;
@@ -4515,9 +4628,15 @@ data: ${JSON.stringify(item.data)}
4515
4628
  }
4516
4629
  }
4517
4630
  if (req.method === "GET" && pathname === "/settings") {
4518
- const s = getGlobalSettings();
4519
- res.writeHead(200, { "Content-Type": "application/json" });
4520
- return res.end(JSON.stringify({ ...s, autoStarted }));
4631
+ try {
4632
+ const s = getGlobalSettings();
4633
+ res.writeHead(200, { "Content-Type": "application/json" });
4634
+ return res.end(JSON.stringify({ ...s, autoStarted }));
4635
+ } catch (err) {
4636
+ console.error(import_chalk4.default.red("[node9 daemon] GET /settings failed:"), err);
4637
+ res.writeHead(500, { "Content-Type": "application/json" });
4638
+ return res.end(JSON.stringify({ error: "internal" }));
4639
+ }
4521
4640
  }
4522
4641
  if (req.method === "POST" && pathname === "/settings") {
4523
4642
  if (!validToken(req)) return res.writeHead(403).end();
@@ -4540,9 +4659,15 @@ data: ${JSON.stringify(item.data)}
4540
4659
  }
4541
4660
  }
4542
4661
  if (req.method === "GET" && pathname === "/slack-status") {
4543
- const s = getGlobalSettings();
4544
- res.writeHead(200, { "Content-Type": "application/json" });
4545
- return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
4662
+ try {
4663
+ const s = getGlobalSettings();
4664
+ res.writeHead(200, { "Content-Type": "application/json" });
4665
+ return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
4666
+ } catch (err) {
4667
+ console.error(import_chalk4.default.red("[node9 daemon] GET /slack-status failed:"), err);
4668
+ res.writeHead(500, { "Content-Type": "application/json" });
4669
+ return res.end(JSON.stringify({ error: "internal" }));
4670
+ }
4546
4671
  }
4547
4672
  if (req.method === "POST" && pathname === "/slack-key") {
4548
4673
  if (!validToken(req)) return res.writeHead(403).end();
@@ -4691,6 +4816,13 @@ data: ${JSON.stringify(item.data)}
4691
4816
  console.error(import_chalk4.default.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
4692
4817
  process.exit(1);
4693
4818
  });
4819
+ if (!daemonRejectionHandlerRegistered) {
4820
+ daemonRejectionHandlerRegistered = true;
4821
+ process.on("unhandledRejection", (reason) => {
4822
+ const stack = reason instanceof Error ? reason.stack : String(reason);
4823
+ console.error(import_chalk4.default.red("[node9 daemon] unhandled rejection \u2014 keeping daemon alive:"), stack);
4824
+ });
4825
+ }
4694
4826
  server.listen(DAEMON_PORT2, DAEMON_HOST2, () => {
4695
4827
  atomicWriteSync2(
4696
4828
  DAEMON_PID_FILE,
@@ -4787,7 +4919,7 @@ function daemonStatus() {
4787
4919
  console.log(import_chalk4.default.yellow("Node9 daemon: not running"));
4788
4920
  }
4789
4921
  }
4790
- var import_http, import_net2, import_fs5, import_path7, import_os4, import_child_process3, import_crypto3, import_chalk4, ACTIVITY_SOCKET_PATH2, DAEMON_PORT2, DAEMON_HOST2, homeDir, DAEMON_PID_FILE, DECISIONS_FILE, GLOBAL_CONFIG_FILE, CREDENTIALS_FILE, AUDIT_LOG_FILE, TRUST_FILE2, TRUST_DURATIONS, SECRET_KEY_RE, AUTO_DENY_MS, autoStarted, pending, sseClients, abandonTimer, daemonServer, hadBrowserClient, ACTIVITY_RING_SIZE, activityRing;
4922
+ var import_http, import_net2, import_fs5, import_path7, import_os4, import_child_process3, import_crypto3, import_chalk4, daemonRejectionHandlerRegistered, ACTIVITY_SOCKET_PATH2, DAEMON_PORT2, DAEMON_HOST2, homeDir, DAEMON_PID_FILE, DECISIONS_FILE, GLOBAL_CONFIG_FILE, CREDENTIALS_FILE, AUDIT_LOG_FILE, TRUST_FILE2, TRUST_DURATIONS, SECRET_KEY_RE, AUTO_DENY_MS, autoStarted, pending, sseClients, abandonTimer, daemonServer, hadBrowserClient, ACTIVITY_RING_SIZE, activityRing;
4791
4923
  var init_daemon = __esm({
4792
4924
  "src/daemon/index.ts"() {
4793
4925
  "use strict";
@@ -4802,6 +4934,7 @@ var init_daemon = __esm({
4802
4934
  import_chalk4 = __toESM(require("chalk"));
4803
4935
  init_core();
4804
4936
  init_shields();
4937
+ daemonRejectionHandlerRegistered = false;
4805
4938
  ACTIVITY_SOCKET_PATH2 = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : import_path7.default.join(import_os4.default.tmpdir(), "node9-activity.sock");
4806
4939
  DAEMON_PORT2 = 7391;
4807
4940
  DAEMON_HOST2 = "127.0.0.1";
@@ -6903,42 +7036,219 @@ shieldCmd.command("list").description("Show all available shields").action(() =>
6903
7036
  }
6904
7037
  console.log("");
6905
7038
  });
6906
- shieldCmd.command("status").description("Show which shields are currently active").action(() => {
7039
+ shieldCmd.command("status").description("Show active shields and their individual rules with verdicts").action(() => {
6907
7040
  const active = readActiveShields();
6908
7041
  if (active.length === 0) {
6909
- console.log(import_chalk6.default.yellow("\n\u2139\uFE0F No shields are active.\n"));
6910
- console.log(`Run ${import_chalk6.default.cyan("node9 shield list")} to see available shields.
7042
+ console.error(import_chalk6.default.yellow("\n\u2139\uFE0F No shields are active.\n"));
7043
+ console.error(`Run ${import_chalk6.default.cyan("node9 shield list")} to see available shields.
6911
7044
  `);
6912
7045
  return;
6913
7046
  }
6914
- console.log(import_chalk6.default.bold("\n\u{1F6E1}\uFE0F Active Shields\n"));
7047
+ const overrides = readShieldOverrides();
7048
+ console.error(import_chalk6.default.bold("\n\u{1F6E1}\uFE0F Active Shields\n"));
6915
7049
  for (const name of active) {
6916
7050
  const shield = getShield(name);
6917
7051
  if (!shield) continue;
6918
- console.log(` ${import_chalk6.default.green("\u25CF")} ${import_chalk6.default.cyan(name)}`);
6919
- console.log(
7052
+ console.error(` ${import_chalk6.default.green("\u25CF")} ${import_chalk6.default.cyan(name)} \u2014 ${shield.description}`);
7053
+ const ruleOverrides = overrides[name] ?? {};
7054
+ for (const rule of shield.smartRules) {
7055
+ const shortName = rule.name ? rule.name.replace(`shield:${name}:`, "") : "(unnamed)";
7056
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
7057
+ const effectiveVerdict = overrideVerdict ?? rule.verdict;
7058
+ const verdictLabel = effectiveVerdict === "block" ? import_chalk6.default.red("block ") : effectiveVerdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow ");
7059
+ const overrideNote = overrideVerdict ? import_chalk6.default.gray(` \u2190 overridden (was: ${rule.verdict})`) : "";
7060
+ console.error(
7061
+ ` ${verdictLabel} ${shortName.padEnd(24)} ${import_chalk6.default.gray(rule.reason ?? "")}${overrideNote}`
7062
+ );
7063
+ }
7064
+ if (shield.dangerousWords.length > 0) {
7065
+ console.error(import_chalk6.default.gray(` words: ${shield.dangerousWords.join(", ")}`));
7066
+ }
7067
+ console.error("");
7068
+ }
7069
+ if (Object.keys(overrides).length > 0) {
7070
+ console.error(
6920
7071
  import_chalk6.default.gray(
6921
- ` ${shield.smartRules.length} smart rules \xB7 ${shield.dangerousWords.length} dangerous words`
7072
+ ` Tip: run ${import_chalk6.default.cyan("node9 shield unset <shield> <rule>")} to remove an override.
7073
+ `
6922
7074
  )
6923
7075
  );
6924
7076
  }
6925
- console.log("");
6926
7077
  });
6927
- process.on("unhandledRejection", (reason) => {
6928
- const isCheckHook = process.argv[2] === "check";
6929
- if (isCheckHook) {
6930
- if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
6931
- const logPath = import_path10.default.join(import_os7.default.homedir(), ".node9", "hook-debug.log");
6932
- const msg = reason instanceof Error ? reason.message : String(reason);
6933
- import_fs8.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
7078
+ 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) => {
7079
+ const name = resolveShieldName(service);
7080
+ if (!name) {
7081
+ console.error(import_chalk6.default.red(`
7082
+ \u274C Unknown shield: "${service}"
7083
+ `));
7084
+ console.error(`Run ${import_chalk6.default.cyan("node9 shield list")} to see available shields.
6934
7085
  `);
7086
+ process.exit(1);
7087
+ }
7088
+ if (!readActiveShields().includes(name)) {
7089
+ console.error(import_chalk6.default.red(`
7090
+ \u274C Shield "${name}" is not active. Enable it first:
7091
+ `));
7092
+ console.error(` ${import_chalk6.default.cyan(`node9 shield enable ${name}`)}
7093
+ `);
7094
+ process.exit(1);
7095
+ }
7096
+ if (!isShieldVerdict(verdict)) {
7097
+ console.error(import_chalk6.default.red(`
7098
+ \u274C Invalid verdict "${verdict}". Use: block, review, or allow
7099
+ `));
7100
+ process.exit(1);
7101
+ }
7102
+ if (verdict === "allow" && !opts.force) {
7103
+ console.error(
7104
+ import_chalk6.default.red(`
7105
+ \u26A0\uFE0F Setting a verdict to "allow" silences the rule entirely.
7106
+ `) + import_chalk6.default.yellow(
7107
+ ` This disables a shield protection. If you are sure, re-run with --force:
7108
+ `
7109
+ ) + import_chalk6.default.cyan(`
7110
+ node9 shield set ${service} ${rule} allow --force
7111
+ `)
7112
+ );
7113
+ process.exit(1);
7114
+ }
7115
+ const ruleName = resolveShieldRule(name, rule);
7116
+ if (!ruleName) {
7117
+ const shield = getShield(name);
7118
+ console.error(import_chalk6.default.red(`
7119
+ \u274C Unknown rule "${rule}" for shield "${name}".
7120
+ `));
7121
+ console.error(" Available rules:");
7122
+ for (const r of shield?.smartRules ?? []) {
7123
+ const short = r.name ? r.name.replace(`shield:${name}:`, "") : "";
7124
+ console.error(` ${import_chalk6.default.cyan(short)}`);
6935
7125
  }
6936
- process.exit(0);
7126
+ console.error("");
7127
+ process.exit(1);
7128
+ }
7129
+ writeShieldOverride(name, ruleName, verdict);
7130
+ if (verdict === "allow") {
7131
+ appendConfigAudit({ event: "shield-override-allow", shield: name, rule: ruleName });
7132
+ }
7133
+ const shortName = ruleName.replace(`shield:${name}:`, "");
7134
+ const verdictLabel = verdict === "block" ? import_chalk6.default.red("block") : verdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow");
7135
+ if (verdict === "allow") {
7136
+ console.error(
7137
+ import_chalk6.default.yellow(`
7138
+ \u26A0\uFE0F ${name}/${shortName} \u2192 ${verdictLabel}`) + import_chalk6.default.gray(" (rule silenced \u2014 use `node9 shield unset` to restore)\n")
7139
+ );
6937
7140
  } else {
6938
- console.error("[Node9] Unhandled error:", reason);
7141
+ console.error(import_chalk6.default.green(`
7142
+ \u2705 ${name}/${shortName} \u2192 ${verdictLabel}
7143
+ `));
7144
+ }
7145
+ console.error(
7146
+ import_chalk6.default.gray(` Run ${import_chalk6.default.cyan("node9 shield status")} to see all active rules.
7147
+ `)
7148
+ );
7149
+ });
7150
+ shieldCmd.command("unset <service> <rule>").description("Remove a verdict override, restoring the shield default").action((service, rule) => {
7151
+ const name = resolveShieldName(service);
7152
+ if (!name) {
7153
+ console.error(import_chalk6.default.red(`
7154
+ \u274C Unknown shield: "${service}"
7155
+ `));
7156
+ process.exit(1);
7157
+ }
7158
+ const ruleName = resolveShieldRule(name, rule);
7159
+ if (!ruleName) {
7160
+ console.error(import_chalk6.default.red(`
7161
+ \u274C Unknown rule "${rule}" for shield "${name}".
7162
+ `));
6939
7163
  process.exit(1);
6940
7164
  }
7165
+ clearShieldOverride(name, ruleName);
7166
+ const shortName = ruleName.replace(`shield:${name}:`, "");
7167
+ console.error(
7168
+ import_chalk6.default.green(`
7169
+ \u2705 Override removed \u2014 ${name}/${shortName} restored to default.
7170
+ `)
7171
+ );
7172
+ });
7173
+ program.command("config show").description("Show the full effective runtime configuration including shields and advisory rules").action(() => {
7174
+ const config = getConfig();
7175
+ const active = readActiveShields();
7176
+ const overrides = readShieldOverrides();
7177
+ console.error(import_chalk6.default.bold("\n\u{1F50D} Node9 Effective Configuration\n"));
7178
+ 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");
7179
+ console.error(` Mode: ${modeLabel}
7180
+ `);
7181
+ if (active.length > 0) {
7182
+ 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"));
7183
+ for (const name of active) {
7184
+ const shield = getShield(name);
7185
+ if (!shield) continue;
7186
+ const ruleOverrides = overrides[name] ?? {};
7187
+ console.error(`
7188
+ ${import_chalk6.default.green("\u25CF")} ${import_chalk6.default.cyan(name)}`);
7189
+ for (const rule of shield.smartRules) {
7190
+ const shortName = rule.name ? rule.name.replace(`shield:${name}:`, "") : "(unnamed)";
7191
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
7192
+ const effectiveVerdict = overrideVerdict ?? rule.verdict;
7193
+ const vLabel = effectiveVerdict === "block" ? import_chalk6.default.red("block ") : effectiveVerdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow ");
7194
+ const note = overrideVerdict ? import_chalk6.default.gray(` \u2190 overridden`) : "";
7195
+ console.error(` ${vLabel} ${shortName}${note}`);
7196
+ }
7197
+ }
7198
+ console.error("");
7199
+ } else {
7200
+ console.error(import_chalk6.default.gray(" No shields active. Run `node9 shield list` to see options.\n"));
7201
+ }
7202
+ 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"));
7203
+ for (const rule of config.policy.smartRules) {
7204
+ const isShieldRule = rule.name?.startsWith("shield:");
7205
+ const isAdvisory = [
7206
+ "review-rm",
7207
+ "allow-rm-safe-paths",
7208
+ "review-drop-table-sql",
7209
+ "review-truncate-sql",
7210
+ "review-drop-column-sql"
7211
+ ].includes(rule.name ?? "");
7212
+ if (isShieldRule || isAdvisory) continue;
7213
+ const vLabel = rule.verdict === "block" ? import_chalk6.default.red("block ") : rule.verdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow ");
7214
+ console.error(` ${vLabel} ${import_chalk6.default.gray(rule.name ?? rule.tool)}`);
7215
+ }
7216
+ console.error("");
7217
+ 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"));
7218
+ const advisoryNames = /* @__PURE__ */ new Set([
7219
+ "review-rm",
7220
+ "allow-rm-safe-paths",
7221
+ "review-drop-table-sql",
7222
+ "review-truncate-sql",
7223
+ "review-drop-column-sql"
7224
+ ]);
7225
+ for (const rule of config.policy.smartRules) {
7226
+ if (!advisoryNames.has(rule.name ?? "")) continue;
7227
+ const vLabel = rule.verdict === "block" ? import_chalk6.default.red("block ") : rule.verdict === "review" ? import_chalk6.default.yellow("review") : import_chalk6.default.green("allow ");
7228
+ console.error(` ${vLabel} ${import_chalk6.default.gray(rule.name ?? rule.tool)}`);
7229
+ }
7230
+ console.error("");
7231
+ 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"));
7232
+ console.error(` ${import_chalk6.default.gray(config.policy.dangerousWords.join(", "))}
7233
+ `);
6941
7234
  });
7235
+ if (process.argv[2] !== "daemon") {
7236
+ process.on("unhandledRejection", (reason) => {
7237
+ const isCheckHook = process.argv[2] === "check";
7238
+ if (isCheckHook) {
7239
+ if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
7240
+ const logPath = import_path10.default.join(import_os7.default.homedir(), ".node9", "hook-debug.log");
7241
+ const msg = reason instanceof Error ? reason.message : String(reason);
7242
+ import_fs8.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
7243
+ `);
7244
+ }
7245
+ process.exit(0);
7246
+ } else {
7247
+ console.error("[Node9] Unhandled error:", reason);
7248
+ process.exit(1);
7249
+ }
7250
+ });
7251
+ }
6942
7252
  var knownSubcommands = new Set(program.commands.map((c) => c.name()));
6943
7253
  var firstArg = process.argv[2];
6944
7254
  if (firstArg && firstArg !== "--" && !firstArg.startsWith("-") && !knownSubcommands.has(firstArg)) {
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"() {
@@ -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;
@@ -4502,9 +4615,15 @@ data: ${JSON.stringify(item.data)}
4502
4615
  }
4503
4616
  }
4504
4617
  if (req.method === "GET" && pathname === "/settings") {
4505
- const s = getGlobalSettings();
4506
- res.writeHead(200, { "Content-Type": "application/json" });
4507
- return res.end(JSON.stringify({ ...s, autoStarted }));
4618
+ try {
4619
+ const s = getGlobalSettings();
4620
+ res.writeHead(200, { "Content-Type": "application/json" });
4621
+ return res.end(JSON.stringify({ ...s, autoStarted }));
4622
+ } catch (err) {
4623
+ console.error(chalk4.red("[node9 daemon] GET /settings failed:"), err);
4624
+ res.writeHead(500, { "Content-Type": "application/json" });
4625
+ return res.end(JSON.stringify({ error: "internal" }));
4626
+ }
4508
4627
  }
4509
4628
  if (req.method === "POST" && pathname === "/settings") {
4510
4629
  if (!validToken(req)) return res.writeHead(403).end();
@@ -4527,9 +4646,15 @@ data: ${JSON.stringify(item.data)}
4527
4646
  }
4528
4647
  }
4529
4648
  if (req.method === "GET" && pathname === "/slack-status") {
4530
- const s = getGlobalSettings();
4531
- res.writeHead(200, { "Content-Type": "application/json" });
4532
- return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
4649
+ try {
4650
+ const s = getGlobalSettings();
4651
+ res.writeHead(200, { "Content-Type": "application/json" });
4652
+ return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
4653
+ } catch (err) {
4654
+ console.error(chalk4.red("[node9 daemon] GET /slack-status failed:"), err);
4655
+ res.writeHead(500, { "Content-Type": "application/json" });
4656
+ return res.end(JSON.stringify({ error: "internal" }));
4657
+ }
4533
4658
  }
4534
4659
  if (req.method === "POST" && pathname === "/slack-key") {
4535
4660
  if (!validToken(req)) return res.writeHead(403).end();
@@ -4678,6 +4803,13 @@ data: ${JSON.stringify(item.data)}
4678
4803
  console.error(chalk4.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
4679
4804
  process.exit(1);
4680
4805
  });
4806
+ if (!daemonRejectionHandlerRegistered) {
4807
+ daemonRejectionHandlerRegistered = true;
4808
+ process.on("unhandledRejection", (reason) => {
4809
+ const stack = reason instanceof Error ? reason.stack : String(reason);
4810
+ console.error(chalk4.red("[node9 daemon] unhandled rejection \u2014 keeping daemon alive:"), stack);
4811
+ });
4812
+ }
4681
4813
  server.listen(DAEMON_PORT2, DAEMON_HOST2, () => {
4682
4814
  atomicWriteSync2(
4683
4815
  DAEMON_PID_FILE,
@@ -4774,13 +4906,14 @@ function daemonStatus() {
4774
4906
  console.log(chalk4.yellow("Node9 daemon: not running"));
4775
4907
  }
4776
4908
  }
4777
- var ACTIVITY_SOCKET_PATH2, DAEMON_PORT2, DAEMON_HOST2, homeDir, DAEMON_PID_FILE, DECISIONS_FILE, GLOBAL_CONFIG_FILE, CREDENTIALS_FILE, AUDIT_LOG_FILE, TRUST_FILE2, TRUST_DURATIONS, SECRET_KEY_RE, AUTO_DENY_MS, autoStarted, pending, sseClients, abandonTimer, daemonServer, hadBrowserClient, ACTIVITY_RING_SIZE, activityRing;
4909
+ var daemonRejectionHandlerRegistered, ACTIVITY_SOCKET_PATH2, DAEMON_PORT2, DAEMON_HOST2, homeDir, DAEMON_PID_FILE, DECISIONS_FILE, GLOBAL_CONFIG_FILE, CREDENTIALS_FILE, AUDIT_LOG_FILE, TRUST_FILE2, TRUST_DURATIONS, SECRET_KEY_RE, AUTO_DENY_MS, autoStarted, pending, sseClients, abandonTimer, daemonServer, hadBrowserClient, ACTIVITY_RING_SIZE, activityRing;
4778
4910
  var init_daemon = __esm({
4779
4911
  "src/daemon/index.ts"() {
4780
4912
  "use strict";
4781
4913
  init_ui2();
4782
4914
  init_core();
4783
4915
  init_shields();
4916
+ daemonRejectionHandlerRegistered = false;
4784
4917
  ACTIVITY_SOCKET_PATH2 = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path7.join(os4.tmpdir(), "node9-activity.sock");
4785
4918
  DAEMON_PORT2 = 7391;
4786
4919
  DAEMON_HOST2 = "127.0.0.1";
@@ -6882,42 +7015,219 @@ shieldCmd.command("list").description("Show all available shields").action(() =>
6882
7015
  }
6883
7016
  console.log("");
6884
7017
  });
6885
- shieldCmd.command("status").description("Show which shields are currently active").action(() => {
7018
+ shieldCmd.command("status").description("Show active shields and their individual rules with verdicts").action(() => {
6886
7019
  const active = readActiveShields();
6887
7020
  if (active.length === 0) {
6888
- console.log(chalk6.yellow("\n\u2139\uFE0F No shields are active.\n"));
6889
- console.log(`Run ${chalk6.cyan("node9 shield list")} to see available shields.
7021
+ console.error(chalk6.yellow("\n\u2139\uFE0F No shields are active.\n"));
7022
+ console.error(`Run ${chalk6.cyan("node9 shield list")} to see available shields.
6890
7023
  `);
6891
7024
  return;
6892
7025
  }
6893
- console.log(chalk6.bold("\n\u{1F6E1}\uFE0F Active Shields\n"));
7026
+ const overrides = readShieldOverrides();
7027
+ console.error(chalk6.bold("\n\u{1F6E1}\uFE0F Active Shields\n"));
6894
7028
  for (const name of active) {
6895
7029
  const shield = getShield(name);
6896
7030
  if (!shield) continue;
6897
- console.log(` ${chalk6.green("\u25CF")} ${chalk6.cyan(name)}`);
6898
- console.log(
7031
+ console.error(` ${chalk6.green("\u25CF")} ${chalk6.cyan(name)} \u2014 ${shield.description}`);
7032
+ const ruleOverrides = overrides[name] ?? {};
7033
+ for (const rule of shield.smartRules) {
7034
+ const shortName = rule.name ? rule.name.replace(`shield:${name}:`, "") : "(unnamed)";
7035
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
7036
+ const effectiveVerdict = overrideVerdict ?? rule.verdict;
7037
+ const verdictLabel = effectiveVerdict === "block" ? chalk6.red("block ") : effectiveVerdict === "review" ? chalk6.yellow("review") : chalk6.green("allow ");
7038
+ const overrideNote = overrideVerdict ? chalk6.gray(` \u2190 overridden (was: ${rule.verdict})`) : "";
7039
+ console.error(
7040
+ ` ${verdictLabel} ${shortName.padEnd(24)} ${chalk6.gray(rule.reason ?? "")}${overrideNote}`
7041
+ );
7042
+ }
7043
+ if (shield.dangerousWords.length > 0) {
7044
+ console.error(chalk6.gray(` words: ${shield.dangerousWords.join(", ")}`));
7045
+ }
7046
+ console.error("");
7047
+ }
7048
+ if (Object.keys(overrides).length > 0) {
7049
+ console.error(
6899
7050
  chalk6.gray(
6900
- ` ${shield.smartRules.length} smart rules \xB7 ${shield.dangerousWords.length} dangerous words`
7051
+ ` Tip: run ${chalk6.cyan("node9 shield unset <shield> <rule>")} to remove an override.
7052
+ `
6901
7053
  )
6902
7054
  );
6903
7055
  }
6904
- console.log("");
6905
7056
  });
6906
- process.on("unhandledRejection", (reason) => {
6907
- const isCheckHook = process.argv[2] === "check";
6908
- if (isCheckHook) {
6909
- if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
6910
- const logPath = path10.join(os7.homedir(), ".node9", "hook-debug.log");
6911
- const msg = reason instanceof Error ? reason.message : String(reason);
6912
- fs8.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
7057
+ 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) => {
7058
+ const name = resolveShieldName(service);
7059
+ if (!name) {
7060
+ console.error(chalk6.red(`
7061
+ \u274C Unknown shield: "${service}"
7062
+ `));
7063
+ console.error(`Run ${chalk6.cyan("node9 shield list")} to see available shields.
6913
7064
  `);
7065
+ process.exit(1);
7066
+ }
7067
+ if (!readActiveShields().includes(name)) {
7068
+ console.error(chalk6.red(`
7069
+ \u274C Shield "${name}" is not active. Enable it first:
7070
+ `));
7071
+ console.error(` ${chalk6.cyan(`node9 shield enable ${name}`)}
7072
+ `);
7073
+ process.exit(1);
7074
+ }
7075
+ if (!isShieldVerdict(verdict)) {
7076
+ console.error(chalk6.red(`
7077
+ \u274C Invalid verdict "${verdict}". Use: block, review, or allow
7078
+ `));
7079
+ process.exit(1);
7080
+ }
7081
+ if (verdict === "allow" && !opts.force) {
7082
+ console.error(
7083
+ chalk6.red(`
7084
+ \u26A0\uFE0F Setting a verdict to "allow" silences the rule entirely.
7085
+ `) + chalk6.yellow(
7086
+ ` This disables a shield protection. If you are sure, re-run with --force:
7087
+ `
7088
+ ) + chalk6.cyan(`
7089
+ node9 shield set ${service} ${rule} allow --force
7090
+ `)
7091
+ );
7092
+ process.exit(1);
7093
+ }
7094
+ const ruleName = resolveShieldRule(name, rule);
7095
+ if (!ruleName) {
7096
+ const shield = getShield(name);
7097
+ console.error(chalk6.red(`
7098
+ \u274C Unknown rule "${rule}" for shield "${name}".
7099
+ `));
7100
+ console.error(" Available rules:");
7101
+ for (const r of shield?.smartRules ?? []) {
7102
+ const short = r.name ? r.name.replace(`shield:${name}:`, "") : "";
7103
+ console.error(` ${chalk6.cyan(short)}`);
6914
7104
  }
6915
- process.exit(0);
7105
+ console.error("");
7106
+ process.exit(1);
7107
+ }
7108
+ writeShieldOverride(name, ruleName, verdict);
7109
+ if (verdict === "allow") {
7110
+ appendConfigAudit({ event: "shield-override-allow", shield: name, rule: ruleName });
7111
+ }
7112
+ const shortName = ruleName.replace(`shield:${name}:`, "");
7113
+ const verdictLabel = verdict === "block" ? chalk6.red("block") : verdict === "review" ? chalk6.yellow("review") : chalk6.green("allow");
7114
+ if (verdict === "allow") {
7115
+ console.error(
7116
+ chalk6.yellow(`
7117
+ \u26A0\uFE0F ${name}/${shortName} \u2192 ${verdictLabel}`) + chalk6.gray(" (rule silenced \u2014 use `node9 shield unset` to restore)\n")
7118
+ );
6916
7119
  } else {
6917
- console.error("[Node9] Unhandled error:", reason);
7120
+ console.error(chalk6.green(`
7121
+ \u2705 ${name}/${shortName} \u2192 ${verdictLabel}
7122
+ `));
7123
+ }
7124
+ console.error(
7125
+ chalk6.gray(` Run ${chalk6.cyan("node9 shield status")} to see all active rules.
7126
+ `)
7127
+ );
7128
+ });
7129
+ shieldCmd.command("unset <service> <rule>").description("Remove a verdict override, restoring the shield default").action((service, rule) => {
7130
+ const name = resolveShieldName(service);
7131
+ if (!name) {
7132
+ console.error(chalk6.red(`
7133
+ \u274C Unknown shield: "${service}"
7134
+ `));
7135
+ process.exit(1);
7136
+ }
7137
+ const ruleName = resolveShieldRule(name, rule);
7138
+ if (!ruleName) {
7139
+ console.error(chalk6.red(`
7140
+ \u274C Unknown rule "${rule}" for shield "${name}".
7141
+ `));
6918
7142
  process.exit(1);
6919
7143
  }
7144
+ clearShieldOverride(name, ruleName);
7145
+ const shortName = ruleName.replace(`shield:${name}:`, "");
7146
+ console.error(
7147
+ chalk6.green(`
7148
+ \u2705 Override removed \u2014 ${name}/${shortName} restored to default.
7149
+ `)
7150
+ );
7151
+ });
7152
+ program.command("config show").description("Show the full effective runtime configuration including shields and advisory rules").action(() => {
7153
+ const config = getConfig();
7154
+ const active = readActiveShields();
7155
+ const overrides = readShieldOverrides();
7156
+ console.error(chalk6.bold("\n\u{1F50D} Node9 Effective Configuration\n"));
7157
+ const modeLabel = config.settings.mode === "audit" ? chalk6.blue("audit") : config.settings.mode === "strict" ? chalk6.red("strict") : chalk6.white("standard");
7158
+ console.error(` Mode: ${modeLabel}
7159
+ `);
7160
+ if (active.length > 0) {
7161
+ 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"));
7162
+ for (const name of active) {
7163
+ const shield = getShield(name);
7164
+ if (!shield) continue;
7165
+ const ruleOverrides = overrides[name] ?? {};
7166
+ console.error(`
7167
+ ${chalk6.green("\u25CF")} ${chalk6.cyan(name)}`);
7168
+ for (const rule of shield.smartRules) {
7169
+ const shortName = rule.name ? rule.name.replace(`shield:${name}:`, "") : "(unnamed)";
7170
+ const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
7171
+ const effectiveVerdict = overrideVerdict ?? rule.verdict;
7172
+ const vLabel = effectiveVerdict === "block" ? chalk6.red("block ") : effectiveVerdict === "review" ? chalk6.yellow("review") : chalk6.green("allow ");
7173
+ const note = overrideVerdict ? chalk6.gray(` \u2190 overridden`) : "";
7174
+ console.error(` ${vLabel} ${shortName}${note}`);
7175
+ }
7176
+ }
7177
+ console.error("");
7178
+ } else {
7179
+ console.error(chalk6.gray(" No shields active. Run `node9 shield list` to see options.\n"));
7180
+ }
7181
+ 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"));
7182
+ for (const rule of config.policy.smartRules) {
7183
+ const isShieldRule = rule.name?.startsWith("shield:");
7184
+ const isAdvisory = [
7185
+ "review-rm",
7186
+ "allow-rm-safe-paths",
7187
+ "review-drop-table-sql",
7188
+ "review-truncate-sql",
7189
+ "review-drop-column-sql"
7190
+ ].includes(rule.name ?? "");
7191
+ if (isShieldRule || isAdvisory) continue;
7192
+ const vLabel = rule.verdict === "block" ? chalk6.red("block ") : rule.verdict === "review" ? chalk6.yellow("review") : chalk6.green("allow ");
7193
+ console.error(` ${vLabel} ${chalk6.gray(rule.name ?? rule.tool)}`);
7194
+ }
7195
+ console.error("");
7196
+ 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"));
7197
+ const advisoryNames = /* @__PURE__ */ new Set([
7198
+ "review-rm",
7199
+ "allow-rm-safe-paths",
7200
+ "review-drop-table-sql",
7201
+ "review-truncate-sql",
7202
+ "review-drop-column-sql"
7203
+ ]);
7204
+ for (const rule of config.policy.smartRules) {
7205
+ if (!advisoryNames.has(rule.name ?? "")) continue;
7206
+ const vLabel = rule.verdict === "block" ? chalk6.red("block ") : rule.verdict === "review" ? chalk6.yellow("review") : chalk6.green("allow ");
7207
+ console.error(` ${vLabel} ${chalk6.gray(rule.name ?? rule.tool)}`);
7208
+ }
7209
+ console.error("");
7210
+ 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"));
7211
+ console.error(` ${chalk6.gray(config.policy.dangerousWords.join(", "))}
7212
+ `);
6920
7213
  });
7214
+ if (process.argv[2] !== "daemon") {
7215
+ process.on("unhandledRejection", (reason) => {
7216
+ const isCheckHook = process.argv[2] === "check";
7217
+ if (isCheckHook) {
7218
+ if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
7219
+ const logPath = path10.join(os7.homedir(), ".node9", "hook-debug.log");
7220
+ const msg = reason instanceof Error ? reason.message : String(reason);
7221
+ fs8.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
7222
+ `);
7223
+ }
7224
+ process.exit(0);
7225
+ } else {
7226
+ console.error("[Node9] Unhandled error:", reason);
7227
+ process.exit(1);
7228
+ }
7229
+ });
7230
+ }
6921
7231
  var knownSubcommands = new Set(program.commands.map((c) => c.name()));
6922
7232
  var firstArg = process.argv[2];
6923
7233
  if (firstArg && firstArg !== "--" && !firstArg.startsWith("-") && !knownSubcommands.has(firstArg)) {
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
@@ -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
@@ -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.3",
3
+ "version": "1.1.5",
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",