@node9/policy-engine 1.32.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -971,7 +971,7 @@ declare const LONG_OUTPUT_THRESHOLD_BYTES: number;
971
971
  * and fails CI when the hash drifts without a version bump — forgetting
972
972
  * is loud, not silent.
973
973
  */
974
- declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
974
+ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v6";
975
975
  /**
976
976
  * SHA-256 prefix of the detector-source files
977
977
  * (canonical.ts + pii.ts + destructive-regex.ts).
@@ -982,7 +982,7 @@ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
982
982
  * files changed, this hash must change too, and you must consciously
983
983
  * decide whether to bump CANONICAL_EXTRACTOR_VERSION."
984
984
  */
985
- declare const CANONICAL_EXTRACTOR_HASH = "dbe0199dae0f29f6";
985
+ declare const CANONICAL_EXTRACTOR_HASH = "a0e2bb339fe67e19";
986
986
  declare function extractCanonicalFindings(call: ToolCallEntry, ctx: ExtractContext): CanonicalFinding[];
987
987
  declare function extractSessionLevelFindings(calls: ReadonlyArray<SessionToolCall>, ctx: SessionExtractContext): CanonicalFinding[];
988
988
  /**
package/dist/index.d.ts CHANGED
@@ -971,7 +971,7 @@ declare const LONG_OUTPUT_THRESHOLD_BYTES: number;
971
971
  * and fails CI when the hash drifts without a version bump — forgetting
972
972
  * is loud, not silent.
973
973
  */
974
- declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
974
+ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v6";
975
975
  /**
976
976
  * SHA-256 prefix of the detector-source files
977
977
  * (canonical.ts + pii.ts + destructive-regex.ts).
@@ -982,7 +982,7 @@ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
982
982
  * files changed, this hash must change too, and you must consciously
983
983
  * decide whether to bump CANONICAL_EXTRACTOR_VERSION."
984
984
  */
985
- declare const CANONICAL_EXTRACTOR_HASH = "dbe0199dae0f29f6";
985
+ declare const CANONICAL_EXTRACTOR_HASH = "a0e2bb339fe67e19";
986
986
  declare function extractCanonicalFindings(call: ToolCallEntry, ctx: ExtractContext): CanonicalFinding[];
987
987
  declare function extractSessionLevelFindings(calls: ReadonlyArray<SessionToolCall>, ctx: SessionExtractContext): CanonicalFinding[];
988
988
  /**
package/dist/index.js CHANGED
@@ -829,6 +829,8 @@ function normalizeCommandForPolicyImpl(command) {
829
829
  if (f === PARSE_FAIL) return command;
830
830
  try {
831
831
  const strips = [];
832
+ const rewrites = [];
833
+ const msgSpans = /* @__PURE__ */ new Set();
832
834
  syntax.Walk(f, (node) => {
833
835
  if (!node) return false;
834
836
  const n = node;
@@ -844,25 +846,46 @@ function normalizeCommandForPolicyImpl(command) {
844
846
  if (nextParts.length !== 1) continue;
845
847
  const quotedNode = nextParts[0];
846
848
  const nt = syntax.NodeType(quotedNode);
849
+ const markStrip = () => {
850
+ const s = next.Pos().Offset();
851
+ const e = next.End().Offset();
852
+ strips.push([s, e]);
853
+ msgSpans.add(`${s}:${e}`);
854
+ };
847
855
  if (nt === "SglQuoted") {
848
- strips.push([next.Pos().Offset(), next.End().Offset()]);
856
+ markStrip();
849
857
  } else if (nt === "DblQuoted") {
850
858
  const innerParts = quotedNode.Parts || [];
851
859
  const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
852
860
  if (allLit) {
853
- strips.push([next.Pos().Offset(), next.End().Offset()]);
861
+ markStrip();
854
862
  } else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
855
- strips.push([next.Pos().Offset(), next.End().Offset()]);
863
+ markStrip();
856
864
  }
857
865
  }
858
866
  }
867
+ for (const arg of args) {
868
+ const s = arg.Pos().Offset();
869
+ const e = arg.End().Offset();
870
+ if (msgSpans.has(`${s}:${e}`)) continue;
871
+ const resolved = resolveWordLiteral(arg);
872
+ if (resolved === null) continue;
873
+ const source = command.slice(s, e);
874
+ if (resolved === source) continue;
875
+ if (resolved === "" || /\s/.test(resolved)) continue;
876
+ rewrites.push([s, e, resolved]);
877
+ }
859
878
  return true;
860
879
  });
861
- if (strips.length === 0) return command;
862
- strips.sort((a, b) => b[0] - a[0]);
880
+ const edits = [
881
+ ...strips.map(([s, e]) => [s, e, '""']),
882
+ ...rewrites
883
+ ];
884
+ if (edits.length === 0) return command;
885
+ edits.sort((a, b) => b[0] - a[0]);
863
886
  let result = command;
864
- for (const [start, end] of strips) {
865
- result = result.slice(0, start) + '""' + result.slice(end);
887
+ for (const [s, e, rep] of edits) {
888
+ result = result.slice(0, s) + rep + result.slice(e);
866
889
  }
867
890
  return result;
868
891
  } catch {
@@ -1028,8 +1051,35 @@ var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
1028
1051
  "shield:project-jail:block-read-ssh",
1029
1052
  "shield:project-jail:block-read-aws",
1030
1053
  "shield:project-jail:block-read-env",
1031
- "shield:project-jail:review-read-credentials"
1054
+ "shield:project-jail:review-read-credentials",
1055
+ // SQL-DDL is now owned by the AST detector (analyzeSqlDestructive) so the
1056
+ // raw-regex smart rule is suppressed for bash — its cond1 read a grep
1057
+ // alternation's `|` as a shell pipe (`grep "…|mysql…"` → false positive).
1058
+ "review-drop-truncate-shell"
1059
+ ]);
1060
+ var SQL_DB_CLIS = /* @__PURE__ */ new Set([
1061
+ "psql",
1062
+ "mysql",
1063
+ "mariadb",
1064
+ "sqlite3",
1065
+ "sqlplus",
1066
+ "cockroach",
1067
+ "clickhouse-client",
1068
+ "mongo",
1069
+ "mongosh"
1032
1070
  ]);
1071
+ var SQL_DDL_RE = /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA|INDEX)\b/i;
1072
+ function analyzeSqlDestructive(command) {
1073
+ if (!SQL_DDL_RE.test(command)) return null;
1074
+ const { actions } = analyzeShellCommand(command);
1075
+ if (!actions.some((a) => SQL_DB_CLIS.has(a))) return null;
1076
+ return {
1077
+ ruleName: "review-drop-truncate-shell",
1078
+ verdict: "review",
1079
+ reason: "SQL DDL destructive statement inside a shell command",
1080
+ description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
1081
+ };
1082
+ }
1033
1083
  function isProtectedHomePath(rawPath) {
1034
1084
  let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
1035
1085
  let underHome = false;
@@ -1278,19 +1328,20 @@ function extractShellDestinations(command) {
1278
1328
  var FS_OP_CACHE_MAX = 5e3;
1279
1329
  var fsOpCache = /* @__PURE__ */ new Map();
1280
1330
  function analyzeFsOperation(command) {
1281
- if (!FS_OP_PRESCREEN_RE.test(command)) return null;
1282
- if (fsOpCache.has(command)) {
1283
- const hit = fsOpCache.get(command) ?? null;
1284
- fsOpCache.delete(command);
1285
- fsOpCache.set(command, hit);
1331
+ const normalized = normalizeCommandForPolicy(command);
1332
+ if (!FS_OP_PRESCREEN_RE.test(normalized)) return null;
1333
+ if (fsOpCache.has(normalized)) {
1334
+ const hit = fsOpCache.get(normalized) ?? null;
1335
+ fsOpCache.delete(normalized);
1336
+ fsOpCache.set(normalized, hit);
1286
1337
  return hit;
1287
1338
  }
1288
- const computed = analyzeFsOperationImpl(command);
1339
+ const computed = analyzeFsOperationImpl(normalized);
1289
1340
  if (fsOpCache.size >= FS_OP_CACHE_MAX) {
1290
1341
  const oldest = fsOpCache.keys().next().value;
1291
1342
  if (oldest !== void 0) fsOpCache.delete(oldest);
1292
1343
  }
1293
- fsOpCache.set(command, computed);
1344
+ fsOpCache.set(normalized, computed);
1294
1345
  return computed;
1295
1346
  }
1296
1347
  function analyzeFsOperationImpl(command) {
@@ -2014,6 +2065,17 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2014
2065
  ruleDescription: fsVerdict.reason
2015
2066
  };
2016
2067
  }
2068
+ const sqlVerdict = analyzeSqlDestructive(bashCommand);
2069
+ if (sqlVerdict) {
2070
+ return {
2071
+ decision: sqlVerdict.verdict,
2072
+ blockedByLabel: `Node9 (AST): ${sqlVerdict.ruleName}`,
2073
+ reason: sqlVerdict.reason,
2074
+ tier: 2,
2075
+ ruleName: sqlVerdict.ruleName,
2076
+ ruleDescription: sqlVerdict.description
2077
+ };
2078
+ }
2017
2079
  }
2018
2080
  if (config.policy.smartRules.length > 0) {
2019
2081
  const matchedRule = config.policy.smartRules.find(
@@ -3329,8 +3391,8 @@ function detectArgsPii(args) {
3329
3391
 
3330
3392
  // src/scan/canonical.ts
3331
3393
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3332
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
3333
- var CANONICAL_EXTRACTOR_HASH = "dbe0199dae0f29f6";
3394
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v6";
3395
+ var CANONICAL_EXTRACTOR_HASH = "a0e2bb339fe67e19";
3334
3396
  var DEDUPE_PREVIEW_LEN = 120;
3335
3397
  function extractCanonicalFindings(call, ctx) {
3336
3398
  const out = [];
@@ -3492,7 +3554,7 @@ function extractCanonicalFindings(call, ctx) {
3492
3554
  })
3493
3555
  );
3494
3556
  }
3495
- if (DESTRUCTIVE_OP_RE.test(command)) {
3557
+ if (command !== null && DESTRUCTIVE_OP_RE.test(normalizeCommandForPolicy(command))) {
3496
3558
  out.push(
3497
3559
  makeFinding({
3498
3560
  type: "destructive-op",
package/dist/index.mjs CHANGED
@@ -722,6 +722,8 @@ function normalizeCommandForPolicyImpl(command) {
722
722
  if (f === PARSE_FAIL) return command;
723
723
  try {
724
724
  const strips = [];
725
+ const rewrites = [];
726
+ const msgSpans = /* @__PURE__ */ new Set();
725
727
  syntax.Walk(f, (node) => {
726
728
  if (!node) return false;
727
729
  const n = node;
@@ -737,25 +739,46 @@ function normalizeCommandForPolicyImpl(command) {
737
739
  if (nextParts.length !== 1) continue;
738
740
  const quotedNode = nextParts[0];
739
741
  const nt = syntax.NodeType(quotedNode);
742
+ const markStrip = () => {
743
+ const s = next.Pos().Offset();
744
+ const e = next.End().Offset();
745
+ strips.push([s, e]);
746
+ msgSpans.add(`${s}:${e}`);
747
+ };
740
748
  if (nt === "SglQuoted") {
741
- strips.push([next.Pos().Offset(), next.End().Offset()]);
749
+ markStrip();
742
750
  } else if (nt === "DblQuoted") {
743
751
  const innerParts = quotedNode.Parts || [];
744
752
  const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
745
753
  if (allLit) {
746
- strips.push([next.Pos().Offset(), next.End().Offset()]);
754
+ markStrip();
747
755
  } else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
748
- strips.push([next.Pos().Offset(), next.End().Offset()]);
756
+ markStrip();
749
757
  }
750
758
  }
751
759
  }
760
+ for (const arg of args) {
761
+ const s = arg.Pos().Offset();
762
+ const e = arg.End().Offset();
763
+ if (msgSpans.has(`${s}:${e}`)) continue;
764
+ const resolved = resolveWordLiteral(arg);
765
+ if (resolved === null) continue;
766
+ const source = command.slice(s, e);
767
+ if (resolved === source) continue;
768
+ if (resolved === "" || /\s/.test(resolved)) continue;
769
+ rewrites.push([s, e, resolved]);
770
+ }
752
771
  return true;
753
772
  });
754
- if (strips.length === 0) return command;
755
- strips.sort((a, b) => b[0] - a[0]);
773
+ const edits = [
774
+ ...strips.map(([s, e]) => [s, e, '""']),
775
+ ...rewrites
776
+ ];
777
+ if (edits.length === 0) return command;
778
+ edits.sort((a, b) => b[0] - a[0]);
756
779
  let result = command;
757
- for (const [start, end] of strips) {
758
- result = result.slice(0, start) + '""' + result.slice(end);
780
+ for (const [s, e, rep] of edits) {
781
+ result = result.slice(0, s) + rep + result.slice(e);
759
782
  }
760
783
  return result;
761
784
  } catch {
@@ -921,8 +944,35 @@ var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
921
944
  "shield:project-jail:block-read-ssh",
922
945
  "shield:project-jail:block-read-aws",
923
946
  "shield:project-jail:block-read-env",
924
- "shield:project-jail:review-read-credentials"
947
+ "shield:project-jail:review-read-credentials",
948
+ // SQL-DDL is now owned by the AST detector (analyzeSqlDestructive) so the
949
+ // raw-regex smart rule is suppressed for bash — its cond1 read a grep
950
+ // alternation's `|` as a shell pipe (`grep "…|mysql…"` → false positive).
951
+ "review-drop-truncate-shell"
952
+ ]);
953
+ var SQL_DB_CLIS = /* @__PURE__ */ new Set([
954
+ "psql",
955
+ "mysql",
956
+ "mariadb",
957
+ "sqlite3",
958
+ "sqlplus",
959
+ "cockroach",
960
+ "clickhouse-client",
961
+ "mongo",
962
+ "mongosh"
925
963
  ]);
964
+ var SQL_DDL_RE = /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA|INDEX)\b/i;
965
+ function analyzeSqlDestructive(command) {
966
+ if (!SQL_DDL_RE.test(command)) return null;
967
+ const { actions } = analyzeShellCommand(command);
968
+ if (!actions.some((a) => SQL_DB_CLIS.has(a))) return null;
969
+ return {
970
+ ruleName: "review-drop-truncate-shell",
971
+ verdict: "review",
972
+ reason: "SQL DDL destructive statement inside a shell command",
973
+ description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
974
+ };
975
+ }
926
976
  function isProtectedHomePath(rawPath) {
927
977
  let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
928
978
  let underHome = false;
@@ -1171,19 +1221,20 @@ function extractShellDestinations(command) {
1171
1221
  var FS_OP_CACHE_MAX = 5e3;
1172
1222
  var fsOpCache = /* @__PURE__ */ new Map();
1173
1223
  function analyzeFsOperation(command) {
1174
- if (!FS_OP_PRESCREEN_RE.test(command)) return null;
1175
- if (fsOpCache.has(command)) {
1176
- const hit = fsOpCache.get(command) ?? null;
1177
- fsOpCache.delete(command);
1178
- fsOpCache.set(command, hit);
1224
+ const normalized = normalizeCommandForPolicy(command);
1225
+ if (!FS_OP_PRESCREEN_RE.test(normalized)) return null;
1226
+ if (fsOpCache.has(normalized)) {
1227
+ const hit = fsOpCache.get(normalized) ?? null;
1228
+ fsOpCache.delete(normalized);
1229
+ fsOpCache.set(normalized, hit);
1179
1230
  return hit;
1180
1231
  }
1181
- const computed = analyzeFsOperationImpl(command);
1232
+ const computed = analyzeFsOperationImpl(normalized);
1182
1233
  if (fsOpCache.size >= FS_OP_CACHE_MAX) {
1183
1234
  const oldest = fsOpCache.keys().next().value;
1184
1235
  if (oldest !== void 0) fsOpCache.delete(oldest);
1185
1236
  }
1186
- fsOpCache.set(command, computed);
1237
+ fsOpCache.set(normalized, computed);
1187
1238
  return computed;
1188
1239
  }
1189
1240
  function analyzeFsOperationImpl(command) {
@@ -1907,6 +1958,17 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
1907
1958
  ruleDescription: fsVerdict.reason
1908
1959
  };
1909
1960
  }
1961
+ const sqlVerdict = analyzeSqlDestructive(bashCommand);
1962
+ if (sqlVerdict) {
1963
+ return {
1964
+ decision: sqlVerdict.verdict,
1965
+ blockedByLabel: `Node9 (AST): ${sqlVerdict.ruleName}`,
1966
+ reason: sqlVerdict.reason,
1967
+ tier: 2,
1968
+ ruleName: sqlVerdict.ruleName,
1969
+ ruleDescription: sqlVerdict.description
1970
+ };
1971
+ }
1910
1972
  }
1911
1973
  if (config.policy.smartRules.length > 0) {
1912
1974
  const matchedRule = config.policy.smartRules.find(
@@ -3222,8 +3284,8 @@ function detectArgsPii(args) {
3222
3284
 
3223
3285
  // src/scan/canonical.ts
3224
3286
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3225
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
3226
- var CANONICAL_EXTRACTOR_HASH = "dbe0199dae0f29f6";
3287
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v6";
3288
+ var CANONICAL_EXTRACTOR_HASH = "a0e2bb339fe67e19";
3227
3289
  var DEDUPE_PREVIEW_LEN = 120;
3228
3290
  function extractCanonicalFindings(call, ctx) {
3229
3291
  const out = [];
@@ -3385,7 +3447,7 @@ function extractCanonicalFindings(call, ctx) {
3385
3447
  })
3386
3448
  );
3387
3449
  }
3388
- if (DESTRUCTIVE_OP_RE.test(command)) {
3450
+ if (command !== null && DESTRUCTIVE_OP_RE.test(normalizeCommandForPolicy(command))) {
3389
3451
  out.push(
3390
3452
  makeFinding({
3391
3453
  type: "destructive-op",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Shared policy evaluation engine for node9 — DLP, smart rules, AST shell parsing, shields, loop detection. Pure functions, no I/O. Used by both node9-proxy and the node9 SaaS firewall.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://node9.ai",