@tuent/sentinel 0.1.1 → 0.1.2

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.
@@ -1,5 +1,4 @@
1
1
  import {
2
- DEFAULT_FORBIDDEN_PATTERNS,
3
2
  DEFAULT_MAP_PATH,
4
3
  DEFAULT_MEDIUM_DISPOSITION,
5
4
  DEFAULT_OVERLAY_PATH,
@@ -16,13 +15,15 @@ import {
16
15
  removeOverlayDecision,
17
16
  saveMap,
18
17
  saveOverlay,
19
- walkForbiddenInodeRoots
20
- } from "./chunk-QHE56MEO.js";
18
+ unionWithDefaultForbiddenPatterns,
19
+ walkForbiddenInodeRoots,
20
+ withPolicyReadExceptions
21
+ } from "./chunk-QIYQWOLO.js";
21
22
  import {
22
23
  loadPolicy,
23
24
  policyToConfig,
24
25
  policyToRole
25
- } from "./chunk-2FFMYSVC.js";
26
+ } from "./chunk-WLIDSTS4.js";
26
27
  import {
27
28
  publicKeyToBase64,
28
29
  reconstructSigningPayload,
@@ -742,6 +743,12 @@ var AuditTrail = class _AuditTrail {
742
743
  if (finding.softSignal === true) {
743
744
  entry.softSignal = true;
744
745
  }
746
+ if (finding.mentionOnly === true) {
747
+ entry.mentionOnly = true;
748
+ }
749
+ if (finding.dedupKey !== void 0) {
750
+ entry.dedupKey = finding.dedupKey;
751
+ }
745
752
  await this.writeLine(entry);
746
753
  }
747
754
  /**
@@ -6438,12 +6445,16 @@ var Sentinel = class _Sentinel {
6438
6445
  "tool_invocation"
6439
6446
  ],
6440
6447
  allowedTargetPatterns: options.allowedTargetPatterns ?? ["**"],
6441
- // Forbidden-pattern normalization chokepoint (Part A): **/-prepend bare
6442
- // patterns so they match absolute paths in Check 2. Idempotent.
6443
- forbiddenTargetPatterns: (options.forbiddenTargetPatterns ?? DEFAULT_FORBIDDEN_PATTERNS).map(
6444
- normalizeForbiddenPattern
6445
- ),
6446
- exceptions: options.exceptions,
6448
+ // Forbidden-pattern normalization chokepoint (Part A) + Sprint 26 FIX 3B:
6449
+ // the defaults are a floor unioned into every role, not a fallback — a
6450
+ // policy yaml extends but cannot remove them (the `??` form let any
6451
+ // policy-supplied list supersede the defaults entirely).
6452
+ forbiddenTargetPatterns: unionWithDefaultForbiddenPatterns(options.forbiddenTargetPatterns),
6453
+ // Sprint 26 FIX 3 — ceiling-side read carve-out for the self-protection
6454
+ // forbids (policy + hook-wiring files). Appended here, not in the yaml
6455
+ // template: mergeRoles drops workspace exceptions, so this is the only
6456
+ // authorable layer — which is the self-locking property.
6457
+ exceptions: withPolicyReadExceptions(options.exceptions),
6447
6458
  networkHosts: options.networkHosts,
6448
6459
  expectedSchedule: options.expectedSchedule,
6449
6460
  maxEventsPerHour: options.maxEventsPerHour,
@@ -6513,8 +6524,15 @@ var Sentinel = class _Sentinel {
6513
6524
  ...options.role,
6514
6525
  agentId,
6515
6526
  name: options.name,
6516
- // Forbidden-pattern normalization chokepoint (Part A) idempotent.
6517
- forbiddenTargetPatterns: (options.role.forbiddenTargetPatterns ?? DEFAULT_FORBIDDEN_PATTERNS).map(normalizeForbiddenPattern)
6527
+ // Forbidden-pattern normalization chokepoint (Part A) + Sprint 26 FIX 3B
6528
+ // defaults-as-floor union — same semantics as monitor().
6529
+ forbiddenTargetPatterns: unionWithDefaultForbiddenPatterns(
6530
+ options.role.forbiddenTargetPatterns
6531
+ ),
6532
+ // Sprint 26 FIX 3 — same ceiling-side read carve-out as monitor(). The
6533
+ // merged role arriving here keeps ceiling exceptions (workspace ones are
6534
+ // already dropped by mergeRoles); the append is idempotent by target.
6535
+ exceptions: withPolicyReadExceptions(options.role.exceptions)
6518
6536
  };
6519
6537
  if (await this.profileManager.agentExists(agentId)) {
6520
6538
  const store = await this.profileManager.loadAgentProfile(agentId);
@@ -7318,13 +7336,13 @@ var Sentinel = class _Sentinel {
7318
7336
  type: "agent_quarantined",
7319
7337
  agentId,
7320
7338
  agentName: event.agentName ?? agentId,
7321
- description: `Agent ${agentId} is quarantined. All actions blocked. Run 'sentinel release' to restore access.`,
7339
+ description: `Agent ${agentId} is quarantined. All actions blocked. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore access.`,
7322
7340
  evidence: {
7323
7341
  action: event.action,
7324
7342
  target: event.primaryTarget,
7325
7343
  timestamp: event.timestamp
7326
7344
  },
7327
- recommendation: `Agent must be manually released before it can perform any actions. Run 'sentinel release' in this workspace (or 'sentinel release --agent=${agentId}') to restore access.`,
7345
+ recommendation: `Agent must be manually released before it can perform any actions. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore access.`,
7328
7346
  timestamp: event.timestamp
7329
7347
  }
7330
7348
  };
@@ -7339,13 +7357,13 @@ var Sentinel = class _Sentinel {
7339
7357
  type: "agent_restricted",
7340
7358
  agentId,
7341
7359
  agentName: event.agentName ?? agentId,
7342
- description: `Agent ${agentId} is restricted. Action '${event.action}' is not permitted in restricted mode. Run 'sentinel release' to restore full access.`,
7360
+ description: `Agent ${agentId} is restricted. Action '${event.action}' is not permitted in restricted mode. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore full access.`,
7343
7361
  evidence: {
7344
7362
  action: event.action,
7345
7363
  target: event.primaryTarget,
7346
7364
  timestamp: event.timestamp
7347
7365
  },
7348
- recommendation: `Only file_read and tool_invocation are allowed in restricted mode. Run 'sentinel release' in this workspace (or 'sentinel release --agent=${agentId}') to restore full access.`,
7366
+ recommendation: `Only file_read and tool_invocation are allowed in restricted mode. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore full access.`,
7349
7367
  timestamp: event.timestamp
7350
7368
  }
7351
7369
  };
@@ -7374,6 +7392,17 @@ var Sentinel = class _Sentinel {
7374
7392
  * Sprint 16 Prompt 3 — replaces in-memory blockCounts Map to survive
7375
7393
  * gateway restarts. Same pattern as Sprint 11 sessionCount Shape D fix.
7376
7394
  */
7395
+ /**
7396
+ * Shared escalation-eligibility predicate (Sprint 26 F-5). Single source of
7397
+ * truth for "this finding counts toward the block ladder", extracted from the
7398
+ * formerly-inline copies in getEffectiveBlockCount and maybeEscalate — behavior
7399
+ * of both is identical to before the extraction. Accepts either a
7400
+ * SecurityFinding-shaped object ({type}) or an audit entry ({findingType},
7401
+ * mapped by the caller).
7402
+ */
7403
+ static isEscalationEligible(f) {
7404
+ return _Sentinel.ESCALATION_ELIGIBLE_TYPES.has(f.type) && (f.severity === "HIGH" || f.severity === "CRITICAL") && f.kind === "actionable";
7405
+ }
7377
7406
  async getEffectiveBlockCount(agentId) {
7378
7407
  const modeChanges = await this.query(agentId, { type: "mode_change" });
7379
7408
  const releaseEntry = modeChanges.find((e) => e.mode === "normal");
@@ -7382,16 +7411,31 @@ var Sentinel = class _Sentinel {
7382
7411
  type: "finding",
7383
7412
  ...anchor ? { startTime: anchor } : {}
7384
7413
  });
7385
- return findings.filter(
7386
- (f) => _Sentinel.ESCALATION_ELIGIBLE_TYPES.has(f.findingType) && (f.severity === "HIGH" || f.severity === "CRITICAL") && f.kind === "actionable"
7387
- ).length;
7414
+ const survivors = findings.filter(
7415
+ (f) => _Sentinel.isEscalationEligible({
7416
+ type: f.findingType,
7417
+ severity: f.severity,
7418
+ kind: f.kind
7419
+ }) && f.mentionOnly !== true
7420
+ );
7421
+ const distinctKeys = /* @__PURE__ */ new Set();
7422
+ let keyless = 0;
7423
+ for (const f of survivors) {
7424
+ const key = f.dedupKey;
7425
+ if (typeof key === "string" && key.length > 0) distinctKeys.add(key);
7426
+ else keyless++;
7427
+ }
7428
+ return distinctKeys.size + keyless;
7388
7429
  }
7389
7430
  async maybeEscalate(agentId, finding) {
7390
7431
  const mode = this.getMode(agentId);
7391
7432
  if (mode === "quarantined") return;
7392
- if (finding.severity !== "HIGH" && finding.severity !== "CRITICAL") return;
7393
- if (!_Sentinel.ESCALATION_ELIGIBLE_TYPES.has(finding.type)) return;
7394
- if (finding.kind !== "actionable") return;
7433
+ if (!_Sentinel.isEscalationEligible({
7434
+ type: finding.type,
7435
+ severity: finding.severity,
7436
+ kind: finding.kind
7437
+ }))
7438
+ return;
7395
7439
  const count = await this.getEffectiveBlockCount(agentId);
7396
7440
  if (mode === "restricted") {
7397
7441
  if (count >= this.quarantineThreshold) {
@@ -7426,4 +7470,4 @@ export {
7426
7470
  createCliApproval,
7427
7471
  Sentinel
7428
7472
  };
7429
- //# sourceMappingURL=chunk-NS6ZLMDK.js.map
7473
+ //# sourceMappingURL=chunk-GRN5P3H2.js.map
@@ -7,6 +7,7 @@ import {
7
7
  import {
8
8
  DEFAULT_FORBIDDEN_PATTERNS,
9
9
  FORBIDDEN_BASENAMES,
10
+ classifyDeny,
10
11
  isPositionallySafeMention,
11
12
  matchGlobInsensitive,
12
13
  normalizeForbiddenPattern,
@@ -14,12 +15,12 @@ import {
14
15
  scanContentForForbiddenBasenames,
15
16
  scanGlobPattern,
16
17
  tokenizePaths
17
- } from "./chunk-QHE56MEO.js";
18
+ } from "./chunk-QIYQWOLO.js";
18
19
  import {
19
20
  loadPolicy,
20
21
  policyToConfig,
21
22
  policyToRole
22
- } from "./chunk-2FFMYSVC.js";
23
+ } from "./chunk-WLIDSTS4.js";
23
24
 
24
25
  // src/gateway/workspaceRouter.ts
25
26
  import { resolve, dirname } from "path";
@@ -646,7 +647,37 @@ var TOOL_MAP = {
646
647
  WebSearch: { action: "network_request", targetKey: "query" },
647
648
  Task: { action: "tool_invocation", targetKey: "description" },
648
649
  Skill: { action: "tool_invocation", targetKey: "skill" },
649
- NotebookEdit: { action: "file_write", targetKey: "notebook_path" }
650
+ NotebookEdit: { action: "file_write", targetKey: "notebook_path" },
651
+ // Sprint 26 Gate-A Item D (F-8) — TOOL_MAP refresh. The 11 names above were
652
+ // a stale subset of cc's native tool set; with the unknown-tool deny consumer
653
+ // live, every missing native name would hard-fail. Inventory taken from a
654
+ // live cc session (2026-06). Conservative mapping: tool_invocation, with a
655
+ // targetKey only where the input schema is known to carry a representative
656
+ // free-text field (scanned by Check 2 / sensitivity like Task.description).
657
+ // Subagent-spawning tools (Agent/Workflow/Task*) are orchestration-only here:
658
+ // each spawned agent's own tool calls hook through PreToolUse individually.
659
+ Agent: { action: "tool_invocation", targetKey: "prompt" },
660
+ SendMessage: { action: "tool_invocation" },
661
+ AskUserQuestion: { action: "tool_invocation" },
662
+ ScheduleWakeup: { action: "tool_invocation", targetKey: "prompt" },
663
+ ToolSearch: { action: "tool_invocation", targetKey: "query" },
664
+ Workflow: { action: "tool_invocation", targetKey: "script" },
665
+ Monitor: { action: "tool_invocation" },
666
+ EnterPlanMode: { action: "tool_invocation" },
667
+ ExitPlanMode: { action: "tool_invocation" },
668
+ EnterWorktree: { action: "tool_invocation" },
669
+ ExitWorktree: { action: "tool_invocation" },
670
+ CronCreate: { action: "tool_invocation" },
671
+ CronDelete: { action: "tool_invocation" },
672
+ CronList: { action: "tool_invocation" },
673
+ TaskCreate: { action: "tool_invocation" },
674
+ TaskGet: { action: "tool_invocation" },
675
+ TaskList: { action: "tool_invocation" },
676
+ TaskOutput: { action: "tool_invocation" },
677
+ TaskStop: { action: "tool_invocation" },
678
+ TaskUpdate: { action: "tool_invocation" },
679
+ PushNotification: { action: "tool_invocation" },
680
+ RemoteTrigger: { action: "tool_invocation" }
650
681
  };
651
682
  var AGENT_ID = "claude-code";
652
683
  var AGENT_NAME = "Claude Code";
@@ -739,7 +770,7 @@ function extractTargets(toolName, toolInput, cwd) {
739
770
  return extractGrepTargets(toolInput, cwd);
740
771
  default: {
741
772
  const mapping = TOOL_MAP[toolName];
742
- if (!mapping) return [toolName];
773
+ if (!mapping || !mapping.targetKey) return [toolName];
743
774
  const val = toolInput[mapping.targetKey];
744
775
  if (typeof val === "string" && val.length > 0) return [val];
745
776
  return [toolName];
@@ -778,6 +809,17 @@ function extractMcpTargets(toolName, toolInput) {
778
809
  var UNKNOWN_TOOL_REASON = "tool schema unknown \u2014 sensitivity scoring and forbidden target patterns cannot evaluate this event";
779
810
  var ClaudeCodeTranslator = class {
780
811
  agentType = "claude-code";
812
+ /**
813
+ * Sprint 26 Gate-A Item D (F-8) — operator allowlist escape hatch. Names
814
+ * listed in the launch policy's enforcement.allowUnknownTools translate as
815
+ * KNOWN tool_invocation (no _unknownTool marker), so a new cc native tool
816
+ * can be unbricked with a one-line policy edit + daemon restart instead of
817
+ * waiting on a Sentinel release that refreshes TOOL_MAP.
818
+ */
819
+ allowUnknownTools;
820
+ constructor(options) {
821
+ this.allowUnknownTools = new Set(options?.allowUnknownTools ?? []);
822
+ }
781
823
  translatePreToolUse(payload) {
782
824
  const p = payload;
783
825
  if (!p || typeof p !== "object" || !p.tool_name) return null;
@@ -798,7 +840,7 @@ var ClaudeCodeTranslator = class {
798
840
  metadata._policyEnforcementBypassed = "true";
799
841
  metadata._policyBypassReason = UNKNOWN_TOOL_REASON;
800
842
  console.warn(
801
- `[SENTINEL] Unknown Claude Code tool "${toolName}" \u2014 allowing with WARN. ${UNKNOWN_TOOL_REASON}`
843
+ `[SENTINEL] Unknown Claude Code tool "${toolName}" \u2014 flagged for gateway disposition (enforcement.unknownTools; default warn). ${UNKNOWN_TOOL_REASON}`
802
844
  );
803
845
  }
804
846
  if (isMcp) {
@@ -956,6 +998,14 @@ var ClaudeCodeTranslator = class {
956
998
  mcpMutating: mcp.mutating
957
999
  };
958
1000
  }
1001
+ if (this.allowUnknownTools.has(toolName)) {
1002
+ return {
1003
+ action: "tool_invocation",
1004
+ targets: [toolName],
1005
+ isUnknown: false,
1006
+ isMcp: false
1007
+ };
1008
+ }
959
1009
  return {
960
1010
  action: "tool_invocation",
961
1011
  targets: [toolName],
@@ -1049,6 +1099,8 @@ var SentinelGateway = class {
1049
1099
  operatorCeiling;
1050
1100
  home;
1051
1101
  releaseToken;
1102
+ /** Item D (F-8): disposition for unknown (non-MCP, unrecognized) tool names. */
1103
+ unknownTools;
1052
1104
  server = null;
1053
1105
  running = false;
1054
1106
  signalHandlersInstalled = false;
@@ -1063,6 +1115,7 @@ var SentinelGateway = class {
1063
1115
  this.operatorCeiling = options.operatorCeiling ?? null;
1064
1116
  this.home = options.home ?? "";
1065
1117
  this.releaseToken = options.releaseToken ?? null;
1118
+ this.unknownTools = options.unknownTools ?? "warn";
1066
1119
  const internal = options;
1067
1120
  if (internal.registry) {
1068
1121
  this.registry = internal.registry;
@@ -1071,7 +1124,9 @@ var SentinelGateway = class {
1071
1124
  this.registry.register(internal.translator);
1072
1125
  } else {
1073
1126
  this.registry = new TranslatorRegistry();
1074
- this.registry.register(new ClaudeCodeTranslator());
1127
+ this.registry.register(
1128
+ new ClaudeCodeTranslator({ allowUnknownTools: options.allowUnknownTools })
1129
+ );
1075
1130
  }
1076
1131
  }
1077
1132
  get port() {
@@ -1406,35 +1461,26 @@ var SentinelGateway = class {
1406
1461
  routingId = routed.agentId;
1407
1462
  event.agentId = routingId;
1408
1463
  }
1409
- let suppressForbiddenBasename = false;
1410
- if (event.action === "command_exec" && event.targets && event.targets.length > 0) {
1411
- const literalCommand = event.targets[0] ?? "";
1412
- const decodedImplicated = event.targets.slice(1).some((t) => scanBashCommand(t, FORBIDDEN_BASENAMES).matched);
1413
- suppressForbiddenBasename = isPositionallySafeMention(literalCommand) && !decodedImplicated;
1414
- }
1415
- if (event.action === "command_exec" && event.targets && event.targets.length > 0 && !suppressForbiddenBasename) {
1416
- const allL2Hits = [];
1417
- for (const scanTarget of event.targets) {
1418
- const scan = scanBashCommand(scanTarget, FORBIDDEN_BASENAMES);
1419
- if (scan.matched) allL2Hits.push(...scan.hits);
1420
- }
1421
- if (allL2Hits.length > 0) {
1464
+ if (event.metadata?._unknownTool === "true") {
1465
+ const unknownName = event.metadata.ccToolName ?? event.primaryTarget;
1466
+ if (this.unknownTools === "deny") {
1422
1467
  const finding = {
1423
1468
  severity: "HIGH",
1424
1469
  kind: "actionable",
1425
- type: "unauthorized_target",
1470
+ type: "unknown_tool",
1426
1471
  agentId: event.agentId,
1427
1472
  agentName: event.agentName,
1428
- description: `Bash command references forbidden basename: ${allL2Hits.join(", ")}`,
1473
+ description: `Unknown tool "${unknownName}" denied \u2014 not in Sentinel's recognized tool set, so policy checks cannot evaluate it. If this is a legitimate tool, add it to enforcement.allowUnknownTools in the operator launch policy file and restart the gateway daemon.`,
1429
1474
  evidence: {
1430
1475
  action: event.action,
1431
- target: allL2Hits[0],
1476
+ target: unknownName,
1432
1477
  timestamp: event.timestamp,
1433
- baselineComparison: "credentials_exfil_attempt"
1478
+ baselineComparison: "unknown_tool_denied"
1434
1479
  },
1435
- recommendation: "Review the command for credential access or exfiltration. If legitimate, use existing policy exception mechanisms.",
1480
+ recommendation: `Add "${unknownName}" to enforcement.allowUnknownTools in the operator launch policy file and restart the daemon, or update @tuent/sentinel to a build whose recognized tool set includes it.`,
1436
1481
  timestamp: event.timestamp,
1437
- decision: "deny"
1482
+ decision: "deny",
1483
+ dedupKey: unknownName
1438
1484
  };
1439
1485
  await this.sentinel.handleGatewayDeny(routingId, finding);
1440
1486
  this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
@@ -1442,6 +1488,38 @@ var SentinelGateway = class {
1442
1488
  this.sendJson(res, 200, response);
1443
1489
  return;
1444
1490
  }
1491
+ const warnFinding = {
1492
+ severity: "LOW",
1493
+ kind: "informational",
1494
+ type: "unknown_tool",
1495
+ agentId: event.agentId,
1496
+ agentName: event.agentName,
1497
+ description: `Unknown tool "${unknownName}" allowed (enforcement.unknownTools: warn) \u2014 not in Sentinel's recognized tool set; policy checks could not evaluate it.`,
1498
+ evidence: {
1499
+ action: event.action,
1500
+ target: unknownName,
1501
+ timestamp: event.timestamp,
1502
+ baselineComparison: "unknown_tool_allowed_warn"
1503
+ },
1504
+ recommendation: `Add "${unknownName}" to enforcement.allowUnknownTools (or update @tuent/sentinel) to clear this warning, or switch enforcement.unknownTools to deny.`,
1505
+ timestamp: event.timestamp,
1506
+ decision: "allow",
1507
+ dedupKey: unknownName
1508
+ };
1509
+ await this.sentinel.logFinding(routingId, warnFinding);
1510
+ }
1511
+ let suppressForbiddenBasename = false;
1512
+ if (event.action === "command_exec" && event.targets && event.targets.length > 0) {
1513
+ const literalCommand = event.targets[0] ?? "";
1514
+ const decodedImplicated = event.targets.slice(1).some((t) => scanBashCommand(t, FORBIDDEN_BASENAMES).matched);
1515
+ suppressForbiddenBasename = isPositionallySafeMention(literalCommand) && !decodedImplicated;
1516
+ }
1517
+ if (event.action === "command_exec" && event.targets && event.targets.length > 0 && !suppressForbiddenBasename) {
1518
+ const allL2Hits = [];
1519
+ for (const scanTarget of event.targets) {
1520
+ const scan = scanBashCommand(scanTarget, FORBIDDEN_BASENAMES);
1521
+ if (scan.matched) allL2Hits.push(...scan.hits);
1522
+ }
1445
1523
  const allTokenPaths = [];
1446
1524
  let anyUnparseable = false;
1447
1525
  let anyDangerousConstruct = false;
@@ -1463,6 +1541,38 @@ var SentinelGateway = class {
1463
1541
  }
1464
1542
  if (matchedPath) break;
1465
1543
  }
1544
+ if (allL2Hits.length > 0) {
1545
+ const { mentionOnly } = classifyDeny(event.targets[0] ?? "", {
1546
+ l2Hits: allL2Hits,
1547
+ hasL1Hit: matchedPath !== null,
1548
+ unparseable: anyUnparseable,
1549
+ hasDangerousConstruct: anyDangerousConstruct
1550
+ });
1551
+ const finding = {
1552
+ severity: "HIGH",
1553
+ kind: "actionable",
1554
+ type: "unauthorized_target",
1555
+ agentId: event.agentId,
1556
+ agentName: event.agentName,
1557
+ description: `Bash command references forbidden basename: ${allL2Hits.join(", ")}`,
1558
+ evidence: {
1559
+ action: event.action,
1560
+ target: allL2Hits[0],
1561
+ timestamp: event.timestamp,
1562
+ baselineComparison: "credentials_exfil_attempt"
1563
+ },
1564
+ recommendation: "Review the command for credential access or exfiltration. If legitimate, use existing policy exception mechanisms.",
1565
+ timestamp: event.timestamp,
1566
+ decision: "deny",
1567
+ mentionOnly,
1568
+ dedupKey: event.primaryTarget
1569
+ };
1570
+ await this.sentinel.handleGatewayDeny(routingId, finding);
1571
+ this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
1572
+ const response = translator.formatPreToolUseResponse({ blocked: true, finding });
1573
+ this.sendJson(res, 200, response);
1574
+ return;
1575
+ }
1466
1576
  if (matchedPath) {
1467
1577
  const finding = {
1468
1578
  severity: "HIGH",
@@ -1479,7 +1589,10 @@ var SentinelGateway = class {
1479
1589
  },
1480
1590
  recommendation: "Review the command for credential or sensitive file access. If legitimate, use existing policy exception mechanisms.",
1481
1591
  timestamp: event.timestamp,
1482
- decision: "deny"
1592
+ decision: "deny",
1593
+ // A resolved path-glob hit is a file target — never a mention.
1594
+ mentionOnly: false,
1595
+ dedupKey: event.primaryTarget
1483
1596
  };
1484
1597
  await this.sentinel.handleGatewayDeny(routingId, finding);
1485
1598
  this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
@@ -1911,15 +2024,17 @@ async function runGatewayDaemon({
1911
2024
  policyPath,
1912
2025
  port = DEFAULT_PORT
1913
2026
  }) {
1914
- const { Sentinel: SentinelClass } = await import("./Sentinel-QHMQ67W3.js");
2027
+ const { Sentinel: SentinelClass } = await import("./Sentinel-XMSJE4DZ.js");
1915
2028
  const { writePidFile, writeReleaseToken } = await import("./pidManager-DOGVN6ZT.js");
1916
2029
  const { homedir } = await import("os");
1917
2030
  const { randomBytes } = await import("crypto");
1918
- const { loadPolicy: loadPolicy2, policyToRole: policyToRole2 } = await import("./policyLoader-6KR5VFVV.js");
2031
+ const { loadPolicy: loadPolicy2, policyToRole: policyToRole2, policyToConfig: policyToConfig2 } = await import("./policyLoader-KZL2U4M2.js");
1919
2032
  const sentinel = await SentinelClass.fromPolicy(policyPath);
1920
2033
  const baseline = await sentinel.computeBaseline("claude-code");
1921
2034
  sentinel.setBaseline("claude-code", baseline);
1922
- const operatorCeiling = policyToRole2(await loadPolicy2(policyPath));
2035
+ const operatorPolicy = await loadPolicy2(policyPath);
2036
+ const operatorCeiling = policyToRole2(operatorPolicy);
2037
+ const operatorConfig = policyToConfig2(operatorPolicy);
1923
2038
  const releaseToken = randomBytes(32).toString("hex");
1924
2039
  const gateway = new SentinelGateway({
1925
2040
  port,
@@ -1929,7 +2044,9 @@ async function runGatewayDaemon({
1929
2044
  workspaceIsolation: process.env.SENTINEL_WORKSPACE_ISOLATION !== "0",
1930
2045
  operatorCeiling,
1931
2046
  home: homedir(),
1932
- releaseToken
2047
+ releaseToken,
2048
+ unknownTools: operatorConfig.enforcement?.unknownTools,
2049
+ allowUnknownTools: operatorConfig.enforcement?.allowUnknownTools
1933
2050
  });
1934
2051
  await gateway.start();
1935
2052
  const home = homedir();
@@ -1942,4 +2059,4 @@ export {
1942
2059
  SentinelGateway,
1943
2060
  runGatewayDaemon
1944
2061
  };
1945
- //# sourceMappingURL=chunk-IYC5E7RL.js.map
2062
+ //# sourceMappingURL=chunk-L4R3LPJS.js.map
@@ -234,8 +234,43 @@ var DEFAULT_FORBIDDEN_PATTERNS = [
234
234
  "**/id_ed25519*",
235
235
  "**/*.pem",
236
236
  "**/*.key",
237
- "/etc/**"
237
+ "/etc/**",
238
+ // Sprint 26 FIX 1 (A) — common credential stores. DRIFT: keep in sync with
239
+ // STARTER_POLICY.forbid.targets in setup/initClaudeCode.ts (template↔defaults
240
+ // unification tracked in a separate ticket — do not refactor here).
241
+ "**/.netrc",
242
+ "**/.npmrc",
243
+ "**/.git-credentials",
244
+ "**/.pgpass",
245
+ "**/.zsh_history",
246
+ "**/.config/gh/**",
247
+ "**/.docker/config.json",
248
+ "**/.gnupg/**",
249
+ "**/.config/gcloud/**",
250
+ "**/.kube/**",
251
+ "**/Library/Keychains/**",
252
+ // Sprint 26 FIX 1 (B) — Sentinel's own state dir (current path only; the
253
+ // ~/.dahlia → ~/.sentinel rename is a separate re-arch).
254
+ "**/.dahlia/**",
255
+ // Sprint 26 FIX 3 — the live policy file and cc's hook-wiring settings files.
256
+ // Denies the agent's own tool-writes (policy rewrite / unhook vectors); reads
257
+ // stay allowed via DEFAULT_POLICY_READ_EXCEPTIONS below. The settings glob
258
+ // covers project AND user-level (~/.claude/settings*.json) in one pattern.
259
+ // DRIFT: keep in sync with STARTER_POLICY.forbid.targets in setup/initClaudeCode.ts.
260
+ "**/.sentinel.yaml",
261
+ "**/.claude/settings*.json"
238
262
  ];
263
+ var DEFAULT_POLICY_READ_EXCEPTIONS = [
264
+ { target: "**/.sentinel.yaml", allowedActions: ["file_read"] },
265
+ { target: "**/.claude/settings*.json", allowedActions: ["file_read"] }
266
+ ];
267
+ function withPolicyReadExceptions(existing) {
268
+ const merged = existing ? [...existing] : [];
269
+ for (const exc of DEFAULT_POLICY_READ_EXCEPTIONS) {
270
+ if (!merged.some((e) => e.target === exc.target)) merged.push(exc);
271
+ }
272
+ return merged;
273
+ }
239
274
  var DEFAULT_MEDIUM_DISPOSITION = {
240
275
  network_request: "deny"
241
276
  };
@@ -419,7 +454,7 @@ function shouldDispatchWildcard(token) {
419
454
  if (BRACE_PATTERN_RE.test(token)) return true;
420
455
  return false;
421
456
  }
422
- var SENSITIVE_BASENAME_RE = /(?:\.env|\.ssh|secrets|credentials|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.pem|\.key)/i;
457
+ var SENSITIVE_BASENAME_RE = /(?:\.env|\.ssh|secrets|credentials|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.pem|\.key|\.netrc|\.npmrc|\.pgpass|\.zsh_history|\.gnupg|\.kube|\.dahlia)/i;
423
458
  var DANGEROUS_COMMAND_TOKENS = /* @__PURE__ */ new Set(["eval"]);
424
459
  var COMMAND_SUBSTITUTION_RE = /\$\(|`/;
425
460
  var DANGEROUS_RAW_RE = /<<<|<\(|>\(/;
@@ -577,7 +612,13 @@ var FORBIDDEN_BASENAMES = [
577
612
  "id_ecdsa",
578
613
  "id_ed25519",
579
614
  ".pem",
580
- ".key"
615
+ ".key",
616
+ // Sprint 26 FIX 1 (A) — credential-store file basenames (L2 bash deny).
617
+ // `.git-credentials` is already covered by the "credentials" entry above.
618
+ ".netrc",
619
+ ".npmrc",
620
+ ".pgpass",
621
+ ".zsh_history"
581
622
  ];
582
623
  function scanBashCommand(command, forbiddenBasenames) {
583
624
  const basenames = forbiddenBasenames ?? FORBIDDEN_BASENAMES;
@@ -744,6 +785,35 @@ function isPositionallySafeMention(command) {
744
785
  const safe = positionallySafeBasenames(command);
745
786
  return hits.every((h) => safe.has(h));
746
787
  }
788
+ function classifyDeny(command, signals) {
789
+ const { l2Hits, hasL1Hit, unparseable, hasDangerousConstruct } = signals;
790
+ if (hasL1Hit || unparseable || hasDangerousConstruct) return { mentionOnly: false };
791
+ if (l2Hits.length === 0) return { mentionOnly: false };
792
+ let tokens;
793
+ try {
794
+ tokens = shellParse(command, (key) => ({ __sentinel_var: key }));
795
+ } catch {
796
+ return { mentionOnly: false };
797
+ }
798
+ if (!Array.isArray(tokens)) return { mentionOnly: false };
799
+ if (tokens.some((t) => isVarMarker(t))) return { mentionOnly: false };
800
+ const strTokens = tokens.filter((t) => typeof t === "string");
801
+ for (const hit of l2Hits) {
802
+ const h = hit.toLowerCase();
803
+ const confident = strTokens.some((tok) => {
804
+ const low = tok.toLowerCase();
805
+ if (!low.includes(h)) return false;
806
+ if (low.length === h.length) return false;
807
+ const reHits = scanBashCommand(tok, FORBIDDEN_BASENAMES).hits;
808
+ if (reHits.some((rh) => tok.length === rh.length)) return false;
809
+ const reTok = tokenizePaths(tok);
810
+ if (reTok.unparseable || reTok.hasDangerousConstruct) return false;
811
+ return true;
812
+ });
813
+ if (!confident) return { mentionOnly: false };
814
+ }
815
+ return { mentionOnly: true };
816
+ }
747
817
 
748
818
  // src/roleValidator.ts
749
819
  var SUSPICIOUS_BASENAME_RE = /^\.|(\.env|secret|credential|key|config|token)/i;
@@ -832,6 +902,11 @@ function normalizeForbiddenPattern(pattern) {
832
902
  if (pattern.startsWith("**/") || pattern.startsWith("/")) return pattern;
833
903
  return "**/" + pattern;
834
904
  }
905
+ function unionWithDefaultForbiddenPatterns(supplied) {
906
+ return [
907
+ ...new Set([...supplied ?? [], ...DEFAULT_FORBIDDEN_PATTERNS].map(normalizeForbiddenPattern))
908
+ ];
909
+ }
835
910
  function isPathShaped2(value) {
836
911
  if (value.length === 0 || value.length > 4096) return false;
837
912
  if (/\s/.test(value)) return false;
@@ -1694,6 +1769,7 @@ export {
1694
1769
  removeOverlayDecision,
1695
1770
  createEmptyOverlay,
1696
1771
  DEFAULT_FORBIDDEN_PATTERNS,
1772
+ withPolicyReadExceptions,
1697
1773
  DEFAULT_MEDIUM_DISPOSITION,
1698
1774
  TargetSensitivityScorer,
1699
1775
  tokenizePaths,
@@ -1702,12 +1778,14 @@ export {
1702
1778
  scanContentForForbiddenBasenames,
1703
1779
  scanGlobPattern,
1704
1780
  isPositionallySafeMention,
1781
+ classifyDeny,
1705
1782
  matchGlob,
1706
1783
  normalizeForbiddenPattern,
1784
+ unionWithDefaultForbiddenPatterns,
1707
1785
  matchGlobInsensitive,
1708
1786
  getTargetRecommendation,
1709
1787
  walkForbiddenInodeRoots,
1710
1788
  findMatchingException,
1711
1789
  RoleValidator
1712
1790
  };
1713
- //# sourceMappingURL=chunk-QHE56MEO.js.map
1791
+ //# sourceMappingURL=chunk-QIYQWOLO.js.map
@@ -210,6 +210,16 @@ function validatePolicy(data) {
210
210
  }
211
211
  }
212
212
  }
213
+ if (enforcement.unknownTools !== void 0) {
214
+ if (enforcement.unknownTools !== "deny" && enforcement.unknownTools !== "warn") {
215
+ fail('enforcement.unknownTools must be "deny" or "warn"');
216
+ }
217
+ }
218
+ if (enforcement.allowUnknownTools !== void 0) {
219
+ if (!Array.isArray(enforcement.allowUnknownTools) || !enforcement.allowUnknownTools.every((t) => typeof t === "string")) {
220
+ fail("enforcement.allowUnknownTools must be an array of tool name strings");
221
+ }
222
+ }
213
223
  if (enforcement.baselineMaturity !== void 0) {
214
224
  if (typeof enforcement.baselineMaturity !== "object" || enforcement.baselineMaturity === null) {
215
225
  fail("enforcement.baselineMaturity must be an object");
@@ -371,7 +381,7 @@ function policyToRole(policy) {
371
381
  function policyToConfig(policy) {
372
382
  const config = {};
373
383
  if (policy.enforcement) {
374
- if (policy.enforcement.restrictAfter !== void 0 || policy.enforcement.quarantineAfter !== void 0 || policy.enforcement.promote !== void 0 || policy.enforcement.baselineMaturity !== void 0) {
384
+ if (policy.enforcement.restrictAfter !== void 0 || policy.enforcement.quarantineAfter !== void 0 || policy.enforcement.promote !== void 0 || policy.enforcement.baselineMaturity !== void 0 || policy.enforcement.unknownTools !== void 0 || policy.enforcement.allowUnknownTools !== void 0) {
375
385
  config.enforcement = {};
376
386
  if (policy.enforcement.restrictAfter !== void 0) {
377
387
  config.enforcement.restrictAfter = policy.enforcement.restrictAfter;
@@ -385,6 +395,12 @@ function policyToConfig(policy) {
385
395
  if (policy.enforcement.baselineMaturity !== void 0) {
386
396
  config.enforcement.baselineMaturity = policy.enforcement.baselineMaturity;
387
397
  }
398
+ if (policy.enforcement.unknownTools !== void 0) {
399
+ config.enforcement.unknownTools = policy.enforcement.unknownTools;
400
+ }
401
+ if (policy.enforcement.allowUnknownTools !== void 0) {
402
+ config.enforcement.allowUnknownTools = policy.enforcement.allowUnknownTools;
403
+ }
388
404
  }
389
405
  }
390
406
  if (policy.alerts) {
@@ -425,4 +441,4 @@ export {
425
441
  policyToRole,
426
442
  policyToConfig
427
443
  };
428
- //# sourceMappingURL=chunk-2FFMYSVC.js.map
444
+ //# sourceMappingURL=chunk-WLIDSTS4.js.map