@node9/policy-engine 2.16.2 → 2.18.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
@@ -402,6 +402,15 @@ interface ShellCommandAnalysis {
402
402
  */
403
403
  declare function analyzeShellCommand(command: string): ShellCommandAnalysis;
404
404
 
405
+ /**
406
+ * A destination a tool call will reach, whichever carrier declared it: a
407
+ * shell command (extractShellDestinations) or a tool argument
408
+ * (extractToolDestinations). The interface is still spelled ShellDestination
409
+ * where it lives (shell/index.ts, which is hashed by the extractor-version
410
+ * gate, so a rename there is not free); this is the name that says what the
411
+ * shape is for.
412
+ */
413
+ type Destination = ShellDestination;
405
414
  interface EgressPolicy {
406
415
  /** Master switch. Default false — opt-in, like dlp.pii. */
407
416
  enabled: boolean;
@@ -459,7 +468,7 @@ declare function isPrivateHost(host: string): boolean;
459
468
  * first review; null if everything is allowed (or policy disabled / mode off).
460
469
  * Pure.
461
470
  */
462
- declare function evaluateEgress(dests: readonly ShellDestination[], policy: EgressPolicy): EgressVerdict | null;
471
+ declare function evaluateEgress(dests: readonly Destination[], policy: EgressPolicy): EgressVerdict | null;
463
472
 
464
473
  interface PipeChainAnalysis {
465
474
  isPipeline: boolean;
@@ -628,16 +637,6 @@ declare function resolvePinned(matches: SmartRule[]): SmartRule | undefined;
628
637
  * Returns a reason string if dangerous, null if safe.
629
638
  */
630
639
  declare function checkDangerousSql(sql: string): string | null;
631
- /**
632
- * Stateless policy evaluation. Same waterfall as the original
633
- * proxy/src/policy/index.ts:evaluatePolicy, but config + context + I/O
634
- * hooks come in as parameters so this function works in any host.
635
- *
636
- * Returns 'allow' for ignored tools, the matched smart-rule verdict,
637
- * inline-execution review, eval-detection verdict, pipe-chain verdict,
638
- * provenance verdict, sandbox allow, dangerous-word review, or strict-mode
639
- * fallback. See the design doc for the full tier table.
640
- */
641
640
  declare function evaluatePolicy(config: PolicyConfig, toolName: string, args?: unknown, context?: PolicyContext, hooks?: PolicyHostHooks): Promise<PolicyVerdict>;
642
641
  /** Returns true when toolName matches the config's ignoredTools list. */
643
642
  declare function isIgnoredTool(toolName: string, config: PolicyConfig): boolean;
@@ -1439,6 +1438,33 @@ interface SsrfFloorOptions {
1439
1438
  * those constantly (72 of 308 destinations on measured real history). */
1440
1439
  ssrfStrict?: boolean;
1441
1440
  }
1441
+ /**
1442
+ * Does this exemption list release this address?
1443
+ *
1444
+ * ONE function, because the shell floor and the declared-URL floor must answer
1445
+ * identically. They had two spellings of it — `normalizeIpLiteral(e) ?? e.trim()
1446
+ * .toLowerCase()` here and `classifySsrf(a)?.normalized ?? a` in
1447
+ * destinations.ts — and for an entry classifySsrf does not match they already
1448
+ * disagreed. Nothing depended on the difference, which is exactly when it is
1449
+ * cheap to remove.
1450
+ *
1451
+ * An entry is an exact address or a CIDR range. The range form exists so an
1452
+ * operator can exempt the mesh-VPN range they actually use (100.64.0.0/10)
1453
+ * instead of listing peers one at a time, which is what the settings panel's
1454
+ * "remove this default" control needs.
1455
+ *
1456
+ * ⚠ This answers "is it in the list", NOT "may it be released". The caller
1457
+ * keeps its `m.overridable` guard, and that guard is per ADDRESS, decided by
1458
+ * classifySsrf before this is consulted. So a range can never release a
1459
+ * protected address inside it: 100.100.100.200 (Alibaba IMDS) sits inside
1460
+ * 100.64.0.0/10 and is named in METADATA_ADDRESSES above the range check, so
1461
+ * it classifies as metadata / overridable:false and stays blocked with the
1462
+ * whole range exempted. Pinned by E2 in exempt-range.spec.ts.
1463
+ *
1464
+ * Never throws: this runs on the hook path for every tool call, and a bad
1465
+ * entry in a hand-edited config must not break every command on the machine.
1466
+ */
1467
+ declare function ssrfExemptMatches(entries: readonly string[] | undefined, normalized: string | undefined): boolean;
1442
1468
  /**
1443
1469
  * The floor. Returns the first protected destination, or null.
1444
1470
  *
@@ -1464,6 +1490,25 @@ declare function ssrfFloor(tokens: ReadonlyArray<{
1464
1490
  * `[]` in a path means "every element of this array".
1465
1491
  */
1466
1492
  declare const DESTINATION_ARGS: ReadonlyMap<string, readonly string[]>;
1493
+ /**
1494
+ * The hosts a non-shell tool call will reach, in the SAME shape the shell
1495
+ * extractor produces, so both feed one evaluateEgress (G10).
1496
+ *
1497
+ * Measured 2026-09-20 on dev e748b4a, egress { mode: 'block', allow: [] }:
1498
+ * `curl https://evil.example.com/` denied, the same URL through WebFetch, MCP
1499
+ * fetch and browser navigate allowed. The allowlist was shell-only, and an
1500
+ * agent that wants to exfiltrate does not need curl. The extraction below is
1501
+ * what the floor above already does; the policy was never applied to it.
1502
+ *
1503
+ * Same closed table, same helpers, for the same reason: a tool is covered
1504
+ * because it is LISTED, never because an argument looked like a URL. A tool
1505
+ * not in DESTINATION_ARGS yields [] and is invisible to the egress policy,
1506
+ * exactly as it is invisible to the floor. `binary` carries the tool name so
1507
+ * the reason string and the audit row say which carrier it was.
1508
+ *
1509
+ * Never throws: this runs on the hook path for every tool call.
1510
+ */
1511
+ declare function extractToolDestinations(toolName: string, args: unknown): Destination[];
1467
1512
  interface DestinationHit extends SsrfMatch {
1468
1513
  /** The path that carried it, for the message and the audit row. */
1469
1514
  argPath: string;
@@ -1483,4 +1528,4 @@ declare function ssrfDestinationFloor(toolName: string, args: unknown, opts?: {
1483
1528
  /** Engine version stamped on audit entries for future drift detection. */
1484
1529
  declare const ENGINE_VERSION = "1.4.0";
1485
1530
 
1486
- export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COMMAND_WRAPPERS, COPY_VERBS, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, MAX_BLAST_PATH, NET_BINARIES, PATTERN_VERB_NAMES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type PositionedArg, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, fileOperandFlagsOf, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, patternShapeOf, positionedArgs, previewArgs, redactText, resolvePinned, safeMessage, sampleCopyCommand, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, stripControlChars, stripTerminalEscapes, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
1531
+ export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COMMAND_WRAPPERS, COPY_VERBS, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type Destination, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, MAX_BLAST_PATH, NET_BINARIES, PATTERN_VERB_NAMES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type PositionedArg, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, extractToolDestinations, fileOperandFlagsOf, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, patternShapeOf, positionedArgs, previewArgs, redactText, resolvePinned, safeMessage, sampleCopyCommand, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfExemptMatches, ssrfFloor, ssrfReason, stripControlChars, stripTerminalEscapes, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
package/dist/index.d.ts CHANGED
@@ -402,6 +402,15 @@ interface ShellCommandAnalysis {
402
402
  */
403
403
  declare function analyzeShellCommand(command: string): ShellCommandAnalysis;
404
404
 
405
+ /**
406
+ * A destination a tool call will reach, whichever carrier declared it: a
407
+ * shell command (extractShellDestinations) or a tool argument
408
+ * (extractToolDestinations). The interface is still spelled ShellDestination
409
+ * where it lives (shell/index.ts, which is hashed by the extractor-version
410
+ * gate, so a rename there is not free); this is the name that says what the
411
+ * shape is for.
412
+ */
413
+ type Destination = ShellDestination;
405
414
  interface EgressPolicy {
406
415
  /** Master switch. Default false — opt-in, like dlp.pii. */
407
416
  enabled: boolean;
@@ -459,7 +468,7 @@ declare function isPrivateHost(host: string): boolean;
459
468
  * first review; null if everything is allowed (or policy disabled / mode off).
460
469
  * Pure.
461
470
  */
462
- declare function evaluateEgress(dests: readonly ShellDestination[], policy: EgressPolicy): EgressVerdict | null;
471
+ declare function evaluateEgress(dests: readonly Destination[], policy: EgressPolicy): EgressVerdict | null;
463
472
 
464
473
  interface PipeChainAnalysis {
465
474
  isPipeline: boolean;
@@ -628,16 +637,6 @@ declare function resolvePinned(matches: SmartRule[]): SmartRule | undefined;
628
637
  * Returns a reason string if dangerous, null if safe.
629
638
  */
630
639
  declare function checkDangerousSql(sql: string): string | null;
631
- /**
632
- * Stateless policy evaluation. Same waterfall as the original
633
- * proxy/src/policy/index.ts:evaluatePolicy, but config + context + I/O
634
- * hooks come in as parameters so this function works in any host.
635
- *
636
- * Returns 'allow' for ignored tools, the matched smart-rule verdict,
637
- * inline-execution review, eval-detection verdict, pipe-chain verdict,
638
- * provenance verdict, sandbox allow, dangerous-word review, or strict-mode
639
- * fallback. See the design doc for the full tier table.
640
- */
641
640
  declare function evaluatePolicy(config: PolicyConfig, toolName: string, args?: unknown, context?: PolicyContext, hooks?: PolicyHostHooks): Promise<PolicyVerdict>;
642
641
  /** Returns true when toolName matches the config's ignoredTools list. */
643
642
  declare function isIgnoredTool(toolName: string, config: PolicyConfig): boolean;
@@ -1439,6 +1438,33 @@ interface SsrfFloorOptions {
1439
1438
  * those constantly (72 of 308 destinations on measured real history). */
1440
1439
  ssrfStrict?: boolean;
1441
1440
  }
1441
+ /**
1442
+ * Does this exemption list release this address?
1443
+ *
1444
+ * ONE function, because the shell floor and the declared-URL floor must answer
1445
+ * identically. They had two spellings of it — `normalizeIpLiteral(e) ?? e.trim()
1446
+ * .toLowerCase()` here and `classifySsrf(a)?.normalized ?? a` in
1447
+ * destinations.ts — and for an entry classifySsrf does not match they already
1448
+ * disagreed. Nothing depended on the difference, which is exactly when it is
1449
+ * cheap to remove.
1450
+ *
1451
+ * An entry is an exact address or a CIDR range. The range form exists so an
1452
+ * operator can exempt the mesh-VPN range they actually use (100.64.0.0/10)
1453
+ * instead of listing peers one at a time, which is what the settings panel's
1454
+ * "remove this default" control needs.
1455
+ *
1456
+ * ⚠ This answers "is it in the list", NOT "may it be released". The caller
1457
+ * keeps its `m.overridable` guard, and that guard is per ADDRESS, decided by
1458
+ * classifySsrf before this is consulted. So a range can never release a
1459
+ * protected address inside it: 100.100.100.200 (Alibaba IMDS) sits inside
1460
+ * 100.64.0.0/10 and is named in METADATA_ADDRESSES above the range check, so
1461
+ * it classifies as metadata / overridable:false and stays blocked with the
1462
+ * whole range exempted. Pinned by E2 in exempt-range.spec.ts.
1463
+ *
1464
+ * Never throws: this runs on the hook path for every tool call, and a bad
1465
+ * entry in a hand-edited config must not break every command on the machine.
1466
+ */
1467
+ declare function ssrfExemptMatches(entries: readonly string[] | undefined, normalized: string | undefined): boolean;
1442
1468
  /**
1443
1469
  * The floor. Returns the first protected destination, or null.
1444
1470
  *
@@ -1464,6 +1490,25 @@ declare function ssrfFloor(tokens: ReadonlyArray<{
1464
1490
  * `[]` in a path means "every element of this array".
1465
1491
  */
1466
1492
  declare const DESTINATION_ARGS: ReadonlyMap<string, readonly string[]>;
1493
+ /**
1494
+ * The hosts a non-shell tool call will reach, in the SAME shape the shell
1495
+ * extractor produces, so both feed one evaluateEgress (G10).
1496
+ *
1497
+ * Measured 2026-09-20 on dev e748b4a, egress { mode: 'block', allow: [] }:
1498
+ * `curl https://evil.example.com/` denied, the same URL through WebFetch, MCP
1499
+ * fetch and browser navigate allowed. The allowlist was shell-only, and an
1500
+ * agent that wants to exfiltrate does not need curl. The extraction below is
1501
+ * what the floor above already does; the policy was never applied to it.
1502
+ *
1503
+ * Same closed table, same helpers, for the same reason: a tool is covered
1504
+ * because it is LISTED, never because an argument looked like a URL. A tool
1505
+ * not in DESTINATION_ARGS yields [] and is invisible to the egress policy,
1506
+ * exactly as it is invisible to the floor. `binary` carries the tool name so
1507
+ * the reason string and the audit row say which carrier it was.
1508
+ *
1509
+ * Never throws: this runs on the hook path for every tool call.
1510
+ */
1511
+ declare function extractToolDestinations(toolName: string, args: unknown): Destination[];
1467
1512
  interface DestinationHit extends SsrfMatch {
1468
1513
  /** The path that carried it, for the message and the audit row. */
1469
1514
  argPath: string;
@@ -1483,4 +1528,4 @@ declare function ssrfDestinationFloor(toolName: string, args: unknown, opts?: {
1483
1528
  /** Engine version stamped on audit entries for future drift detection. */
1484
1529
  declare const ENGINE_VERSION = "1.4.0";
1485
1530
 
1486
- export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COMMAND_WRAPPERS, COPY_VERBS, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, MAX_BLAST_PATH, NET_BINARIES, PATTERN_VERB_NAMES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type PositionedArg, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, fileOperandFlagsOf, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, patternShapeOf, positionedArgs, previewArgs, redactText, resolvePinned, safeMessage, sampleCopyCommand, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, stripControlChars, stripTerminalEscapes, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
1531
+ export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COMMAND_WRAPPERS, COPY_VERBS, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type Destination, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, MAX_BLAST_PATH, NET_BINARIES, PATTERN_VERB_NAMES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type PositionedArg, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, extractToolDestinations, fileOperandFlagsOf, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, patternShapeOf, positionedArgs, previewArgs, redactText, resolvePinned, safeMessage, sampleCopyCommand, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfExemptMatches, ssrfFloor, ssrfReason, stripControlChars, stripTerminalEscapes, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
package/dist/index.js CHANGED
@@ -90,6 +90,7 @@ __export(src_exports, {
90
90
  extractSessionLevelFindings: () => extractSessionLevelFindings,
91
91
  extractShellDestTokens: () => extractShellDestTokens,
92
92
  extractShellDestinations: () => extractShellDestinations,
93
+ extractToolDestinations: () => extractToolDestinations,
93
94
  fileOperandFlagsOf: () => fileOperandFlagsOf,
94
95
  getCompiledRegex: () => getCompiledRegex,
95
96
  getNestedValue: () => getNestedValue,
@@ -122,6 +123,7 @@ __export(src_exports, {
122
123
  scanText: () => scanText,
123
124
  sensitivePathMatch: () => sensitivePathMatch,
124
125
  ssrfDestinationFloor: () => ssrfDestinationFloor,
126
+ ssrfExemptMatches: () => ssrfExemptMatches,
125
127
  ssrfFloor: () => ssrfFloor,
126
128
  ssrfReason: () => ssrfReason,
127
129
  stripControlChars: () => stripControlChars,
@@ -3398,6 +3400,38 @@ function classifySsrf(host) {
3398
3400
  return null;
3399
3401
  }
3400
3402
  }
3403
+ function ssrfExemptMatches(entries, normalized) {
3404
+ if (!entries?.length || !normalized) return false;
3405
+ const target = bitsOf(normalized);
3406
+ for (const raw of entries) {
3407
+ const entry = raw.trim().toLowerCase();
3408
+ if (!entry) continue;
3409
+ const slash = entry.indexOf("/");
3410
+ if (slash === -1) {
3411
+ if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
3412
+ continue;
3413
+ }
3414
+ if (!target) continue;
3415
+ const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
3416
+ const prefixText = entry.slice(slash + 1);
3417
+ const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
3418
+ if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
3419
+ // differ, so `0.0.0.0/0` does not release `::1`.
3420
+ base.length !== target.length || prefix > base.length) {
3421
+ continue;
3422
+ }
3423
+ if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
3424
+ }
3425
+ return false;
3426
+ }
3427
+ function bitsOf(normalized) {
3428
+ const o = v4Octets(normalized);
3429
+ if (o) {
3430
+ return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
3431
+ }
3432
+ const g = expandIpv6(normalized);
3433
+ return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
3434
+ }
3401
3435
  var TIER_REASON = {
3402
3436
  metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3403
3437
  "link-local": "a link-local address",
@@ -3407,14 +3441,11 @@ var TIER_REASON = {
3407
3441
  private: "a loopback or private address"
3408
3442
  };
3409
3443
  function ssrfFloor(tokens, opts = {}) {
3410
- const exempt = new Set(
3411
- (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3412
- );
3413
3444
  for (const { token, binary } of tokens) {
3414
3445
  const m = classifySsrf(token);
3415
3446
  if (!m) continue;
3416
3447
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3417
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3448
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
3418
3449
  return { ...m, host: token, binary, reason: ssrfReason(m, token) };
3419
3450
  }
3420
3451
  return null;
@@ -3445,18 +3476,18 @@ var DEFAULT_EGRESS_ALLOWLIST = [
3445
3476
  "*.ubuntu.com"
3446
3477
  ];
3447
3478
  function hostMatches(host, pattern) {
3448
- const h = host.toLowerCase();
3449
- const p = pattern.toLowerCase().trim();
3479
+ const p = pattern.trim().toLowerCase();
3450
3480
  if (!p) return false;
3451
3481
  if (p === "*") return true;
3482
+ const h = canonicalHost(host);
3452
3483
  if (p.startsWith("*.")) {
3453
- const suffix = p.slice(2);
3484
+ const suffix = canonicalHost(p.slice(2));
3454
3485
  return h === suffix || h.endsWith("." + suffix);
3455
3486
  }
3456
- return h === p;
3487
+ return h === canonicalHost(p);
3457
3488
  }
3458
3489
  function matchesAny(host, patterns) {
3459
- for (const p of patterns) if (hostMatches(host, p)) return true;
3490
+ for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
3460
3491
  return false;
3461
3492
  }
3462
3493
  var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
@@ -3467,13 +3498,17 @@ function isUniqueLocalV6(host) {
3467
3498
  return g !== null && (g[0] & 65024) === 64512;
3468
3499
  }
3469
3500
  function isPrivateHost(host) {
3470
- const h = host.trim().toLowerCase();
3501
+ const h = host.trim().toLowerCase().replace(/\.$/, "");
3471
3502
  const m = classifySsrf(h);
3472
3503
  if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
3473
3504
  if (h === "localhost") return true;
3474
3505
  if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
3475
3506
  return isUniqueLocalV6(h);
3476
3507
  }
3508
+ function canonicalHost(host) {
3509
+ const ip = normalizeIpLiteral(host);
3510
+ return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
3511
+ }
3477
3512
  function evaluateEgress(dests, policy) {
3478
3513
  if (!policy.enabled) return null;
3479
3514
  let review = null;
@@ -3867,11 +3902,26 @@ function hostOf(value) {
3867
3902
  return null;
3868
3903
  }
3869
3904
  }
3905
+ function extractToolDestinations(toolName, args) {
3906
+ try {
3907
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
3908
+ if (!paths) return [];
3909
+ const out = [];
3910
+ for (const path of paths) {
3911
+ for (const value of valuesAt(args, path)) {
3912
+ const host = hostOf(value);
3913
+ if (host) out.push({ host, binary: toolName, raw: value });
3914
+ }
3915
+ }
3916
+ return out;
3917
+ } catch {
3918
+ return [];
3919
+ }
3920
+ }
3870
3921
  function ssrfDestinationFloor(toolName, args, opts = {}) {
3871
3922
  try {
3872
3923
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
3873
3924
  if (!paths) return null;
3874
- const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
3875
3925
  for (const path of paths) {
3876
3926
  for (const value of valuesAt(args, path)) {
3877
3927
  const host = hostOf(value);
@@ -3879,7 +3929,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
3879
3929
  const m = classifySsrf(host);
3880
3930
  if (!m) continue;
3881
3931
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3882
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3932
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
3883
3933
  return { ...m, argPath: path, host, reason: ssrfReason(m, host) };
3884
3934
  }
3885
3935
  }
@@ -3985,6 +4035,16 @@ function pipeChainVerdict(command, isTrustedHost, highAction = "review") {
3985
4035
  tier: 3
3986
4036
  };
3987
4037
  }
4038
+ function egressPolicyVerdict(eg) {
4039
+ return {
4040
+ decision: eg.verdict,
4041
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4042
+ reason: eg.reason,
4043
+ ruleName: `egress:${eg.binary}:${eg.host}`,
4044
+ ruleDescription: eg.reason,
4045
+ tier: eg.verdict === "block" ? 3 : 4
4046
+ };
4047
+ }
3988
4048
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
3989
4049
  const { agent, cwd, activeEnvironment } = context;
3990
4050
  const { checkProvenance, isTrustedHost } = hooks;
@@ -4019,7 +4079,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4019
4079
  };
4020
4080
  }
4021
4081
  }
4022
- if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
4082
+ const pendingToolEgress = config.policy.egress?.enabled ? (() => {
4083
+ const dests = extractToolDestinations(toolName, args);
4084
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
4085
+ return eg ? egressPolicyVerdict(eg) : void 0;
4086
+ })() : void 0;
4087
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) {
4088
+ return pendingToolEgress ?? { decision: "allow" };
4089
+ }
4023
4090
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
4024
4091
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
4025
4092
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
@@ -4108,6 +4175,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4108
4175
  }
4109
4176
  let allTokens = [];
4110
4177
  let pathTokens = [];
4178
+ if (pendingToolEgress) return pendingToolEgress;
4111
4179
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
4112
4180
  if (shellCommand) {
4113
4181
  const analyzed = analyzeShellCommand(shellCommand);
@@ -4173,16 +4241,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4173
4241
  const dests = extractShellDestinations(shellCommand);
4174
4242
  if (dests.length > 0) {
4175
4243
  const eg = evaluateEgress(dests, config.policy.egress);
4176
- if (eg) {
4177
- return {
4178
- decision: eg.verdict,
4179
- blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4180
- reason: eg.reason,
4181
- ruleName: `egress:${eg.binary}:${eg.host}`,
4182
- ruleDescription: eg.reason,
4183
- tier: eg.verdict === "block" ? 3 : 4
4184
- };
4185
- }
4244
+ if (eg) return egressPolicyVerdict(eg);
4186
4245
  }
4187
4246
  }
4188
4247
  const firstToken = analyzed.actions[0] ?? "";
@@ -6106,6 +6165,7 @@ var ENGINE_VERSION = "1.4.0";
6106
6165
  extractSessionLevelFindings,
6107
6166
  extractShellDestTokens,
6108
6167
  extractShellDestinations,
6168
+ extractToolDestinations,
6109
6169
  fileOperandFlagsOf,
6110
6170
  getCompiledRegex,
6111
6171
  getNestedValue,
@@ -6138,6 +6198,7 @@ var ENGINE_VERSION = "1.4.0";
6138
6198
  scanText,
6139
6199
  sensitivePathMatch,
6140
6200
  ssrfDestinationFloor,
6201
+ ssrfExemptMatches,
6141
6202
  ssrfFloor,
6142
6203
  ssrfReason,
6143
6204
  stripControlChars,
package/dist/index.mjs CHANGED
@@ -3258,6 +3258,38 @@ function classifySsrf(host) {
3258
3258
  return null;
3259
3259
  }
3260
3260
  }
3261
+ function ssrfExemptMatches(entries, normalized) {
3262
+ if (!entries?.length || !normalized) return false;
3263
+ const target = bitsOf(normalized);
3264
+ for (const raw of entries) {
3265
+ const entry = raw.trim().toLowerCase();
3266
+ if (!entry) continue;
3267
+ const slash = entry.indexOf("/");
3268
+ if (slash === -1) {
3269
+ if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
3270
+ continue;
3271
+ }
3272
+ if (!target) continue;
3273
+ const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
3274
+ const prefixText = entry.slice(slash + 1);
3275
+ const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
3276
+ if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
3277
+ // differ, so `0.0.0.0/0` does not release `::1`.
3278
+ base.length !== target.length || prefix > base.length) {
3279
+ continue;
3280
+ }
3281
+ if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
3282
+ }
3283
+ return false;
3284
+ }
3285
+ function bitsOf(normalized) {
3286
+ const o = v4Octets(normalized);
3287
+ if (o) {
3288
+ return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
3289
+ }
3290
+ const g = expandIpv6(normalized);
3291
+ return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
3292
+ }
3261
3293
  var TIER_REASON = {
3262
3294
  metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3263
3295
  "link-local": "a link-local address",
@@ -3267,14 +3299,11 @@ var TIER_REASON = {
3267
3299
  private: "a loopback or private address"
3268
3300
  };
3269
3301
  function ssrfFloor(tokens, opts = {}) {
3270
- const exempt = new Set(
3271
- (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3272
- );
3273
3302
  for (const { token, binary } of tokens) {
3274
3303
  const m = classifySsrf(token);
3275
3304
  if (!m) continue;
3276
3305
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3277
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3306
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
3278
3307
  return { ...m, host: token, binary, reason: ssrfReason(m, token) };
3279
3308
  }
3280
3309
  return null;
@@ -3305,18 +3334,18 @@ var DEFAULT_EGRESS_ALLOWLIST = [
3305
3334
  "*.ubuntu.com"
3306
3335
  ];
3307
3336
  function hostMatches(host, pattern) {
3308
- const h = host.toLowerCase();
3309
- const p = pattern.toLowerCase().trim();
3337
+ const p = pattern.trim().toLowerCase();
3310
3338
  if (!p) return false;
3311
3339
  if (p === "*") return true;
3340
+ const h = canonicalHost(host);
3312
3341
  if (p.startsWith("*.")) {
3313
- const suffix = p.slice(2);
3342
+ const suffix = canonicalHost(p.slice(2));
3314
3343
  return h === suffix || h.endsWith("." + suffix);
3315
3344
  }
3316
- return h === p;
3345
+ return h === canonicalHost(p);
3317
3346
  }
3318
3347
  function matchesAny(host, patterns) {
3319
- for (const p of patterns) if (hostMatches(host, p)) return true;
3348
+ for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
3320
3349
  return false;
3321
3350
  }
3322
3351
  var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
@@ -3327,13 +3356,17 @@ function isUniqueLocalV6(host) {
3327
3356
  return g !== null && (g[0] & 65024) === 64512;
3328
3357
  }
3329
3358
  function isPrivateHost(host) {
3330
- const h = host.trim().toLowerCase();
3359
+ const h = host.trim().toLowerCase().replace(/\.$/, "");
3331
3360
  const m = classifySsrf(h);
3332
3361
  if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
3333
3362
  if (h === "localhost") return true;
3334
3363
  if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
3335
3364
  return isUniqueLocalV6(h);
3336
3365
  }
3366
+ function canonicalHost(host) {
3367
+ const ip = normalizeIpLiteral(host);
3368
+ return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
3369
+ }
3337
3370
  function evaluateEgress(dests, policy) {
3338
3371
  if (!policy.enabled) return null;
3339
3372
  let review = null;
@@ -3727,11 +3760,26 @@ function hostOf(value) {
3727
3760
  return null;
3728
3761
  }
3729
3762
  }
3763
+ function extractToolDestinations(toolName, args) {
3764
+ try {
3765
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
3766
+ if (!paths) return [];
3767
+ const out = [];
3768
+ for (const path of paths) {
3769
+ for (const value of valuesAt(args, path)) {
3770
+ const host = hostOf(value);
3771
+ if (host) out.push({ host, binary: toolName, raw: value });
3772
+ }
3773
+ }
3774
+ return out;
3775
+ } catch {
3776
+ return [];
3777
+ }
3778
+ }
3730
3779
  function ssrfDestinationFloor(toolName, args, opts = {}) {
3731
3780
  try {
3732
3781
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
3733
3782
  if (!paths) return null;
3734
- const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
3735
3783
  for (const path of paths) {
3736
3784
  for (const value of valuesAt(args, path)) {
3737
3785
  const host = hostOf(value);
@@ -3739,7 +3787,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
3739
3787
  const m = classifySsrf(host);
3740
3788
  if (!m) continue;
3741
3789
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3742
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3790
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
3743
3791
  return { ...m, argPath: path, host, reason: ssrfReason(m, host) };
3744
3792
  }
3745
3793
  }
@@ -3845,6 +3893,16 @@ function pipeChainVerdict(command, isTrustedHost, highAction = "review") {
3845
3893
  tier: 3
3846
3894
  };
3847
3895
  }
3896
+ function egressPolicyVerdict(eg) {
3897
+ return {
3898
+ decision: eg.verdict,
3899
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
3900
+ reason: eg.reason,
3901
+ ruleName: `egress:${eg.binary}:${eg.host}`,
3902
+ ruleDescription: eg.reason,
3903
+ tier: eg.verdict === "block" ? 3 : 4
3904
+ };
3905
+ }
3848
3906
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
3849
3907
  const { agent, cwd, activeEnvironment } = context;
3850
3908
  const { checkProvenance, isTrustedHost } = hooks;
@@ -3879,7 +3937,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3879
3937
  };
3880
3938
  }
3881
3939
  }
3882
- if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
3940
+ const pendingToolEgress = config.policy.egress?.enabled ? (() => {
3941
+ const dests = extractToolDestinations(toolName, args);
3942
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
3943
+ return eg ? egressPolicyVerdict(eg) : void 0;
3944
+ })() : void 0;
3945
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) {
3946
+ return pendingToolEgress ?? { decision: "allow" };
3947
+ }
3883
3948
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
3884
3949
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
3885
3950
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
@@ -3968,6 +4033,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3968
4033
  }
3969
4034
  let allTokens = [];
3970
4035
  let pathTokens = [];
4036
+ if (pendingToolEgress) return pendingToolEgress;
3971
4037
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
3972
4038
  if (shellCommand) {
3973
4039
  const analyzed = analyzeShellCommand(shellCommand);
@@ -4033,16 +4099,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4033
4099
  const dests = extractShellDestinations(shellCommand);
4034
4100
  if (dests.length > 0) {
4035
4101
  const eg = evaluateEgress(dests, config.policy.egress);
4036
- if (eg) {
4037
- return {
4038
- decision: eg.verdict,
4039
- blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4040
- reason: eg.reason,
4041
- ruleName: `egress:${eg.binary}:${eg.host}`,
4042
- ruleDescription: eg.reason,
4043
- tier: eg.verdict === "block" ? 3 : 4
4044
- };
4045
- }
4102
+ if (eg) return egressPolicyVerdict(eg);
4046
4103
  }
4047
4104
  }
4048
4105
  const firstToken = analyzed.actions[0] ?? "";
@@ -5965,6 +6022,7 @@ export {
5965
6022
  extractSessionLevelFindings,
5966
6023
  extractShellDestTokens,
5967
6024
  extractShellDestinations,
6025
+ extractToolDestinations,
5968
6026
  fileOperandFlagsOf,
5969
6027
  getCompiledRegex,
5970
6028
  getNestedValue,
@@ -5997,6 +6055,7 @@ export {
5997
6055
  scanText,
5998
6056
  sensitivePathMatch,
5999
6057
  ssrfDestinationFloor,
6058
+ ssrfExemptMatches,
6000
6059
  ssrfFloor,
6001
6060
  ssrfReason,
6002
6061
  stripControlChars,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "2.16.2",
3
+ "version": "2.18.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",