@node9/policy-engine 1.65.1 → 1.67.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
@@ -329,10 +329,27 @@ interface PolicyConfig {
329
329
  dlp: {
330
330
  enabled: boolean;
331
331
  scanIgnoredTools: boolean;
332
+ /** Inline-ask v2: 'block' upgrades review-severity DLP matches to a hard
333
+ * block. Optional for back-compat with callers that don't set it. */
334
+ reviewAction?: 'review' | 'block';
332
335
  };
333
336
  /** Egress / destination control (GAP-5). Optional for back-compat with
334
337
  * callers/tests that build a PolicyConfig without it. */
335
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
+ };
336
353
  };
337
354
  settings: {
338
355
  mode: string;
package/dist/index.d.ts CHANGED
@@ -329,10 +329,27 @@ interface PolicyConfig {
329
329
  dlp: {
330
330
  enabled: boolean;
331
331
  scanIgnoredTools: boolean;
332
+ /** Inline-ask v2: 'block' upgrades review-severity DLP matches to a hard
333
+ * block. Optional for back-compat with callers that don't set it. */
334
+ reviewAction?: 'review' | 'block';
332
335
  };
333
336
  /** Egress / destination control (GAP-5). Optional for back-compat with
334
337
  * callers/tests that build a PolicyConfig without it. */
335
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
+ };
336
353
  };
337
354
  settings: {
338
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
@@ -2262,7 +2303,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2262
2303
  const dlpMatch = args !== void 0 ? scanArgs(args) : null;
2263
2304
  if (dlpMatch) {
2264
2305
  return {
2265
- decision: dlpMatch.severity,
2306
+ // reviewAction:'block' (inline-ask v2): the admin upgraded
2307
+ // review-severity matches to a hard block — every evaluatePolicy
2308
+ // caller (orchestrator, explain, gateway) must agree with the gate.
2309
+ decision: dlpMatch.severity === "block" || config.policy.dlp.reviewAction === "block" ? "block" : "review",
2266
2310
  blockedByLabel: `DLP: ${dlpMatch.patternName}`,
2267
2311
  reason: `${dlpMatch.patternName} detected in ${dlpMatch.fieldPath}`
2268
2312
  };
@@ -2271,7 +2315,11 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2271
2315
  if (wouldBeIgnored) return { decision: "allow" };
2272
2316
  const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
2273
2317
  if (bashCommand !== null) {
2274
- const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
2318
+ const pipeVerdict = pipeChainVerdict(
2319
+ bashCommand,
2320
+ isTrustedHost,
2321
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2322
+ );
2275
2323
  if (pipeVerdict) return pipeVerdict;
2276
2324
  const fsVerdict = analyzeFsOperation(bashCommand);
2277
2325
  if (fsVerdict) {
@@ -2286,10 +2334,12 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2286
2334
  ruleDescription: fsVerdict.reason
2287
2335
  };
2288
2336
  }
2289
- const sqlVerdict = analyzeSqlDestructive(bashCommand);
2337
+ const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
2338
+ const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
2290
2339
  if (sqlVerdict) {
2291
2340
  return {
2292
- decision: sqlVerdict.verdict,
2341
+ // analyzeSqlDestructive is typed review-only, so the knob maps 1:1.
2342
+ decision: sqlAction === "block" ? "block" : "review",
2293
2343
  blockedByLabel: `Node9 (AST): ${sqlVerdict.ruleName}`,
2294
2344
  reason: sqlVerdict.reason,
2295
2345
  tier: 2,
@@ -2297,10 +2347,12 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2297
2347
  ruleDescription: sqlVerdict.description
2298
2348
  };
2299
2349
  }
2300
- const chmodVerdict = analyzeChmod777(bashCommand);
2350
+ const chmodAction = resolveCheck(config.policy.commandChecks?.chmod);
2351
+ const chmodVerdict = chmodAction === "off" ? null : analyzeChmod777(bashCommand);
2301
2352
  if (chmodVerdict) {
2302
2353
  return {
2303
- decision: chmodVerdict.verdict,
2354
+ // analyzeChmod777 is typed review-only, so the knob maps 1:1.
2355
+ decision: chmodAction === "block" ? "block" : "review",
2304
2356
  blockedByLabel: `project-jail (AST): ${chmodVerdict.ruleName}`,
2305
2357
  reason: chmodVerdict.reason,
2306
2358
  tier: 2,
@@ -2343,10 +2395,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2343
2395
  const analyzed = analyzeShellCommand(shellCommand);
2344
2396
  allTokens = analyzed.allTokens;
2345
2397
  pathTokens = analyzed.paths;
2346
- const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
2347
- if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
2398
+ const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2399
+ if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2348
2400
  return {
2349
- decision: "review",
2401
+ decision: inlineAction === "block" ? "block" : "review",
2350
2402
  blockedByLabel: "Node9 Standard (Inline Execution)",
2351
2403
  ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2352
2404
  tier: 3
@@ -2364,14 +2416,21 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2364
2416
  }
2365
2417
  if (evalVerdict === "review") {
2366
2418
  return {
2367
- 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),
2368
2423
  blockedByLabel: "Node9: Eval Dynamic Content",
2369
2424
  reason: "eval of dynamic content (variable or subshell expansion) requires approval",
2370
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.",
2371
2426
  tier: 3
2372
2427
  };
2373
2428
  }
2374
- const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
2429
+ const ptVerdict = pipeChainVerdict(
2430
+ shellCommand,
2431
+ isTrustedHost,
2432
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2433
+ );
2375
2434
  if (ptVerdict) return ptVerdict;
2376
2435
  if (config.policy.egress?.enabled) {
2377
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
@@ -2152,7 +2193,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2152
2193
  const dlpMatch = args !== void 0 ? scanArgs(args) : null;
2153
2194
  if (dlpMatch) {
2154
2195
  return {
2155
- decision: dlpMatch.severity,
2196
+ // reviewAction:'block' (inline-ask v2): the admin upgraded
2197
+ // review-severity matches to a hard block — every evaluatePolicy
2198
+ // caller (orchestrator, explain, gateway) must agree with the gate.
2199
+ decision: dlpMatch.severity === "block" || config.policy.dlp.reviewAction === "block" ? "block" : "review",
2156
2200
  blockedByLabel: `DLP: ${dlpMatch.patternName}`,
2157
2201
  reason: `${dlpMatch.patternName} detected in ${dlpMatch.fieldPath}`
2158
2202
  };
@@ -2161,7 +2205,11 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2161
2205
  if (wouldBeIgnored) return { decision: "allow" };
2162
2206
  const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
2163
2207
  if (bashCommand !== null) {
2164
- const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
2208
+ const pipeVerdict = pipeChainVerdict(
2209
+ bashCommand,
2210
+ isTrustedHost,
2211
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2212
+ );
2165
2213
  if (pipeVerdict) return pipeVerdict;
2166
2214
  const fsVerdict = analyzeFsOperation(bashCommand);
2167
2215
  if (fsVerdict) {
@@ -2176,10 +2224,12 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2176
2224
  ruleDescription: fsVerdict.reason
2177
2225
  };
2178
2226
  }
2179
- const sqlVerdict = analyzeSqlDestructive(bashCommand);
2227
+ const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
2228
+ const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
2180
2229
  if (sqlVerdict) {
2181
2230
  return {
2182
- decision: sqlVerdict.verdict,
2231
+ // analyzeSqlDestructive is typed review-only, so the knob maps 1:1.
2232
+ decision: sqlAction === "block" ? "block" : "review",
2183
2233
  blockedByLabel: `Node9 (AST): ${sqlVerdict.ruleName}`,
2184
2234
  reason: sqlVerdict.reason,
2185
2235
  tier: 2,
@@ -2187,10 +2237,12 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2187
2237
  ruleDescription: sqlVerdict.description
2188
2238
  };
2189
2239
  }
2190
- const chmodVerdict = analyzeChmod777(bashCommand);
2240
+ const chmodAction = resolveCheck(config.policy.commandChecks?.chmod);
2241
+ const chmodVerdict = chmodAction === "off" ? null : analyzeChmod777(bashCommand);
2191
2242
  if (chmodVerdict) {
2192
2243
  return {
2193
- decision: chmodVerdict.verdict,
2244
+ // analyzeChmod777 is typed review-only, so the knob maps 1:1.
2245
+ decision: chmodAction === "block" ? "block" : "review",
2194
2246
  blockedByLabel: `project-jail (AST): ${chmodVerdict.ruleName}`,
2195
2247
  reason: chmodVerdict.reason,
2196
2248
  tier: 2,
@@ -2233,10 +2285,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2233
2285
  const analyzed = analyzeShellCommand(shellCommand);
2234
2286
  allTokens = analyzed.allTokens;
2235
2287
  pathTokens = analyzed.paths;
2236
- const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
2237
- if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
2288
+ const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2289
+ if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2238
2290
  return {
2239
- decision: "review",
2291
+ decision: inlineAction === "block" ? "block" : "review",
2240
2292
  blockedByLabel: "Node9 Standard (Inline Execution)",
2241
2293
  ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2242
2294
  tier: 3
@@ -2254,14 +2306,21 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2254
2306
  }
2255
2307
  if (evalVerdict === "review") {
2256
2308
  return {
2257
- 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),
2258
2313
  blockedByLabel: "Node9: Eval Dynamic Content",
2259
2314
  reason: "eval of dynamic content (variable or subshell expansion) requires approval",
2260
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.",
2261
2316
  tier: 3
2262
2317
  };
2263
2318
  }
2264
- const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
2319
+ const ptVerdict = pipeChainVerdict(
2320
+ shellCommand,
2321
+ isTrustedHost,
2322
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2323
+ );
2265
2324
  if (ptVerdict) return ptVerdict;
2266
2325
  if (config.policy.egress?.enabled) {
2267
2326
  const dests = extractShellDestinations(shellCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "1.65.1",
3
+ "version": "1.67.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",