@node9/policy-engine 2.12.0 → 2.13.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
@@ -217,6 +217,7 @@ interface FsOpVerdict {
217
217
  declare const BASH_TOOL_NAMES: Set<string>;
218
218
  declare function isBashTool(toolName: string): boolean;
219
219
  declare const AST_FS_REGEX_RULES: Set<string>;
220
+ declare const COMMAND_WRAPPERS: Set<string>;
220
221
  /**
221
222
  * Does `toolName` carry a shell command? True for BASH_TOOL_NAMES spellings and
222
223
  * for any tool whose toolInspection field is `command` (e.g. `terminal.execute`).
@@ -235,6 +236,11 @@ declare function isShellShapedTool(toolName: string, toolInspection?: Record<str
235
236
  * void them. An absent rule scope matches everything (callers' prior semantics).
236
237
  */
237
238
  declare function toolMatchesRule(toolName: string, ruleTool: string | string[] | undefined, toolInspection?: Record<string, string>): boolean;
239
+ /** Strip leading wrappers/runners from a resolved arg list, returning the index
240
+ * of the real command head. Handles `sudo -u www python3`, `env -u FOO python3`,
241
+ * `timeout 5 python3`, `uv run python`, `conda run -n env python`,
242
+ * `chroot /mnt python3`. */
243
+ declare function unwrapCommandHead(words: (string | null)[]): number;
238
244
  /**
239
245
  * AST-aware inline-execution detector. Returns true when the command runs code
240
246
  * supplied on the command line, via stdin, or via a pipe into a bare
@@ -255,6 +261,7 @@ interface ShellDestination {
255
261
  /** The raw argument token the host came from (for UI / audit). */
256
262
  raw: string;
257
263
  }
264
+ declare const NET_BINARIES: Set<string>;
258
265
  /**
259
266
  * Parse a destination host out of a single token. Handles scheme URLs
260
267
  * (`https://h/p`), scheme-less curl targets (`evil.com/p`), `user@host:path`
@@ -1196,7 +1203,7 @@ declare const LONG_OUTPUT_THRESHOLD_BYTES: number;
1196
1203
  * and fails CI when the hash drifts without a version bump — forgetting
1197
1204
  * is loud, not silent.
1198
1205
  */
1199
- declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
1206
+ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v13";
1200
1207
  /**
1201
1208
  * SHA-256 prefix of the detector-source files
1202
1209
  * (canonical.ts + pii.ts + destructive-regex.ts).
@@ -1207,7 +1214,7 @@ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
1207
1214
  * files changed, this hash must change too, and you must consciously
1208
1215
  * decide whether to bump CANONICAL_EXTRACTOR_VERSION."
1209
1216
  */
1210
- declare const CANONICAL_EXTRACTOR_HASH = "c4edee9dc99eb69a";
1217
+ declare const CANONICAL_EXTRACTOR_HASH = "4622a2f41696af04";
1211
1218
  declare function extractCanonicalFindings(call: ToolCallEntry, ctx: ExtractContext): CanonicalFinding[];
1212
1219
  declare function extractSessionLevelFindings(calls: ReadonlyArray<SessionToolCall>, ctx: SessionExtractContext): CanonicalFinding[];
1213
1220
  /**
@@ -1327,4 +1334,4 @@ declare function ssrfDestinationFloor(toolName: string, args: unknown, opts?: {
1327
1334
  /** Engine version stamped on audit entries for future drift detection. */
1328
1335
  declare const ENGINE_VERSION = "1.4.0";
1329
1336
 
1330
- 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, 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, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, 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, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, previewArgs, redactText, resolvePinned, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, validateOverrides, validateRegex, validateShieldDefinition };
1337
+ 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, 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, NET_BINARIES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, 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, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, previewArgs, redactText, resolvePinned, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
package/dist/index.d.ts CHANGED
@@ -217,6 +217,7 @@ interface FsOpVerdict {
217
217
  declare const BASH_TOOL_NAMES: Set<string>;
218
218
  declare function isBashTool(toolName: string): boolean;
219
219
  declare const AST_FS_REGEX_RULES: Set<string>;
220
+ declare const COMMAND_WRAPPERS: Set<string>;
220
221
  /**
221
222
  * Does `toolName` carry a shell command? True for BASH_TOOL_NAMES spellings and
222
223
  * for any tool whose toolInspection field is `command` (e.g. `terminal.execute`).
@@ -235,6 +236,11 @@ declare function isShellShapedTool(toolName: string, toolInspection?: Record<str
235
236
  * void them. An absent rule scope matches everything (callers' prior semantics).
236
237
  */
237
238
  declare function toolMatchesRule(toolName: string, ruleTool: string | string[] | undefined, toolInspection?: Record<string, string>): boolean;
239
+ /** Strip leading wrappers/runners from a resolved arg list, returning the index
240
+ * of the real command head. Handles `sudo -u www python3`, `env -u FOO python3`,
241
+ * `timeout 5 python3`, `uv run python`, `conda run -n env python`,
242
+ * `chroot /mnt python3`. */
243
+ declare function unwrapCommandHead(words: (string | null)[]): number;
238
244
  /**
239
245
  * AST-aware inline-execution detector. Returns true when the command runs code
240
246
  * supplied on the command line, via stdin, or via a pipe into a bare
@@ -255,6 +261,7 @@ interface ShellDestination {
255
261
  /** The raw argument token the host came from (for UI / audit). */
256
262
  raw: string;
257
263
  }
264
+ declare const NET_BINARIES: Set<string>;
258
265
  /**
259
266
  * Parse a destination host out of a single token. Handles scheme URLs
260
267
  * (`https://h/p`), scheme-less curl targets (`evil.com/p`), `user@host:path`
@@ -1196,7 +1203,7 @@ declare const LONG_OUTPUT_THRESHOLD_BYTES: number;
1196
1203
  * and fails CI when the hash drifts without a version bump — forgetting
1197
1204
  * is loud, not silent.
1198
1205
  */
1199
- declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
1206
+ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v13";
1200
1207
  /**
1201
1208
  * SHA-256 prefix of the detector-source files
1202
1209
  * (canonical.ts + pii.ts + destructive-regex.ts).
@@ -1207,7 +1214,7 @@ declare const CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
1207
1214
  * files changed, this hash must change too, and you must consciously
1208
1215
  * decide whether to bump CANONICAL_EXTRACTOR_VERSION."
1209
1216
  */
1210
- declare const CANONICAL_EXTRACTOR_HASH = "c4edee9dc99eb69a";
1217
+ declare const CANONICAL_EXTRACTOR_HASH = "4622a2f41696af04";
1211
1218
  declare function extractCanonicalFindings(call: ToolCallEntry, ctx: ExtractContext): CanonicalFinding[];
1212
1219
  declare function extractSessionLevelFindings(calls: ReadonlyArray<SessionToolCall>, ctx: SessionExtractContext): CanonicalFinding[];
1213
1220
  /**
@@ -1327,4 +1334,4 @@ declare function ssrfDestinationFloor(toolName: string, args: unknown, opts?: {
1327
1334
  /** Engine version stamped on audit entries for future drift detection. */
1328
1335
  declare const ENGINE_VERSION = "1.4.0";
1329
1336
 
1330
- 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, 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, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, 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, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, previewArgs, redactText, resolvePinned, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, validateOverrides, validateRegex, validateShieldDefinition };
1337
+ 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, 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, NET_BINARIES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, 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, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, previewArgs, redactText, resolvePinned, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ __export(src_exports, {
36
36
  CANARY_MIN_LENGTH: () => CANARY_MIN_LENGTH,
37
37
  CANONICAL_EXTRACTOR_HASH: () => CANONICAL_EXTRACTOR_HASH,
38
38
  CANONICAL_EXTRACTOR_VERSION: () => CANONICAL_EXTRACTOR_VERSION,
39
+ COMMAND_WRAPPERS: () => COMMAND_WRAPPERS,
39
40
  COST_PER_LOOP_ITER_USD: () => COST_PER_LOOP_ITER_USD,
40
41
  DEFAULT_EGRESS_ALLOWLIST: () => DEFAULT_EGRESS_ALLOWLIST,
41
42
  DESTINATION_ARGS: () => DESTINATION_ARGS,
@@ -49,6 +50,7 @@ __export(src_exports, {
49
50
  LONG_OUTPUT_THRESHOLD_BYTES: () => LONG_OUTPUT_THRESHOLD_BYTES,
50
51
  LOOP_MAX_RECORDS: () => LOOP_MAX_RECORDS,
51
52
  LOOP_THRESHOLD_FOR_WASTE: () => LOOP_THRESHOLD_FOR_WASTE,
53
+ NET_BINARIES: () => NET_BINARIES,
52
54
  PRIVILEGE_ESCALATION_RE: () => PRIVILEGE_ESCALATION_RE,
53
55
  REALTIME_PII_PATTERNS: () => REALTIME_PII_PATTERNS,
54
56
  SCAN_SIGNAL_WEIGHTS: () => SCAN_SIGNAL_WEIGHTS,
@@ -119,6 +121,7 @@ __export(src_exports, {
119
121
  toScanFinding: () => toScanFinding,
120
122
  toolMatchesRule: () => toolMatchesRule,
121
123
  truncateBlastPath: () => truncateBlastPath,
124
+ unwrapCommandHead: () => unwrapCommandHead,
122
125
  validateOverrides: () => validateOverrides,
123
126
  validateRegex: () => validateRegex,
124
127
  validateShieldDefinition: () => validateShieldDefinition
@@ -844,13 +847,32 @@ var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
844
847
  })
845
848
  );
846
849
  var SENSITIVE_PATH_PATTERNS = [
847
- /[/\\]\.ssh[/\\]/i,
848
- /[/\\]\.aws[/\\]/i,
850
+ /[/\\]\.ssh([/\\]|$)/i,
851
+ /[/\\]\.aws([/\\]|$)/i,
849
852
  /[/\\]\.config[/\\]gcloud[/\\]/i,
850
853
  /[/\\]\.azure[/\\]/i,
851
854
  /[/\\]\.kube[/\\]config$/i,
852
- /[/\\]\.env($|\.)/i,
853
- // .env, .env.local, .env.production — not .envoy
855
+ // ⚠️ ONE SEMANTIC, FOUR COPIES. This is the AST tier's `.env` rule verbatim
856
+ // (shell/index.ts SENSITIVE_PATH_RULES), whose reasoning is documented there:
857
+ // structural suffix chain rather than a hand-written list, `example|sample|
858
+ // template` exempt because a fixture stays a fixture whatever follows, and
859
+ // `.test` anchored because `test` names an ENVIRONMENT -- `.env.test` is the
860
+ // committed template, `.env.test.local` is gitignored and holds real values.
861
+ //
862
+ // It was previously `[/\\]\.env($|\.)` with NO exemptions, so `Read .env.example`
863
+ // blocked while `cat .env.example` allowed: the same file, opposite verdicts,
864
+ // decided only by which tool asked. See src/__tests__/jail-both-doors.test.ts,
865
+ // which is the contract that now holds these copies in step, and stage 5 of
866
+ // doc/credential-jail-architecture.md, which replaces them with one generated
867
+ // source.
868
+ // ⚠️ The `.local` branch comes FIRST and takes no exemption. A fixture stays a
869
+ // fixture whatever follows it -- `.env.example.md` is documentation -- but
870
+ // `.env.example.local` is gitignored by the `.env*.local` convention and holds
871
+ // real values, exactly the reasoning that anchors `(?!\.test$)` rather than
872
+ // using `\b`. Without this branch the fixture exemption also bought a two-step
873
+ // bypass: `cp .env .env.sample`, then read the copy.
874
+ /[/\\]\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
875
+ // .env + any suffix chain; fixtures exempt unless .local
854
876
  /[/\\]\.git-credentials$/i,
855
877
  /[/\\]\.npmrc$/i,
856
878
  /[/\\]\.docker[/\\]config\.json$/i,
@@ -1409,6 +1431,10 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1409
1431
  "od",
1410
1432
  "xxd",
1411
1433
  "hexdump",
1434
+ // Emits the file's bytes, re-encoded, so it is a read by the set's own test
1435
+ // ("does it emit file contents"). Absent until 2026-09-10, which is why
1436
+ // `base64 ~/.ssh/id_rsa` printed a private key with no verdict.
1437
+ "base64",
1412
1438
  "strings",
1413
1439
  "sort",
1414
1440
  "uniq",
@@ -1417,7 +1443,11 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1417
1443
  "dd"
1418
1444
  ]);
1419
1445
  var FS_OP_PRESCREEN_RE = new RegExp(
1420
- `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
1446
+ // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
1447
+ // reader right after `"` / `'`, and without these two characters the
1448
+ // prescreen rejected every string-wrapped read before the parser ran.
1449
+ // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
1450
+ `(?:^|[\\s|;&("'\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
1421
1451
  );
1422
1452
  var HOME_CACHE_ALLOWLIST = [
1423
1453
  ".cache",
@@ -1438,12 +1468,12 @@ var SENSITIVE_PATH_RULES = [
1438
1468
  {
1439
1469
  rule: "shield:project-jail:block-read-ssh",
1440
1470
  reason: "Reading SSH private keys is blocked by project-jail shield",
1441
- match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
1471
+ match: (p) => /([\\/]\.ssh[\\/]|^\.ssh[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.ssh$)/i.test(p)
1442
1472
  },
1443
1473
  {
1444
1474
  rule: "shield:project-jail:block-read-aws",
1445
1475
  reason: "Reading AWS credentials is blocked by project-jail shield",
1446
- match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
1476
+ match: (p) => /([\\/]\.aws[\\/]|^\.aws[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.aws$)/i.test(p)
1447
1477
  },
1448
1478
  {
1449
1479
  // Mirrors the JSON shield's `.env` pattern (project-jail.json's
@@ -1487,7 +1517,9 @@ var SENSITIVE_PATH_RULES = [
1487
1517
  // symmetry — silently exempts every `.env.test.*` file.
1488
1518
  //
1489
1519
  // shields.test.ts:983-995 is the canonical contract; keep both in step.
1490
- match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$/i.test(p)
1520
+ match: (p) => /(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i.test(
1521
+ p
1522
+ )
1491
1523
  },
1492
1524
  {
1493
1525
  // verdict: 'review' (not 'block') is a deliberate design choice
@@ -1698,15 +1730,14 @@ function listOps() {
1698
1730
  return _listOps;
1699
1731
  }
1700
1732
  var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
1733
+ var FIND_EXEC_FLAGS = /* @__PURE__ */ new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1701
1734
  var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1702
1735
  function unwrapCommandHead(words) {
1703
1736
  let i = 0;
1704
1737
  while (i < words.length) {
1705
1738
  const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1706
1739
  if (head === "find") {
1707
- const x = words.findIndex(
1708
- (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1709
- );
1740
+ const x = words.findIndex((w, k) => k > i && w !== null && FIND_EXEC_FLAGS.has(w));
1710
1741
  if (x < 0) break;
1711
1742
  i = x + 1;
1712
1743
  continue;
@@ -1728,7 +1759,11 @@ function unwrapCommandHead(words) {
1728
1759
  if (t.startsWith("-")) {
1729
1760
  i++;
1730
1761
  const nxt = words[i];
1731
- if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
1762
+ if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()) && // A reader is a command, never a flag's operand: `env - cat X`,
1763
+ // `stdbuf -o0 cat X`, `ionice -c3 cat X`. Without this the head was
1764
+ // swallowed and the jail needed a looser fallback whose cost was a
1765
+ // false positive on `sudo echo cat X`.
1766
+ !FS_READ_TOOLS.has(nxt.split("/").pop()?.toLowerCase() ?? ""))
1732
1767
  i++;
1733
1768
  continue;
1734
1769
  }
@@ -1849,36 +1884,29 @@ function isProtectedHomePath(rawPath) {
1849
1884
  }
1850
1885
  function extractLiteralArgs(callExpr) {
1851
1886
  const args = callExpr.Args || [];
1852
- if (args.length === 0) return { name: "", flags: [], paths: [] };
1853
- const litFromWord = (w) => {
1854
- const parts = w?.Parts || [];
1855
- let s = "";
1856
- for (const p of parts) {
1857
- const t = syntax.NodeType(p);
1858
- if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
1859
- else if (t === "SglQuoted") s += p.Value ?? "";
1860
- else if (t === "DblQuoted") {
1861
- const inner = p.Parts || [];
1862
- if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
1863
- s += inner.map((ip) => ip.Value ?? "").join("");
1864
- } else {
1865
- return null;
1866
- }
1867
- }
1868
- return s;
1869
- };
1870
- const name = (litFromWord(args[0]) || "").toLowerCase();
1887
+ if (args.length === 0) return { name: "", flags: [], paths: [], words: [] };
1888
+ const words = args.map((a) => resolveWordLiteral(a));
1889
+ const name = (words[0] ?? "").toLowerCase();
1871
1890
  const flags = [];
1872
1891
  const paths = [];
1873
- for (let i = 1; i < args.length; i++) {
1874
- const v = litFromWord(args[i]);
1892
+ for (let i = 1; i < words.length; i++) {
1893
+ const v = words[i];
1875
1894
  if (v === null) continue;
1876
1895
  if (v.startsWith("-")) flags.push(v);
1877
1896
  else paths.push(v);
1878
1897
  }
1879
- return { name, flags, paths };
1898
+ return { name, flags, paths, words };
1880
1899
  }
1881
- var NET_BINARIES = /* @__PURE__ */ new Set(["curl", "wget", "scp", "ssh", "nc", "ncat", "netcat"]);
1900
+ var NET_BINARIES = /* @__PURE__ */ new Set([
1901
+ "curl",
1902
+ "wget",
1903
+ "scp",
1904
+ "ssh",
1905
+ "nc",
1906
+ "ncat",
1907
+ "netcat",
1908
+ "rsync"
1909
+ ]);
1882
1910
  var VALUE_FLAGS = {
1883
1911
  curl: /* @__PURE__ */ new Set([
1884
1912
  "-d",
@@ -2163,6 +2191,9 @@ function deriveRedirOp(sample) {
2163
2191
  }
2164
2192
  }
2165
2193
  var REDIR_TRUNCATE_OPS = /* @__PURE__ */ new Set([deriveRedirOp(">_f")]);
2194
+ var REDIR_FILE_IN_OPS = new Set(
2195
+ [deriveRedirOp("cat < f"), deriveRedirOp("cat <> f")].filter((op) => op >= 0)
2196
+ );
2166
2197
  var REDIR_HEREDOC_OPS = /* @__PURE__ */ new Set([
2167
2198
  deriveRedirOp("cat <<X\nX"),
2168
2199
  deriveRedirOp("cat <<-X\nX")
@@ -2227,16 +2258,21 @@ function isRmCreatedInCommandCleanup(command) {
2227
2258
  }
2228
2259
  return sawRm && ok;
2229
2260
  }
2230
- function analyzeFsOperationImpl(command) {
2261
+ function analyzeFsOperationImpl(command, depth = 0) {
2231
2262
  const f = parseShared(command);
2232
2263
  if (f === PARSE_FAIL) return null;
2233
2264
  let result = null;
2234
2265
  try {
2235
2266
  syntax.Walk(f, (node) => {
2236
- if (!node || result) return false;
2267
+ if (!node || result?.verdict === "block") return false;
2237
2268
  const n = node;
2238
- if (syntax.NodeType(n) !== "CallExpr") return true;
2239
- const { name, flags, paths } = extractLiteralArgs(n);
2269
+ const nodeType = syntax.NodeType(n);
2270
+ if (nodeType === "Stmt") {
2271
+ result = stricter(result, jailedRedirectRead(n));
2272
+ return result?.verdict !== "block";
2273
+ }
2274
+ if (nodeType !== "CallExpr") return true;
2275
+ const { name, flags, paths, words } = extractLiteralArgs(n);
2240
2276
  if (!name) return true;
2241
2277
  if (name === "rm") {
2242
2278
  const flagStr = flags.join("").toLowerCase();
@@ -2265,19 +2301,22 @@ function analyzeFsOperationImpl(command) {
2265
2301
  }
2266
2302
  }
2267
2303
  }
2268
- if (FS_READ_TOOLS.has(name)) {
2269
- for (const p of paths) {
2270
- for (const sp of SENSITIVE_PATH_RULES) {
2271
- if (sp.match(p)) {
2272
- result = {
2273
- ruleName: sp.rule,
2274
- verdict: sp.verdict ?? "block",
2275
- reason: sp.reason,
2276
- path: p
2277
- };
2278
- return false;
2279
- }
2304
+ if (depth < 1) {
2305
+ const payload = literalShellPayload(words, name);
2306
+ if (payload !== null) {
2307
+ const inner = analyzeFsOperationImpl(payload, depth + 1);
2308
+ if (inner) {
2309
+ result = inner;
2310
+ return false;
2280
2311
  }
2312
+ return true;
2313
+ }
2314
+ }
2315
+ const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
2316
+ if (readPaths) {
2317
+ for (const p of readPaths) {
2318
+ result = stricter(result, matchSensitivePath2(p));
2319
+ if (result?.verdict === "block") return false;
2281
2320
  }
2282
2321
  }
2283
2322
  return true;
@@ -2287,6 +2326,57 @@ function analyzeFsOperationImpl(command) {
2287
2326
  return null;
2288
2327
  }
2289
2328
  }
2329
+ function stricter(a, b) {
2330
+ if (!a) return b;
2331
+ if (!b) return a;
2332
+ return b.verdict === "block" && a.verdict !== "block" ? b : a;
2333
+ }
2334
+ function matchSensitivePath2(p) {
2335
+ for (const sp of SENSITIVE_PATH_RULES) {
2336
+ if (sp.match(p))
2337
+ return { ruleName: sp.rule, verdict: sp.verdict ?? "block", reason: sp.reason, path: p };
2338
+ }
2339
+ return null;
2340
+ }
2341
+ var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(w.split("/").pop()?.toLowerCase() ?? "");
2342
+ var positionalAfter = (words, from, to = words.length) => words.slice(from, to).filter((w) => w !== null && !w.startsWith("-"));
2343
+ function wrappedReadPaths(words, name) {
2344
+ if (name === "find") {
2345
+ const k = words.findIndex((w) => w !== null && FIND_EXEC_FLAGS.has(w));
2346
+ if (k < 1 || !isReaderWord(words[k + 1] ?? null)) return null;
2347
+ const firstPredicate = words.findIndex((w, i) => i > 0 && w !== null && w.startsWith("-"));
2348
+ return positionalAfter(words, 1, firstPredicate > 0 ? firstPredicate : k);
2349
+ }
2350
+ if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
2351
+ const h = unwrapCommandHead(words);
2352
+ return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
2353
+ }
2354
+ function literalShellPayload(words, name) {
2355
+ const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
2356
+ const head = (words[h] ?? "").split("/").pop()?.toLowerCase() ?? "";
2357
+ if (head === "eval") {
2358
+ const rest = words.slice(h + 1);
2359
+ if (rest.length === 0 || rest.some((w) => w === null)) return null;
2360
+ return rest.join(" ");
2361
+ }
2362
+ if (SHELL_INTERPRETERS.has(head)) {
2363
+ const c = words.findIndex((w, i) => i > h && w !== null && isInlineCodeFlag(head, w));
2364
+ if (c < 0) return null;
2365
+ return words[c + 1] ?? null;
2366
+ }
2367
+ return null;
2368
+ }
2369
+ function jailedRedirectRead(stmt) {
2370
+ const redirs = stmt.Redirs || [];
2371
+ for (const r of redirs) {
2372
+ if (!r || !REDIR_FILE_IN_OPS.has(r.Op)) continue;
2373
+ const p = resolveWordLiteral(r.Word);
2374
+ if (p === null || p === "") continue;
2375
+ const hit = matchSensitivePath2(p);
2376
+ if (hit) return hit;
2377
+ }
2378
+ return null;
2379
+ }
2290
2380
  function analyzeShellCommand(command) {
2291
2381
  const actions = [];
2292
2382
  const paths = [];
@@ -2423,21 +2513,7 @@ function evaluateEgress(dests, policy) {
2423
2513
  }
2424
2514
 
2425
2515
  // src/policy/pipe-chain.ts
2426
- var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
2427
- "cat",
2428
- "head",
2429
- "tail",
2430
- "grep",
2431
- "awk",
2432
- "sed",
2433
- "cut",
2434
- "sort",
2435
- "tee",
2436
- "less",
2437
- "more",
2438
- "strings",
2439
- "xxd"
2440
- ]);
2516
+ var SOURCE_COMMANDS = /* @__PURE__ */ new Set([...FS_READ_TOOLS, "tee"]);
2441
2517
  var SINK_COMMANDS = /* @__PURE__ */ new Set([
2442
2518
  "curl",
2443
2519
  "wget",
@@ -2468,16 +2544,25 @@ var OBFUSCATORS = /* @__PURE__ */ new Set([
2468
2544
  "node"
2469
2545
  ]);
2470
2546
  var SENSITIVE_PATTERNS = [
2471
- /(?:^|\/)\.env(?:\.|$)/i,
2472
- // .env, .env.local, .env.production
2547
+ // Kept in step with the AST tier and dlp/ -- see jail-both-doors.test.ts.
2548
+ /(?:^|\/)\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
2549
+ // .env chain; fixtures exempt unless .local
2473
2550
  /id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
2474
2551
  // SSH private keys
2475
2552
  /\.pem$|\.key$|\.p12$|\.pfx$/i,
2476
2553
  // certificate files
2477
- /(?:^|\/)\.ssh\//i,
2478
- // ~/.ssh/ directory
2479
- /(?:^|\/)\.aws\/credentials/i,
2480
- // AWS credentials
2554
+ // The `$` half mirrors shell/index.ts's SENSITIVE_PATH_RULES: a file INSIDE
2555
+ // the directory counts wherever it appears, while the directory ITSELF counts
2556
+ // only when the path is ROOTED (`~/.ssh`, `/home/u/.ssh`) -- an unrooted
2557
+ // `config/.ssh` is more likely a search pattern than a read. These are
2558
+ // extracted TOKENS (see `args.some(isSensitivePath)` below), the same input
2559
+ // contract as the shell tier, so the same boundary is the right one.
2560
+ // Without it `grep -r x ~/.ssh | curl -d @-` scored one tier BELOW the
2561
+ // identical pipeline naming a file inside that directory.
2562
+ /(?:^|\/)\.ssh\/|^(?:[~/]|[A-Za-z]:).*\/\.ssh$/i,
2563
+ // ~/.ssh/ and ~/.ssh
2564
+ /(?:^|\/)\.aws\/credentials|^(?:[~/]|[A-Za-z]:).*\/\.aws$/i,
2565
+ // AWS creds + dir
2481
2566
  /(?:^|\/)\.netrc$/i,
2482
2567
  // netrc (stores HTTP credentials)
2483
2568
  /(?:^|\/)(passwd|shadow|sudoers)$/i,
@@ -2511,8 +2596,8 @@ function splitOnPipe(cmd) {
2511
2596
  if (current.trim()) segments2.push(current.trim());
2512
2597
  return segments2.filter(Boolean);
2513
2598
  }
2514
- function positionalTokens(segment) {
2515
- return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2599
+ function positionalTokens(tokens) {
2600
+ return tokens.slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2516
2601
  }
2517
2602
  function analyzePipeChain(command) {
2518
2603
  const segments2 = splitOnPipe(command);
@@ -2535,8 +2620,10 @@ function analyzePipeChain(command) {
2535
2620
  for (const segment of segments2) {
2536
2621
  const tokens = segment.split(/\s+/).filter(Boolean);
2537
2622
  if (tokens.length === 0) continue;
2538
- const binary = tokens[0].toLowerCase();
2539
- const args = positionalTokens(segment);
2623
+ const h = unwrapCommandHead(tokens);
2624
+ const head = h < tokens.length ? h : 0;
2625
+ const binary = tokens[head].toLowerCase();
2626
+ const args = positionalTokens(tokens.slice(head));
2540
2627
  if (SOURCE_COMMANDS.has(binary)) {
2541
2628
  sourceFiles.push(...args);
2542
2629
  if (args.some(isSensitivePath)) hasSensitiveSource = true;
@@ -3979,7 +4066,7 @@ var project_jail_default = {
3979
4066
  {
3980
4067
  field: "command",
3981
4068
  op: "matches",
3982
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
4069
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
3983
4070
  flags: "i"
3984
4071
  }
3985
4072
  ],
@@ -3993,7 +4080,7 @@ var project_jail_default = {
3993
4080
  {
3994
4081
  field: "command",
3995
4082
  op: "matches",
3996
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
4083
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
3997
4084
  flags: "i"
3998
4085
  }
3999
4086
  ],
@@ -4007,7 +4094,7 @@ var project_jail_default = {
4007
4094
  {
4008
4095
  field: "command",
4009
4096
  op: "matches",
4010
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
4097
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
4011
4098
  flags: "i"
4012
4099
  }
4013
4100
  ],
@@ -4021,7 +4108,7 @@ var project_jail_default = {
4021
4108
  {
4022
4109
  field: "command",
4023
4110
  op: "matches",
4024
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
4111
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
4025
4112
  flags: "i"
4026
4113
  }
4027
4114
  ],
@@ -4035,7 +4122,7 @@ var project_jail_default = {
4035
4122
  {
4036
4123
  field: "file_path",
4037
4124
  op: "matches",
4038
- value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
4125
+ value: "([\\/\\\\]\\.ssh([\\/\\\\]|$)|^\\.ssh[\\/\\\\])",
4039
4126
  flags: "i"
4040
4127
  }
4041
4128
  ],
@@ -4049,7 +4136,7 @@ var project_jail_default = {
4049
4136
  {
4050
4137
  field: "file_path",
4051
4138
  op: "matches",
4052
- value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
4139
+ value: "([\\/\\\\]\\.aws([\\/\\\\]|$)|^\\.aws[\\/\\\\])",
4053
4140
  flags: "i"
4054
4141
  }
4055
4142
  ],
@@ -4063,7 +4150,7 @@ var project_jail_default = {
4063
4150
  {
4064
4151
  field: "file_path",
4065
4152
  op: "matches",
4066
- value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
4153
+ value: "(^|[\\/\\\\])\\.env(?![\\w-])(?:[\\w.-]*\\.local$|(?!\\.(example|sample|template)\\b)(?!\\.test$)[\\w.-]*$)",
4067
4154
  flags: "i"
4068
4155
  }
4069
4156
  ],
@@ -4506,7 +4593,7 @@ function summarizeBlast(result, opts = {}) {
4506
4593
  // src/scan/destructive-regex.ts
4507
4594
  var DESTRUCTIVE_OP_RE = /\brm\s+-[rRf]+\b|\bDROP\s+(TABLE|DATABASE|COLLECTION|SCHEMA)\b|\bTRUNCATE\s+TABLE\b|\bgit\s+push\s+(--force|-f)\b|\bFLUSHALL\b|\bFLUSHDB\b|\bkubectl\s+delete\b|\bhelm\s+uninstall\b/i;
4508
4595
  var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
4509
- var SENSITIVE_PATH_RE = /\.aws\/(credentials|config)\b|\.ssh\/(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b|\.env(\.|$|\b)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
4596
+ var SENSITIVE_PATH_RE = /[\\/]\.aws(?:[\\/]|$)|^\.aws[\\/]|[\\/]\.ssh(?:[\\/]|$)|^\.ssh[\\/]|(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
4510
4597
  var FILE_TOOLS = /* @__PURE__ */ new Set([
4511
4598
  "read",
4512
4599
  "read_file",
@@ -4766,8 +4853,8 @@ function matchCanaryArgs(args, values) {
4766
4853
 
4767
4854
  // src/scan/canonical.ts
4768
4855
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
4769
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
4770
- var CANONICAL_EXTRACTOR_HASH = "c4edee9dc99eb69a";
4856
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v13";
4857
+ var CANONICAL_EXTRACTOR_HASH = "4622a2f41696af04";
4771
4858
  var DEDUPE_PREVIEW_LEN = 120;
4772
4859
  function extractCanonicalFindings(call, ctx) {
4773
4860
  const out = [];
@@ -5143,6 +5230,7 @@ var ENGINE_VERSION = "1.4.0";
5143
5230
  CANARY_MIN_LENGTH,
5144
5231
  CANONICAL_EXTRACTOR_HASH,
5145
5232
  CANONICAL_EXTRACTOR_VERSION,
5233
+ COMMAND_WRAPPERS,
5146
5234
  COST_PER_LOOP_ITER_USD,
5147
5235
  DEFAULT_EGRESS_ALLOWLIST,
5148
5236
  DESTINATION_ARGS,
@@ -5156,6 +5244,7 @@ var ENGINE_VERSION = "1.4.0";
5156
5244
  LONG_OUTPUT_THRESHOLD_BYTES,
5157
5245
  LOOP_MAX_RECORDS,
5158
5246
  LOOP_THRESHOLD_FOR_WASTE,
5247
+ NET_BINARIES,
5159
5248
  PRIVILEGE_ESCALATION_RE,
5160
5249
  REALTIME_PII_PATTERNS,
5161
5250
  SCAN_SIGNAL_WEIGHTS,
@@ -5226,6 +5315,7 @@ var ENGINE_VERSION = "1.4.0";
5226
5315
  toScanFinding,
5227
5316
  toolMatchesRule,
5228
5317
  truncateBlastPath,
5318
+ unwrapCommandHead,
5229
5319
  validateOverrides,
5230
5320
  validateRegex,
5231
5321
  validateShieldDefinition
package/dist/index.mjs CHANGED
@@ -717,13 +717,32 @@ var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
717
717
  })
718
718
  );
719
719
  var SENSITIVE_PATH_PATTERNS = [
720
- /[/\\]\.ssh[/\\]/i,
721
- /[/\\]\.aws[/\\]/i,
720
+ /[/\\]\.ssh([/\\]|$)/i,
721
+ /[/\\]\.aws([/\\]|$)/i,
722
722
  /[/\\]\.config[/\\]gcloud[/\\]/i,
723
723
  /[/\\]\.azure[/\\]/i,
724
724
  /[/\\]\.kube[/\\]config$/i,
725
- /[/\\]\.env($|\.)/i,
726
- // .env, .env.local, .env.production — not .envoy
725
+ // ⚠️ ONE SEMANTIC, FOUR COPIES. This is the AST tier's `.env` rule verbatim
726
+ // (shell/index.ts SENSITIVE_PATH_RULES), whose reasoning is documented there:
727
+ // structural suffix chain rather than a hand-written list, `example|sample|
728
+ // template` exempt because a fixture stays a fixture whatever follows, and
729
+ // `.test` anchored because `test` names an ENVIRONMENT -- `.env.test` is the
730
+ // committed template, `.env.test.local` is gitignored and holds real values.
731
+ //
732
+ // It was previously `[/\\]\.env($|\.)` with NO exemptions, so `Read .env.example`
733
+ // blocked while `cat .env.example` allowed: the same file, opposite verdicts,
734
+ // decided only by which tool asked. See src/__tests__/jail-both-doors.test.ts,
735
+ // which is the contract that now holds these copies in step, and stage 5 of
736
+ // doc/credential-jail-architecture.md, which replaces them with one generated
737
+ // source.
738
+ // ⚠️ The `.local` branch comes FIRST and takes no exemption. A fixture stays a
739
+ // fixture whatever follows it -- `.env.example.md` is documentation -- but
740
+ // `.env.example.local` is gitignored by the `.env*.local` convention and holds
741
+ // real values, exactly the reasoning that anchors `(?!\.test$)` rather than
742
+ // using `\b`. Without this branch the fixture exemption also bought a two-step
743
+ // bypass: `cp .env .env.sample`, then read the copy.
744
+ /[/\\]\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
745
+ // .env + any suffix chain; fixtures exempt unless .local
727
746
  /[/\\]\.git-credentials$/i,
728
747
  /[/\\]\.npmrc$/i,
729
748
  /[/\\]\.docker[/\\]config\.json$/i,
@@ -1282,6 +1301,10 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1282
1301
  "od",
1283
1302
  "xxd",
1284
1303
  "hexdump",
1304
+ // Emits the file's bytes, re-encoded, so it is a read by the set's own test
1305
+ // ("does it emit file contents"). Absent until 2026-09-10, which is why
1306
+ // `base64 ~/.ssh/id_rsa` printed a private key with no verdict.
1307
+ "base64",
1285
1308
  "strings",
1286
1309
  "sort",
1287
1310
  "uniq",
@@ -1290,7 +1313,11 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1290
1313
  "dd"
1291
1314
  ]);
1292
1315
  var FS_OP_PRESCREEN_RE = new RegExp(
1293
- `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
1316
+ // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
1317
+ // reader right after `"` / `'`, and without these two characters the
1318
+ // prescreen rejected every string-wrapped read before the parser ran.
1319
+ // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
1320
+ `(?:^|[\\s|;&("'\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
1294
1321
  );
1295
1322
  var HOME_CACHE_ALLOWLIST = [
1296
1323
  ".cache",
@@ -1311,12 +1338,12 @@ var SENSITIVE_PATH_RULES = [
1311
1338
  {
1312
1339
  rule: "shield:project-jail:block-read-ssh",
1313
1340
  reason: "Reading SSH private keys is blocked by project-jail shield",
1314
- match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
1341
+ match: (p) => /([\\/]\.ssh[\\/]|^\.ssh[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.ssh$)/i.test(p)
1315
1342
  },
1316
1343
  {
1317
1344
  rule: "shield:project-jail:block-read-aws",
1318
1345
  reason: "Reading AWS credentials is blocked by project-jail shield",
1319
- match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
1346
+ match: (p) => /([\\/]\.aws[\\/]|^\.aws[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.aws$)/i.test(p)
1320
1347
  },
1321
1348
  {
1322
1349
  // Mirrors the JSON shield's `.env` pattern (project-jail.json's
@@ -1360,7 +1387,9 @@ var SENSITIVE_PATH_RULES = [
1360
1387
  // symmetry — silently exempts every `.env.test.*` file.
1361
1388
  //
1362
1389
  // shields.test.ts:983-995 is the canonical contract; keep both in step.
1363
- match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$/i.test(p)
1390
+ match: (p) => /(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i.test(
1391
+ p
1392
+ )
1364
1393
  },
1365
1394
  {
1366
1395
  // verdict: 'review' (not 'block') is a deliberate design choice
@@ -1571,15 +1600,14 @@ function listOps() {
1571
1600
  return _listOps;
1572
1601
  }
1573
1602
  var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
1603
+ var FIND_EXEC_FLAGS = /* @__PURE__ */ new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1574
1604
  var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1575
1605
  function unwrapCommandHead(words) {
1576
1606
  let i = 0;
1577
1607
  while (i < words.length) {
1578
1608
  const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1579
1609
  if (head === "find") {
1580
- const x = words.findIndex(
1581
- (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1582
- );
1610
+ const x = words.findIndex((w, k) => k > i && w !== null && FIND_EXEC_FLAGS.has(w));
1583
1611
  if (x < 0) break;
1584
1612
  i = x + 1;
1585
1613
  continue;
@@ -1601,7 +1629,11 @@ function unwrapCommandHead(words) {
1601
1629
  if (t.startsWith("-")) {
1602
1630
  i++;
1603
1631
  const nxt = words[i];
1604
- if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
1632
+ if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()) && // A reader is a command, never a flag's operand: `env - cat X`,
1633
+ // `stdbuf -o0 cat X`, `ionice -c3 cat X`. Without this the head was
1634
+ // swallowed and the jail needed a looser fallback whose cost was a
1635
+ // false positive on `sudo echo cat X`.
1636
+ !FS_READ_TOOLS.has(nxt.split("/").pop()?.toLowerCase() ?? ""))
1605
1637
  i++;
1606
1638
  continue;
1607
1639
  }
@@ -1722,36 +1754,29 @@ function isProtectedHomePath(rawPath) {
1722
1754
  }
1723
1755
  function extractLiteralArgs(callExpr) {
1724
1756
  const args = callExpr.Args || [];
1725
- if (args.length === 0) return { name: "", flags: [], paths: [] };
1726
- const litFromWord = (w) => {
1727
- const parts = w?.Parts || [];
1728
- let s = "";
1729
- for (const p of parts) {
1730
- const t = syntax.NodeType(p);
1731
- if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
1732
- else if (t === "SglQuoted") s += p.Value ?? "";
1733
- else if (t === "DblQuoted") {
1734
- const inner = p.Parts || [];
1735
- if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
1736
- s += inner.map((ip) => ip.Value ?? "").join("");
1737
- } else {
1738
- return null;
1739
- }
1740
- }
1741
- return s;
1742
- };
1743
- const name = (litFromWord(args[0]) || "").toLowerCase();
1757
+ if (args.length === 0) return { name: "", flags: [], paths: [], words: [] };
1758
+ const words = args.map((a) => resolveWordLiteral(a));
1759
+ const name = (words[0] ?? "").toLowerCase();
1744
1760
  const flags = [];
1745
1761
  const paths = [];
1746
- for (let i = 1; i < args.length; i++) {
1747
- const v = litFromWord(args[i]);
1762
+ for (let i = 1; i < words.length; i++) {
1763
+ const v = words[i];
1748
1764
  if (v === null) continue;
1749
1765
  if (v.startsWith("-")) flags.push(v);
1750
1766
  else paths.push(v);
1751
1767
  }
1752
- return { name, flags, paths };
1768
+ return { name, flags, paths, words };
1753
1769
  }
1754
- var NET_BINARIES = /* @__PURE__ */ new Set(["curl", "wget", "scp", "ssh", "nc", "ncat", "netcat"]);
1770
+ var NET_BINARIES = /* @__PURE__ */ new Set([
1771
+ "curl",
1772
+ "wget",
1773
+ "scp",
1774
+ "ssh",
1775
+ "nc",
1776
+ "ncat",
1777
+ "netcat",
1778
+ "rsync"
1779
+ ]);
1755
1780
  var VALUE_FLAGS = {
1756
1781
  curl: /* @__PURE__ */ new Set([
1757
1782
  "-d",
@@ -2036,6 +2061,9 @@ function deriveRedirOp(sample) {
2036
2061
  }
2037
2062
  }
2038
2063
  var REDIR_TRUNCATE_OPS = /* @__PURE__ */ new Set([deriveRedirOp(">_f")]);
2064
+ var REDIR_FILE_IN_OPS = new Set(
2065
+ [deriveRedirOp("cat < f"), deriveRedirOp("cat <> f")].filter((op) => op >= 0)
2066
+ );
2039
2067
  var REDIR_HEREDOC_OPS = /* @__PURE__ */ new Set([
2040
2068
  deriveRedirOp("cat <<X\nX"),
2041
2069
  deriveRedirOp("cat <<-X\nX")
@@ -2100,16 +2128,21 @@ function isRmCreatedInCommandCleanup(command) {
2100
2128
  }
2101
2129
  return sawRm && ok;
2102
2130
  }
2103
- function analyzeFsOperationImpl(command) {
2131
+ function analyzeFsOperationImpl(command, depth = 0) {
2104
2132
  const f = parseShared(command);
2105
2133
  if (f === PARSE_FAIL) return null;
2106
2134
  let result = null;
2107
2135
  try {
2108
2136
  syntax.Walk(f, (node) => {
2109
- if (!node || result) return false;
2137
+ if (!node || result?.verdict === "block") return false;
2110
2138
  const n = node;
2111
- if (syntax.NodeType(n) !== "CallExpr") return true;
2112
- const { name, flags, paths } = extractLiteralArgs(n);
2139
+ const nodeType = syntax.NodeType(n);
2140
+ if (nodeType === "Stmt") {
2141
+ result = stricter(result, jailedRedirectRead(n));
2142
+ return result?.verdict !== "block";
2143
+ }
2144
+ if (nodeType !== "CallExpr") return true;
2145
+ const { name, flags, paths, words } = extractLiteralArgs(n);
2113
2146
  if (!name) return true;
2114
2147
  if (name === "rm") {
2115
2148
  const flagStr = flags.join("").toLowerCase();
@@ -2138,19 +2171,22 @@ function analyzeFsOperationImpl(command) {
2138
2171
  }
2139
2172
  }
2140
2173
  }
2141
- if (FS_READ_TOOLS.has(name)) {
2142
- for (const p of paths) {
2143
- for (const sp of SENSITIVE_PATH_RULES) {
2144
- if (sp.match(p)) {
2145
- result = {
2146
- ruleName: sp.rule,
2147
- verdict: sp.verdict ?? "block",
2148
- reason: sp.reason,
2149
- path: p
2150
- };
2151
- return false;
2152
- }
2174
+ if (depth < 1) {
2175
+ const payload = literalShellPayload(words, name);
2176
+ if (payload !== null) {
2177
+ const inner = analyzeFsOperationImpl(payload, depth + 1);
2178
+ if (inner) {
2179
+ result = inner;
2180
+ return false;
2153
2181
  }
2182
+ return true;
2183
+ }
2184
+ }
2185
+ const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
2186
+ if (readPaths) {
2187
+ for (const p of readPaths) {
2188
+ result = stricter(result, matchSensitivePath2(p));
2189
+ if (result?.verdict === "block") return false;
2154
2190
  }
2155
2191
  }
2156
2192
  return true;
@@ -2160,6 +2196,57 @@ function analyzeFsOperationImpl(command) {
2160
2196
  return null;
2161
2197
  }
2162
2198
  }
2199
+ function stricter(a, b) {
2200
+ if (!a) return b;
2201
+ if (!b) return a;
2202
+ return b.verdict === "block" && a.verdict !== "block" ? b : a;
2203
+ }
2204
+ function matchSensitivePath2(p) {
2205
+ for (const sp of SENSITIVE_PATH_RULES) {
2206
+ if (sp.match(p))
2207
+ return { ruleName: sp.rule, verdict: sp.verdict ?? "block", reason: sp.reason, path: p };
2208
+ }
2209
+ return null;
2210
+ }
2211
+ var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(w.split("/").pop()?.toLowerCase() ?? "");
2212
+ var positionalAfter = (words, from, to = words.length) => words.slice(from, to).filter((w) => w !== null && !w.startsWith("-"));
2213
+ function wrappedReadPaths(words, name) {
2214
+ if (name === "find") {
2215
+ const k = words.findIndex((w) => w !== null && FIND_EXEC_FLAGS.has(w));
2216
+ if (k < 1 || !isReaderWord(words[k + 1] ?? null)) return null;
2217
+ const firstPredicate = words.findIndex((w, i) => i > 0 && w !== null && w.startsWith("-"));
2218
+ return positionalAfter(words, 1, firstPredicate > 0 ? firstPredicate : k);
2219
+ }
2220
+ if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
2221
+ const h = unwrapCommandHead(words);
2222
+ return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
2223
+ }
2224
+ function literalShellPayload(words, name) {
2225
+ const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
2226
+ const head = (words[h] ?? "").split("/").pop()?.toLowerCase() ?? "";
2227
+ if (head === "eval") {
2228
+ const rest = words.slice(h + 1);
2229
+ if (rest.length === 0 || rest.some((w) => w === null)) return null;
2230
+ return rest.join(" ");
2231
+ }
2232
+ if (SHELL_INTERPRETERS.has(head)) {
2233
+ const c = words.findIndex((w, i) => i > h && w !== null && isInlineCodeFlag(head, w));
2234
+ if (c < 0) return null;
2235
+ return words[c + 1] ?? null;
2236
+ }
2237
+ return null;
2238
+ }
2239
+ function jailedRedirectRead(stmt) {
2240
+ const redirs = stmt.Redirs || [];
2241
+ for (const r of redirs) {
2242
+ if (!r || !REDIR_FILE_IN_OPS.has(r.Op)) continue;
2243
+ const p = resolveWordLiteral(r.Word);
2244
+ if (p === null || p === "") continue;
2245
+ const hit = matchSensitivePath2(p);
2246
+ if (hit) return hit;
2247
+ }
2248
+ return null;
2249
+ }
2163
2250
  function analyzeShellCommand(command) {
2164
2251
  const actions = [];
2165
2252
  const paths = [];
@@ -2296,21 +2383,7 @@ function evaluateEgress(dests, policy) {
2296
2383
  }
2297
2384
 
2298
2385
  // src/policy/pipe-chain.ts
2299
- var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
2300
- "cat",
2301
- "head",
2302
- "tail",
2303
- "grep",
2304
- "awk",
2305
- "sed",
2306
- "cut",
2307
- "sort",
2308
- "tee",
2309
- "less",
2310
- "more",
2311
- "strings",
2312
- "xxd"
2313
- ]);
2386
+ var SOURCE_COMMANDS = /* @__PURE__ */ new Set([...FS_READ_TOOLS, "tee"]);
2314
2387
  var SINK_COMMANDS = /* @__PURE__ */ new Set([
2315
2388
  "curl",
2316
2389
  "wget",
@@ -2341,16 +2414,25 @@ var OBFUSCATORS = /* @__PURE__ */ new Set([
2341
2414
  "node"
2342
2415
  ]);
2343
2416
  var SENSITIVE_PATTERNS = [
2344
- /(?:^|\/)\.env(?:\.|$)/i,
2345
- // .env, .env.local, .env.production
2417
+ // Kept in step with the AST tier and dlp/ -- see jail-both-doors.test.ts.
2418
+ /(?:^|\/)\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
2419
+ // .env chain; fixtures exempt unless .local
2346
2420
  /id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
2347
2421
  // SSH private keys
2348
2422
  /\.pem$|\.key$|\.p12$|\.pfx$/i,
2349
2423
  // certificate files
2350
- /(?:^|\/)\.ssh\//i,
2351
- // ~/.ssh/ directory
2352
- /(?:^|\/)\.aws\/credentials/i,
2353
- // AWS credentials
2424
+ // The `$` half mirrors shell/index.ts's SENSITIVE_PATH_RULES: a file INSIDE
2425
+ // the directory counts wherever it appears, while the directory ITSELF counts
2426
+ // only when the path is ROOTED (`~/.ssh`, `/home/u/.ssh`) -- an unrooted
2427
+ // `config/.ssh` is more likely a search pattern than a read. These are
2428
+ // extracted TOKENS (see `args.some(isSensitivePath)` below), the same input
2429
+ // contract as the shell tier, so the same boundary is the right one.
2430
+ // Without it `grep -r x ~/.ssh | curl -d @-` scored one tier BELOW the
2431
+ // identical pipeline naming a file inside that directory.
2432
+ /(?:^|\/)\.ssh\/|^(?:[~/]|[A-Za-z]:).*\/\.ssh$/i,
2433
+ // ~/.ssh/ and ~/.ssh
2434
+ /(?:^|\/)\.aws\/credentials|^(?:[~/]|[A-Za-z]:).*\/\.aws$/i,
2435
+ // AWS creds + dir
2354
2436
  /(?:^|\/)\.netrc$/i,
2355
2437
  // netrc (stores HTTP credentials)
2356
2438
  /(?:^|\/)(passwd|shadow|sudoers)$/i,
@@ -2384,8 +2466,8 @@ function splitOnPipe(cmd) {
2384
2466
  if (current.trim()) segments2.push(current.trim());
2385
2467
  return segments2.filter(Boolean);
2386
2468
  }
2387
- function positionalTokens(segment) {
2388
- return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2469
+ function positionalTokens(tokens) {
2470
+ return tokens.slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2389
2471
  }
2390
2472
  function analyzePipeChain(command) {
2391
2473
  const segments2 = splitOnPipe(command);
@@ -2408,8 +2490,10 @@ function analyzePipeChain(command) {
2408
2490
  for (const segment of segments2) {
2409
2491
  const tokens = segment.split(/\s+/).filter(Boolean);
2410
2492
  if (tokens.length === 0) continue;
2411
- const binary = tokens[0].toLowerCase();
2412
- const args = positionalTokens(segment);
2493
+ const h = unwrapCommandHead(tokens);
2494
+ const head = h < tokens.length ? h : 0;
2495
+ const binary = tokens[head].toLowerCase();
2496
+ const args = positionalTokens(tokens.slice(head));
2413
2497
  if (SOURCE_COMMANDS.has(binary)) {
2414
2498
  sourceFiles.push(...args);
2415
2499
  if (args.some(isSensitivePath)) hasSensitiveSource = true;
@@ -3852,7 +3936,7 @@ var project_jail_default = {
3852
3936
  {
3853
3937
  field: "command",
3854
3938
  op: "matches",
3855
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
3939
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
3856
3940
  flags: "i"
3857
3941
  }
3858
3942
  ],
@@ -3866,7 +3950,7 @@ var project_jail_default = {
3866
3950
  {
3867
3951
  field: "command",
3868
3952
  op: "matches",
3869
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
3953
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
3870
3954
  flags: "i"
3871
3955
  }
3872
3956
  ],
@@ -3880,7 +3964,7 @@ var project_jail_default = {
3880
3964
  {
3881
3965
  field: "command",
3882
3966
  op: "matches",
3883
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
3967
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
3884
3968
  flags: "i"
3885
3969
  }
3886
3970
  ],
@@ -3894,7 +3978,7 @@ var project_jail_default = {
3894
3978
  {
3895
3979
  field: "command",
3896
3980
  op: "matches",
3897
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
3981
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
3898
3982
  flags: "i"
3899
3983
  }
3900
3984
  ],
@@ -3908,7 +3992,7 @@ var project_jail_default = {
3908
3992
  {
3909
3993
  field: "file_path",
3910
3994
  op: "matches",
3911
- value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
3995
+ value: "([\\/\\\\]\\.ssh([\\/\\\\]|$)|^\\.ssh[\\/\\\\])",
3912
3996
  flags: "i"
3913
3997
  }
3914
3998
  ],
@@ -3922,7 +4006,7 @@ var project_jail_default = {
3922
4006
  {
3923
4007
  field: "file_path",
3924
4008
  op: "matches",
3925
- value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
4009
+ value: "([\\/\\\\]\\.aws([\\/\\\\]|$)|^\\.aws[\\/\\\\])",
3926
4010
  flags: "i"
3927
4011
  }
3928
4012
  ],
@@ -3936,7 +4020,7 @@ var project_jail_default = {
3936
4020
  {
3937
4021
  field: "file_path",
3938
4022
  op: "matches",
3939
- value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
4023
+ value: "(^|[\\/\\\\])\\.env(?![\\w-])(?:[\\w.-]*\\.local$|(?!\\.(example|sample|template)\\b)(?!\\.test$)[\\w.-]*$)",
3940
4024
  flags: "i"
3941
4025
  }
3942
4026
  ],
@@ -4379,7 +4463,7 @@ function summarizeBlast(result, opts = {}) {
4379
4463
  // src/scan/destructive-regex.ts
4380
4464
  var DESTRUCTIVE_OP_RE = /\brm\s+-[rRf]+\b|\bDROP\s+(TABLE|DATABASE|COLLECTION|SCHEMA)\b|\bTRUNCATE\s+TABLE\b|\bgit\s+push\s+(--force|-f)\b|\bFLUSHALL\b|\bFLUSHDB\b|\bkubectl\s+delete\b|\bhelm\s+uninstall\b/i;
4381
4465
  var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
4382
- var SENSITIVE_PATH_RE = /\.aws\/(credentials|config)\b|\.ssh\/(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b|\.env(\.|$|\b)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
4466
+ var SENSITIVE_PATH_RE = /[\\/]\.aws(?:[\\/]|$)|^\.aws[\\/]|[\\/]\.ssh(?:[\\/]|$)|^\.ssh[\\/]|(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
4383
4467
  var FILE_TOOLS = /* @__PURE__ */ new Set([
4384
4468
  "read",
4385
4469
  "read_file",
@@ -4639,8 +4723,8 @@ function matchCanaryArgs(args, values) {
4639
4723
 
4640
4724
  // src/scan/canonical.ts
4641
4725
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
4642
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
4643
- var CANONICAL_EXTRACTOR_HASH = "c4edee9dc99eb69a";
4726
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v13";
4727
+ var CANONICAL_EXTRACTOR_HASH = "4622a2f41696af04";
4644
4728
  var DEDUPE_PREVIEW_LEN = 120;
4645
4729
  function extractCanonicalFindings(call, ctx) {
4646
4730
  const out = [];
@@ -5015,6 +5099,7 @@ export {
5015
5099
  CANARY_MIN_LENGTH,
5016
5100
  CANONICAL_EXTRACTOR_HASH,
5017
5101
  CANONICAL_EXTRACTOR_VERSION,
5102
+ COMMAND_WRAPPERS,
5018
5103
  COST_PER_LOOP_ITER_USD,
5019
5104
  DEFAULT_EGRESS_ALLOWLIST,
5020
5105
  DESTINATION_ARGS,
@@ -5028,6 +5113,7 @@ export {
5028
5113
  LONG_OUTPUT_THRESHOLD_BYTES,
5029
5114
  LOOP_MAX_RECORDS,
5030
5115
  LOOP_THRESHOLD_FOR_WASTE,
5116
+ NET_BINARIES,
5031
5117
  PRIVILEGE_ESCALATION_RE,
5032
5118
  REALTIME_PII_PATTERNS,
5033
5119
  SCAN_SIGNAL_WEIGHTS,
@@ -5098,6 +5184,7 @@ export {
5098
5184
  toScanFinding,
5099
5185
  toolMatchesRule,
5100
5186
  truncateBlastPath,
5187
+ unwrapCommandHead,
5101
5188
  validateOverrides,
5102
5189
  validateRegex,
5103
5190
  validateShieldDefinition
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "2.12.0",
3
+ "version": "2.13.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",