@node9/policy-engine 1.66.0 → 1.67.1

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
@@ -336,6 +336,20 @@ interface PolicyConfig {
336
336
  /** Egress / destination control (GAP-5). Optional for back-compat with
337
337
  * callers/tests that build a PolicyConfig without it. */
338
338
  egress?: EgressPolicy;
339
+ /** Command-checks governance (command-checks-governance-spec.md): admin
340
+ * knobs for the built-in detections' REVIEW-severity findings only.
341
+ * Block-severity findings (pipe-critical, eval-remote, rm-rf-home,
342
+ * nuclear, suspect binary) have NO key here — non-weakenable by
343
+ * construction. Class-B keys (evalDynamic, pipeChainHigh) exclude 'off'.
344
+ * rmAdvisory is consumed by the PROXY at advisory-rule injection. */
345
+ commandChecks?: {
346
+ inlineExec?: 'off' | 'review' | 'block';
347
+ rmAdvisory?: 'off' | 'review' | 'block';
348
+ chmod?: 'off' | 'review' | 'block';
349
+ sqlDdl?: 'off' | 'review' | 'block';
350
+ evalDynamic?: 'review' | 'block';
351
+ pipeChainHigh?: 'review' | 'block';
352
+ };
339
353
  };
340
354
  settings: {
341
355
  mode: string;
package/dist/index.d.ts CHANGED
@@ -336,6 +336,20 @@ interface PolicyConfig {
336
336
  /** Egress / destination control (GAP-5). Optional for back-compat with
337
337
  * callers/tests that build a PolicyConfig without it. */
338
338
  egress?: EgressPolicy;
339
+ /** Command-checks governance (command-checks-governance-spec.md): admin
340
+ * knobs for the built-in detections' REVIEW-severity findings only.
341
+ * Block-severity findings (pipe-critical, eval-remote, rm-rf-home,
342
+ * nuclear, suspect binary) have NO key here — non-weakenable by
343
+ * construction. Class-B keys (evalDynamic, pipeChainHigh) exclude 'off'.
344
+ * rmAdvisory is consumed by the PROXY at advisory-rule injection. */
345
+ commandChecks?: {
346
+ inlineExec?: 'off' | 'review' | 'block';
347
+ rmAdvisory?: 'off' | 'review' | 'block';
348
+ chmod?: 'off' | 'review' | 'block';
349
+ sqlDdl?: 'off' | 'review' | 'block';
350
+ evalDynamic?: 'review' | 'block';
351
+ pipeChainHigh?: 'review' | 'block';
352
+ };
339
353
  };
340
354
  settings: {
341
355
  mode: string;
package/dist/index.js CHANGED
@@ -2176,6 +2176,47 @@ function evaluateSmartConditions(args, rule) {
2176
2176
  }
2177
2177
 
2178
2178
  // src/policy/index.ts
2179
+ function resolveCheck(v) {
2180
+ return v === "off" || v === "block" ? v : "review";
2181
+ }
2182
+ function resolveCheckTight(v) {
2183
+ return v === "block" ? "block" : "review";
2184
+ }
2185
+ var INLINE_INTERP = /^(python[\d.]*|bash|sh|zsh|perl|ruby|node|php|lua)$/i;
2186
+ var INLINE_SHELL = /^(bash|sh|zsh)$/i;
2187
+ function detectInlineExec(command) {
2188
+ const pipeFed = command.includes("|");
2189
+ const segments = command.split(/\|\||&&|;|\||\n|\$\(|`|\(/);
2190
+ for (const rawSeg of segments) {
2191
+ const tokens = rawSeg.trim().split(/\s+/).filter(Boolean);
2192
+ let i = 0;
2193
+ while (i < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[i])) i++;
2194
+ if (i >= tokens.length) continue;
2195
+ const base = tokens[i].split("/").pop() ?? tokens[i];
2196
+ if (!INLINE_INTERP.test(base)) continue;
2197
+ const args = tokens.slice(i + 1);
2198
+ let hadRedirect = false;
2199
+ const positionals = [];
2200
+ for (let j = 0; j < args.length; j++) {
2201
+ const a = args[j];
2202
+ if (a === "-") return true;
2203
+ if (a.startsWith("<")) {
2204
+ hadRedirect = true;
2205
+ if (a === "<" || a === "<<") j++;
2206
+ continue;
2207
+ }
2208
+ if (a.startsWith("-")) {
2209
+ if (/^-(c|e|eval)$/i.test(a)) return true;
2210
+ continue;
2211
+ }
2212
+ positionals.push(a);
2213
+ }
2214
+ if (positionals.length === 0 && (hadRedirect || pipeFed) && !INLINE_SHELL.test(base)) {
2215
+ return true;
2216
+ }
2217
+ }
2218
+ return false;
2219
+ }
2179
2220
  var VERDICT_RANK = {
2180
2221
  allow: 0,
2181
2222
  review: 1,
@@ -2217,7 +2258,7 @@ function checkDangerousSql(sql) {
2217
2258
  return "UPDATE without WHERE \u2014 updates every row";
2218
2259
  return null;
2219
2260
  }
2220
- function pipeChainVerdict(command, isTrustedHost) {
2261
+ function pipeChainVerdict(command, isTrustedHost, highAction = "review") {
2221
2262
  const pipeAnalysis = analyzePipeChain(command);
2222
2263
  if (!pipeAnalysis.isPipeline) return null;
2223
2264
  if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
@@ -2248,7 +2289,7 @@ function pipeChainVerdict(command, isTrustedHost) {
2248
2289
  };
2249
2290
  }
2250
2291
  return {
2251
- decision: "review",
2292
+ decision: highAction,
2252
2293
  blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
2253
2294
  reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
2254
2295
  tier: 3
@@ -2274,7 +2315,11 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2274
2315
  if (wouldBeIgnored) return { decision: "allow" };
2275
2316
  const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
2276
2317
  if (bashCommand !== null) {
2277
- const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
2318
+ const pipeVerdict = pipeChainVerdict(
2319
+ bashCommand,
2320
+ isTrustedHost,
2321
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2322
+ );
2278
2323
  if (pipeVerdict) return pipeVerdict;
2279
2324
  const fsVerdict = analyzeFsOperation(bashCommand);
2280
2325
  if (fsVerdict) {
@@ -2289,10 +2334,12 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2289
2334
  ruleDescription: fsVerdict.reason
2290
2335
  };
2291
2336
  }
2292
- const sqlVerdict = analyzeSqlDestructive(bashCommand);
2337
+ const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
2338
+ const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
2293
2339
  if (sqlVerdict) {
2294
2340
  return {
2295
- decision: sqlVerdict.verdict,
2341
+ // analyzeSqlDestructive is typed review-only, so the knob maps 1:1.
2342
+ decision: sqlAction === "block" ? "block" : "review",
2296
2343
  blockedByLabel: `Node9 (AST): ${sqlVerdict.ruleName}`,
2297
2344
  reason: sqlVerdict.reason,
2298
2345
  tier: 2,
@@ -2300,10 +2347,12 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2300
2347
  ruleDescription: sqlVerdict.description
2301
2348
  };
2302
2349
  }
2303
- const chmodVerdict = analyzeChmod777(bashCommand);
2350
+ const chmodAction = resolveCheck(config.policy.commandChecks?.chmod);
2351
+ const chmodVerdict = chmodAction === "off" ? null : analyzeChmod777(bashCommand);
2304
2352
  if (chmodVerdict) {
2305
2353
  return {
2306
- decision: chmodVerdict.verdict,
2354
+ // analyzeChmod777 is typed review-only, so the knob maps 1:1.
2355
+ decision: chmodAction === "block" ? "block" : "review",
2307
2356
  blockedByLabel: `project-jail (AST): ${chmodVerdict.ruleName}`,
2308
2357
  reason: chmodVerdict.reason,
2309
2358
  tier: 2,
@@ -2346,10 +2395,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2346
2395
  const analyzed = analyzeShellCommand(shellCommand);
2347
2396
  allTokens = analyzed.allTokens;
2348
2397
  pathTokens = analyzed.paths;
2349
- const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
2350
- if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
2398
+ const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2399
+ if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2351
2400
  return {
2352
- decision: "review",
2401
+ decision: inlineAction === "block" ? "block" : "review",
2353
2402
  blockedByLabel: "Node9 Standard (Inline Execution)",
2354
2403
  ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2355
2404
  tier: 3
@@ -2367,14 +2416,21 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2367
2416
  }
2368
2417
  if (evalVerdict === "review") {
2369
2418
  return {
2370
- decision: "review",
2419
+ // Class B tighten-only: commandChecks.evalDynamic may upgrade to
2420
+ // block but can never turn this off (eval-remote above is Class A —
2421
+ // no knob at all).
2422
+ decision: resolveCheckTight(config.policy.commandChecks?.evalDynamic),
2371
2423
  blockedByLabel: "Node9: Eval Dynamic Content",
2372
2424
  reason: "eval of dynamic content (variable or subshell expansion) requires approval",
2373
2425
  ruleDescription: "The AI is running a command that includes a variable or subshell expansion. The actual command executed at runtime may differ from what is shown here.",
2374
2426
  tier: 3
2375
2427
  };
2376
2428
  }
2377
- const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
2429
+ const ptVerdict = pipeChainVerdict(
2430
+ shellCommand,
2431
+ isTrustedHost,
2432
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2433
+ );
2378
2434
  if (ptVerdict) return ptVerdict;
2379
2435
  if (config.policy.egress?.enabled) {
2380
2436
  const dests = extractShellDestinations(shellCommand);
package/dist/index.mjs CHANGED
@@ -2066,6 +2066,47 @@ function evaluateSmartConditions(args, rule) {
2066
2066
  }
2067
2067
 
2068
2068
  // src/policy/index.ts
2069
+ function resolveCheck(v) {
2070
+ return v === "off" || v === "block" ? v : "review";
2071
+ }
2072
+ function resolveCheckTight(v) {
2073
+ return v === "block" ? "block" : "review";
2074
+ }
2075
+ var INLINE_INTERP = /^(python[\d.]*|bash|sh|zsh|perl|ruby|node|php|lua)$/i;
2076
+ var INLINE_SHELL = /^(bash|sh|zsh)$/i;
2077
+ function detectInlineExec(command) {
2078
+ const pipeFed = command.includes("|");
2079
+ const segments = command.split(/\|\||&&|;|\||\n|\$\(|`|\(/);
2080
+ for (const rawSeg of segments) {
2081
+ const tokens = rawSeg.trim().split(/\s+/).filter(Boolean);
2082
+ let i = 0;
2083
+ while (i < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[i])) i++;
2084
+ if (i >= tokens.length) continue;
2085
+ const base = tokens[i].split("/").pop() ?? tokens[i];
2086
+ if (!INLINE_INTERP.test(base)) continue;
2087
+ const args = tokens.slice(i + 1);
2088
+ let hadRedirect = false;
2089
+ const positionals = [];
2090
+ for (let j = 0; j < args.length; j++) {
2091
+ const a = args[j];
2092
+ if (a === "-") return true;
2093
+ if (a.startsWith("<")) {
2094
+ hadRedirect = true;
2095
+ if (a === "<" || a === "<<") j++;
2096
+ continue;
2097
+ }
2098
+ if (a.startsWith("-")) {
2099
+ if (/^-(c|e|eval)$/i.test(a)) return true;
2100
+ continue;
2101
+ }
2102
+ positionals.push(a);
2103
+ }
2104
+ if (positionals.length === 0 && (hadRedirect || pipeFed) && !INLINE_SHELL.test(base)) {
2105
+ return true;
2106
+ }
2107
+ }
2108
+ return false;
2109
+ }
2069
2110
  var VERDICT_RANK = {
2070
2111
  allow: 0,
2071
2112
  review: 1,
@@ -2107,7 +2148,7 @@ function checkDangerousSql(sql) {
2107
2148
  return "UPDATE without WHERE \u2014 updates every row";
2108
2149
  return null;
2109
2150
  }
2110
- function pipeChainVerdict(command, isTrustedHost) {
2151
+ function pipeChainVerdict(command, isTrustedHost, highAction = "review") {
2111
2152
  const pipeAnalysis = analyzePipeChain(command);
2112
2153
  if (!pipeAnalysis.isPipeline) return null;
2113
2154
  if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
@@ -2138,7 +2179,7 @@ function pipeChainVerdict(command, isTrustedHost) {
2138
2179
  };
2139
2180
  }
2140
2181
  return {
2141
- decision: "review",
2182
+ decision: highAction,
2142
2183
  blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
2143
2184
  reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
2144
2185
  tier: 3
@@ -2164,7 +2205,11 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2164
2205
  if (wouldBeIgnored) return { decision: "allow" };
2165
2206
  const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
2166
2207
  if (bashCommand !== null) {
2167
- const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
2208
+ const pipeVerdict = pipeChainVerdict(
2209
+ bashCommand,
2210
+ isTrustedHost,
2211
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2212
+ );
2168
2213
  if (pipeVerdict) return pipeVerdict;
2169
2214
  const fsVerdict = analyzeFsOperation(bashCommand);
2170
2215
  if (fsVerdict) {
@@ -2179,10 +2224,12 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2179
2224
  ruleDescription: fsVerdict.reason
2180
2225
  };
2181
2226
  }
2182
- const sqlVerdict = analyzeSqlDestructive(bashCommand);
2227
+ const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
2228
+ const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
2183
2229
  if (sqlVerdict) {
2184
2230
  return {
2185
- decision: sqlVerdict.verdict,
2231
+ // analyzeSqlDestructive is typed review-only, so the knob maps 1:1.
2232
+ decision: sqlAction === "block" ? "block" : "review",
2186
2233
  blockedByLabel: `Node9 (AST): ${sqlVerdict.ruleName}`,
2187
2234
  reason: sqlVerdict.reason,
2188
2235
  tier: 2,
@@ -2190,10 +2237,12 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2190
2237
  ruleDescription: sqlVerdict.description
2191
2238
  };
2192
2239
  }
2193
- const chmodVerdict = analyzeChmod777(bashCommand);
2240
+ const chmodAction = resolveCheck(config.policy.commandChecks?.chmod);
2241
+ const chmodVerdict = chmodAction === "off" ? null : analyzeChmod777(bashCommand);
2194
2242
  if (chmodVerdict) {
2195
2243
  return {
2196
- decision: chmodVerdict.verdict,
2244
+ // analyzeChmod777 is typed review-only, so the knob maps 1:1.
2245
+ decision: chmodAction === "block" ? "block" : "review",
2197
2246
  blockedByLabel: `project-jail (AST): ${chmodVerdict.ruleName}`,
2198
2247
  reason: chmodVerdict.reason,
2199
2248
  tier: 2,
@@ -2236,10 +2285,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2236
2285
  const analyzed = analyzeShellCommand(shellCommand);
2237
2286
  allTokens = analyzed.allTokens;
2238
2287
  pathTokens = analyzed.paths;
2239
- const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
2240
- if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
2288
+ const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2289
+ if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2241
2290
  return {
2242
- decision: "review",
2291
+ decision: inlineAction === "block" ? "block" : "review",
2243
2292
  blockedByLabel: "Node9 Standard (Inline Execution)",
2244
2293
  ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2245
2294
  tier: 3
@@ -2257,14 +2306,21 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2257
2306
  }
2258
2307
  if (evalVerdict === "review") {
2259
2308
  return {
2260
- decision: "review",
2309
+ // Class B tighten-only: commandChecks.evalDynamic may upgrade to
2310
+ // block but can never turn this off (eval-remote above is Class A —
2311
+ // no knob at all).
2312
+ decision: resolveCheckTight(config.policy.commandChecks?.evalDynamic),
2261
2313
  blockedByLabel: "Node9: Eval Dynamic Content",
2262
2314
  reason: "eval of dynamic content (variable or subshell expansion) requires approval",
2263
2315
  ruleDescription: "The AI is running a command that includes a variable or subshell expansion. The actual command executed at runtime may differ from what is shown here.",
2264
2316
  tier: 3
2265
2317
  };
2266
2318
  }
2267
- const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
2319
+ const ptVerdict = pipeChainVerdict(
2320
+ shellCommand,
2321
+ isTrustedHost,
2322
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2323
+ );
2268
2324
  if (ptVerdict) return ptVerdict;
2269
2325
  if (config.policy.egress?.enabled) {
2270
2326
  const dests = extractShellDestinations(shellCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "1.66.0",
3
+ "version": "1.67.1",
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",