@tuent/sentinel 0.1.1 → 0.1.3

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.
@@ -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 = /<<<|<\(|>\(/;
@@ -515,7 +550,7 @@ function tokenizePaths(command) {
515
550
  if (dispatch.unparseable) {
516
551
  result.unparseable = true;
517
552
  }
518
- } else if (isPathShaped(token)) {
553
+ } else if (isPathShaped(token) && !isEnvIdentifierChain(token)) {
519
554
  const resolved = resolvePathToken(token);
520
555
  result.paths.push(resolved);
521
556
  }
@@ -523,6 +558,21 @@ function tokenizePaths(command) {
523
558
  }
524
559
  return result;
525
560
  }
561
+ function isEnvIdentifierChain(token) {
562
+ if (token.includes("/") || token.startsWith(".")) return false;
563
+ if (!allEnvOccurrencesIdentifierEmbedded(token)) return false;
564
+ return !SENSITIVE_BASENAME_RE.test(token.replace(/\.env/gi, " "));
565
+ }
566
+ function allEnvOccurrencesIdentifierEmbedded(s) {
567
+ const envHits = /\.env/gi;
568
+ let m;
569
+ let any = false;
570
+ while ((m = envHits.exec(s)) !== null) {
571
+ any = true;
572
+ if (!/[A-Za-z0-9_$]/.test(s[m.index - 1] ?? "")) return false;
573
+ }
574
+ return any;
575
+ }
526
576
  function isPathShaped(token) {
527
577
  if (token.includes("/")) return true;
528
578
  if (token.startsWith(".")) return true;
@@ -577,7 +627,13 @@ var FORBIDDEN_BASENAMES = [
577
627
  "id_ecdsa",
578
628
  "id_ed25519",
579
629
  ".pem",
580
- ".key"
630
+ ".key",
631
+ // Sprint 26 FIX 1 (A) — credential-store file basenames (L2 bash deny).
632
+ // `.git-credentials` is already covered by the "credentials" entry above.
633
+ ".netrc",
634
+ ".npmrc",
635
+ ".pgpass",
636
+ ".zsh_history"
581
637
  ];
582
638
  function scanBashCommand(command, forbiddenBasenames) {
583
639
  const basenames = forbiddenBasenames ?? FORBIDDEN_BASENAMES;
@@ -744,8 +800,38 @@ function isPositionallySafeMention(command) {
744
800
  const safe = positionallySafeBasenames(command);
745
801
  return hits.every((h) => safe.has(h));
746
802
  }
803
+ function classifyDeny(command, signals) {
804
+ const { l2Hits, hasL1Hit, unparseable, hasDangerousConstruct } = signals;
805
+ if (hasL1Hit || unparseable || hasDangerousConstruct) return { mentionOnly: false };
806
+ if (l2Hits.length === 0) return { mentionOnly: false };
807
+ let tokens;
808
+ try {
809
+ tokens = shellParse(command, (key) => ({ __sentinel_var: key }));
810
+ } catch {
811
+ return { mentionOnly: false };
812
+ }
813
+ if (!Array.isArray(tokens)) return { mentionOnly: false };
814
+ if (tokens.some((t) => isVarMarker(t))) return { mentionOnly: false };
815
+ const strTokens = tokens.filter((t) => typeof t === "string");
816
+ for (const hit of l2Hits) {
817
+ const h = hit.toLowerCase();
818
+ const confident = strTokens.some((tok) => {
819
+ const low = tok.toLowerCase();
820
+ if (!low.includes(h)) return false;
821
+ if (low.length === h.length) return false;
822
+ const reHits = scanBashCommand(tok, FORBIDDEN_BASENAMES).hits;
823
+ if (reHits.some((rh) => tok.length === rh.length)) return false;
824
+ const reTok = tokenizePaths(tok);
825
+ if (reTok.unparseable || reTok.hasDangerousConstruct) return false;
826
+ return true;
827
+ });
828
+ if (!confident) return { mentionOnly: false };
829
+ }
830
+ return { mentionOnly: true };
831
+ }
747
832
 
748
833
  // src/roleValidator.ts
834
+ import { parse as shellParse2 } from "shell-quote";
749
835
  var SUSPICIOUS_BASENAME_RE = /^\.|(\.env|secret|credential|key|config|token)/i;
750
836
  function resolveSymlinks(normalizedPath) {
751
837
  if (!normalizedPath || normalizedPath.includes("://")) return normalizedPath;
@@ -832,6 +918,11 @@ function normalizeForbiddenPattern(pattern) {
832
918
  if (pattern.startsWith("**/") || pattern.startsWith("/")) return pattern;
833
919
  return "**/" + pattern;
834
920
  }
921
+ function unionWithDefaultForbiddenPatterns(supplied) {
922
+ return [
923
+ ...new Set([...supplied ?? [], ...DEFAULT_FORBIDDEN_PATTERNS].map(normalizeForbiddenPattern))
924
+ ];
925
+ }
835
926
  function isPathShaped2(value) {
836
927
  if (value.length === 0 || value.length > 4096) return false;
837
928
  if (/\s/.test(value)) return false;
@@ -1073,10 +1164,26 @@ var RoleValidator = class {
1073
1164
  }
1074
1165
  }
1075
1166
  }
1076
- const safeCommandMention = event.action === "command_exec" && isPositionallySafeMention(event.primaryTarget);
1167
+ if (event.action === "command_exec") {
1168
+ const cmd = this.screenCommandTarget(event);
1169
+ if (cmd) {
1170
+ const finding = this.buildForbiddenFinding(
1171
+ event,
1172
+ cmd.matchedTarget,
1173
+ cmd.matchedPattern,
1174
+ activeTask ?? null
1175
+ );
1176
+ if (finding) {
1177
+ finding.targetKey = cmd.targetKey;
1178
+ finding.dedupKey = event.primaryTarget;
1179
+ finding.mentionOnly = false;
1180
+ return finding;
1181
+ }
1182
+ }
1183
+ }
1077
1184
  for (const pattern of this.role.forbiddenTargetPatterns) {
1078
1185
  let matchedTargetValue = null;
1079
- if (!safeCommandMention && primaryTargetPaths.some((tp) => matchGlobInsensitive(pattern, tp))) {
1186
+ if (event.action !== "command_exec" && primaryTargetPaths.some((tp) => matchGlobInsensitive(pattern, tp))) {
1080
1187
  matchedTargetValue = normalizedPrimaryTarget;
1081
1188
  }
1082
1189
  if (!matchedTargetValue) {
@@ -1094,57 +1201,13 @@ var RoleValidator = class {
1094
1201
  }
1095
1202
  }
1096
1203
  if (matchedTargetValue) {
1097
- const sensitivity = this.sensitivityScorer?.scoreTarget(matchedTargetValue, event.action);
1098
- const isCritical = sensitivity ? sensitivity.effectiveScore >= 0.9 : false;
1099
- const finding = this.makeFinding(event, {
1100
- severity: "HIGH",
1101
- type: "unauthorized_target",
1102
- description: `Agent accessed '${matchedTargetValue}' which matches forbidden pattern '${pattern}'`,
1103
- recommendation: getTargetRecommendation(
1104
- "unauthorized_target",
1105
- matchedTargetValue,
1106
- event.action
1107
- ),
1108
- matchedTarget: matchedTargetValue
1109
- });
1110
- if (!isCritical) {
1111
- const exception = findMatchingException(this.role.exceptions, event, activeTask ?? null);
1112
- if (exception) {
1113
- this.onAuditEntry?.({
1114
- type: "exception_applied",
1115
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1116
- agentId: event.agentId,
1117
- target: matchedTargetValue,
1118
- action: event.action,
1119
- exceptionTarget: exception.target,
1120
- taskId: activeTask?.taskId ?? null
1121
- });
1122
- if (exception.requiresApproval) {
1123
- const ctx = {
1124
- finding,
1125
- exception,
1126
- activeTask: activeTask ?? null,
1127
- expiresAt: exception.expiresAfter != null && activeTask ? new Date(new Date(activeTask.startedAt).getTime() + exception.expiresAfter) : null
1128
- };
1129
- const approved = this.approvalFn?.(ctx) === true;
1130
- if (approved) {
1131
- this.onAuditEntry?.({
1132
- type: "exception_approved",
1133
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1134
- agentId: event.agentId,
1135
- target: matchedTargetValue,
1136
- action: event.action,
1137
- exceptionTarget: exception.target,
1138
- taskId: activeTask?.taskId ?? null
1139
- });
1140
- continue;
1141
- }
1142
- } else {
1143
- continue;
1144
- }
1145
- }
1146
- }
1147
- return this.enhanceWithSensitivity(finding, event);
1204
+ const finding = this.buildForbiddenFinding(
1205
+ event,
1206
+ matchedTargetValue,
1207
+ pattern,
1208
+ activeTask ?? null
1209
+ );
1210
+ if (finding) return finding;
1148
1211
  }
1149
1212
  }
1150
1213
  if (this.sensitivityScorer && event.metadata?._mcpTool === "true") {
@@ -1163,7 +1226,7 @@ var RoleValidator = class {
1163
1226
  });
1164
1227
  }
1165
1228
  }
1166
- if (this.role.allowedTargetPatterns.length > 0) {
1229
+ if (this.role.allowedTargetPatterns.length > 0 && event.action !== "command_exec") {
1167
1230
  const anchor = this.workspaceRoot !== "" && PATH_TARGET_ACTIONS.has(event.action);
1168
1231
  const matched = this.role.allowedTargetPatterns.some((pattern) => {
1169
1232
  const candidates = anchor ? [pattern, anchorAllowedPattern(pattern, this.workspaceRoot)] : [pattern];
@@ -1201,7 +1264,7 @@ var RoleValidator = class {
1201
1264
  }
1202
1265
  }
1203
1266
  const scopePatterns = activeTask?.scopePatterns;
1204
- if (scopePatterns && scopePatterns.length > 0) {
1267
+ if (scopePatterns && scopePatterns.length > 0 && event.action !== "command_exec") {
1205
1268
  const anchor = this.workspaceRoot !== "" && PATH_TARGET_ACTIONS.has(event.action);
1206
1269
  const inScope = scopePatterns.some((pattern) => {
1207
1270
  const candidates = anchor ? [pattern, anchorAllowedPattern(pattern, this.workspaceRoot)] : [pattern];
@@ -1265,6 +1328,127 @@ var RoleValidator = class {
1265
1328
  }
1266
1329
  return finding;
1267
1330
  }
1331
+ /**
1332
+ * Sprint 26B B′ — scanner-authoritative forbidden screen for a command_exec
1333
+ * PRIMARY target (the command string). Three layers, first match wins:
1334
+ *
1335
+ * L1 — tokenizePaths(command): resolved path-shaped tokens (symlink-resolved,
1336
+ * dir-globs, F-5b's isEnvIdentifierChain guard inside) matched against the
1337
+ * FULL role patterns via matchGlob.
1338
+ * Boundary — every argv token's basename vs each pattern's basename component
1339
+ * via the SAME matchGlob (its `^…$` anchoring → true boundary, so
1340
+ * `^\.env$` rejects `process.env` and `^payroll\.csv$` rejects
1341
+ * `mypayroll.csv`, while a `payroll.csv` token matches a `payroll.csv`
1342
+ * pattern basename). Patterns whose basename is pure-wildcard (a bare
1343
+ * star or globstar) are dir-globs and are skipped here (L1 handles them
1344
+ * on resolved paths).
1345
+ * L2 — scanBashCommand substring net (fixed FORBIDDEN_BASENAMES) gated by
1346
+ * isPositionallySafeMention, for heredoc/substitution shapes tokenization
1347
+ * can't cleanly split.
1348
+ *
1349
+ * Returns the matched target + the F-5a targetKey (resolved path / token /
1350
+ * `l2:<basenames>`, mirroring the gateway emit) + a pattern label for the
1351
+ * finding description, or null when nothing matches.
1352
+ */
1353
+ screenCommandTarget(event) {
1354
+ const command = event.primaryTarget;
1355
+ if (isPositionallySafeMention(command)) return null;
1356
+ const tr = tokenizePaths(command);
1357
+ for (const p of tr.paths) {
1358
+ for (const pattern of this.role.forbiddenTargetPatterns) {
1359
+ if (matchGlobInsensitive(pattern, p)) {
1360
+ return { matchedTarget: p, targetKey: p, matchedPattern: pattern };
1361
+ }
1362
+ }
1363
+ }
1364
+ let tokens;
1365
+ try {
1366
+ tokens = shellParse2(command);
1367
+ } catch {
1368
+ tokens = [];
1369
+ }
1370
+ for (const tok of tokens) {
1371
+ if (typeof tok !== "string") continue;
1372
+ const tokBase = basename2(tok);
1373
+ if (tokBase.length === 0) continue;
1374
+ for (const pattern of this.role.forbiddenTargetPatterns) {
1375
+ const patBase = basename2(pattern);
1376
+ if (patBase === "*" || patBase === "**") continue;
1377
+ if (matchGlobInsensitive(patBase, tokBase)) {
1378
+ return { matchedTarget: tok, targetKey: tok, matchedPattern: pattern };
1379
+ }
1380
+ }
1381
+ }
1382
+ const scan = scanBashCommand(command, FORBIDDEN_BASENAMES);
1383
+ if (scan.matched) {
1384
+ const hits = [...new Set(scan.hits)].sort();
1385
+ return {
1386
+ matchedTarget: scan.hits[0],
1387
+ targetKey: `l2:${hits.join(",")}`,
1388
+ matchedPattern: hits.join(", ")
1389
+ };
1390
+ }
1391
+ return null;
1392
+ }
1393
+ /**
1394
+ * Build the unauthorized_target finding for a matched forbidden target, applying
1395
+ * the same exception/approval/sensitivity flow shared by Check 2's per-pattern
1396
+ * loop and the B′ command screen. Returns the finding to emit, or null when an
1397
+ * exception (auto, or approved) suppresses it (caller continues / falls through).
1398
+ */
1399
+ buildForbiddenFinding(event, matchedTargetValue, pattern, activeTask) {
1400
+ const sensitivity = this.sensitivityScorer?.scoreTarget(matchedTargetValue, event.action);
1401
+ const isCritical = sensitivity ? sensitivity.effectiveScore >= 0.9 : false;
1402
+ const finding = this.makeFinding(event, {
1403
+ severity: "HIGH",
1404
+ type: "unauthorized_target",
1405
+ description: `Agent accessed '${matchedTargetValue}' which matches forbidden pattern '${pattern}'`,
1406
+ recommendation: getTargetRecommendation(
1407
+ "unauthorized_target",
1408
+ matchedTargetValue,
1409
+ event.action
1410
+ ),
1411
+ matchedTarget: matchedTargetValue
1412
+ });
1413
+ if (!isCritical) {
1414
+ const exception = findMatchingException(this.role.exceptions, event, activeTask);
1415
+ if (exception) {
1416
+ this.onAuditEntry?.({
1417
+ type: "exception_applied",
1418
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1419
+ agentId: event.agentId,
1420
+ target: matchedTargetValue,
1421
+ action: event.action,
1422
+ exceptionTarget: exception.target,
1423
+ taskId: activeTask?.taskId ?? null
1424
+ });
1425
+ if (exception.requiresApproval) {
1426
+ const ctx = {
1427
+ finding,
1428
+ exception,
1429
+ activeTask,
1430
+ expiresAt: exception.expiresAfter != null && activeTask ? new Date(new Date(activeTask.startedAt).getTime() + exception.expiresAfter) : null
1431
+ };
1432
+ const approved = this.approvalFn?.(ctx) === true;
1433
+ if (approved) {
1434
+ this.onAuditEntry?.({
1435
+ type: "exception_approved",
1436
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1437
+ agentId: event.agentId,
1438
+ target: matchedTargetValue,
1439
+ action: event.action,
1440
+ exceptionTarget: exception.target,
1441
+ taskId: activeTask?.taskId ?? null
1442
+ });
1443
+ return null;
1444
+ }
1445
+ } else {
1446
+ return null;
1447
+ }
1448
+ }
1449
+ }
1450
+ return this.enhanceWithSensitivity(finding, event);
1451
+ }
1268
1452
  makeFinding(event, details) {
1269
1453
  return {
1270
1454
  severity: details.severity,
@@ -1694,6 +1878,7 @@ export {
1694
1878
  removeOverlayDecision,
1695
1879
  createEmptyOverlay,
1696
1880
  DEFAULT_FORBIDDEN_PATTERNS,
1881
+ withPolicyReadExceptions,
1697
1882
  DEFAULT_MEDIUM_DISPOSITION,
1698
1883
  TargetSensitivityScorer,
1699
1884
  tokenizePaths,
@@ -1702,12 +1887,14 @@ export {
1702
1887
  scanContentForForbiddenBasenames,
1703
1888
  scanGlobPattern,
1704
1889
  isPositionallySafeMention,
1890
+ classifyDeny,
1705
1891
  matchGlob,
1706
1892
  normalizeForbiddenPattern,
1893
+ unionWithDefaultForbiddenPatterns,
1707
1894
  matchGlobInsensitive,
1708
1895
  getTargetRecommendation,
1709
1896
  walkForbiddenInodeRoots,
1710
1897
  findMatchingException,
1711
1898
  RoleValidator
1712
1899
  };
1713
- //# sourceMappingURL=chunk-QHE56MEO.js.map
1900
+ //# sourceMappingURL=chunk-JTR2E7RD.js.map
@@ -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-JTR2E7RD.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,
@@ -314,6 +315,9 @@ var ProfileStore = class {
314
315
  this.profile = await this.backend.read();
315
316
  this.engine = new DataEngine({ petalCount: this.petalCount });
316
317
  if (this.profile) {
318
+ if (!Array.isArray(this.profile.dataPoints)) {
319
+ this.profile.dataPoints = [];
320
+ }
317
321
  for (const point of this.profile.dataPoints) {
318
322
  this.engine.addPoint(point);
319
323
  }
@@ -617,6 +621,7 @@ var AgentActivityClassifier = class {
617
621
 
618
622
  // src/auditTrail.ts
619
623
  import { createHash } from "crypto";
624
+ import { existsSync } from "fs";
620
625
  var AuditTrail = class _AuditTrail {
621
626
  static GENESIS_HASH = "0".repeat(64);
622
627
  /** S21-P4: honest label for cumulative-stats.json's totalEntries. */
@@ -650,6 +655,11 @@ var AuditTrail = class _AuditTrail {
650
655
  this.agentId = agentId;
651
656
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
652
657
  const logDir = options?.logDir ?? `${home}/.dahlia/agents/${agentId}`;
658
+ if (options?.logDir && !existsSync(`${logDir}/audit.log`) && existsSync(`${logDir}/${agentId}/audit.log`)) {
659
+ throw new Error(
660
+ `AuditTrail logDir must be the agent's own directory, not the agents root: "${logDir}" contains no audit.log, but "${logDir}/${agentId}/audit.log" exists. Pass join(agentsRoot, "${agentId}") instead.`
661
+ );
662
+ }
653
663
  this.logPath = `${logDir}/audit.log`;
654
664
  this.statsPath = `${logDir}/cumulative-stats.json`;
655
665
  this.manifestPath = `${logDir}/audit.manifest`;
@@ -742,6 +752,15 @@ var AuditTrail = class _AuditTrail {
742
752
  if (finding.softSignal === true) {
743
753
  entry.softSignal = true;
744
754
  }
755
+ if (finding.mentionOnly === true) {
756
+ entry.mentionOnly = true;
757
+ }
758
+ if (finding.dedupKey !== void 0) {
759
+ entry.dedupKey = finding.dedupKey;
760
+ }
761
+ if (finding.targetKey !== void 0) {
762
+ entry.targetKey = finding.targetKey;
763
+ }
745
764
  await this.writeLine(entry);
746
765
  }
747
766
  /**
@@ -2971,6 +2990,7 @@ var SentinelRunner = class {
2971
2990
  }
2972
2991
  async runGapCheck() {
2973
2992
  if (!this._deviationDetector || !this.auditTrail) return;
2993
+ if (this._eventCount === 0) return;
2974
2994
  const finding = await this._deviationDetector.checkActivityGap(this.auditTrail);
2975
2995
  if (finding) {
2976
2996
  this.findings.push(finding);
@@ -3522,7 +3542,7 @@ var SentinelManager = class {
3522
3542
  };
3523
3543
 
3524
3544
  // src/Sentinel.ts
3525
- import { lstatSync, existsSync } from "fs";
3545
+ import { lstatSync, existsSync as existsSync2 } from "fs";
3526
3546
  import { resolve as resolve2, dirname, sep as sep2 } from "path";
3527
3547
 
3528
3548
  // src/baselineBuilder.ts
@@ -6411,7 +6431,7 @@ var Sentinel = class _Sentinel {
6411
6431
  );
6412
6432
  }
6413
6433
  }
6414
- if (!existsSync(root)) {
6434
+ if (!existsSync2(root)) {
6415
6435
  console.warn(
6416
6436
  `[Sentinel] Workspace root '${root}' does not exist on disk \u2014 anchored allowed patterns will match nothing and every allowed read may be denied.`
6417
6437
  );
@@ -6438,12 +6458,16 @@ var Sentinel = class _Sentinel {
6438
6458
  "tool_invocation"
6439
6459
  ],
6440
6460
  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,
6461
+ // Forbidden-pattern normalization chokepoint (Part A) + Sprint 26 FIX 3B:
6462
+ // the defaults are a floor unioned into every role, not a fallback — a
6463
+ // policy yaml extends but cannot remove them (the `??` form let any
6464
+ // policy-supplied list supersede the defaults entirely).
6465
+ forbiddenTargetPatterns: unionWithDefaultForbiddenPatterns(options.forbiddenTargetPatterns),
6466
+ // Sprint 26 FIX 3 — ceiling-side read carve-out for the self-protection
6467
+ // forbids (policy + hook-wiring files). Appended here, not in the yaml
6468
+ // template: mergeRoles drops workspace exceptions, so this is the only
6469
+ // authorable layer — which is the self-locking property.
6470
+ exceptions: withPolicyReadExceptions(options.exceptions),
6447
6471
  networkHosts: options.networkHosts,
6448
6472
  expectedSchedule: options.expectedSchedule,
6449
6473
  maxEventsPerHour: options.maxEventsPerHour,
@@ -6513,8 +6537,15 @@ var Sentinel = class _Sentinel {
6513
6537
  ...options.role,
6514
6538
  agentId,
6515
6539
  name: options.name,
6516
- // Forbidden-pattern normalization chokepoint (Part A) idempotent.
6517
- forbiddenTargetPatterns: (options.role.forbiddenTargetPatterns ?? DEFAULT_FORBIDDEN_PATTERNS).map(normalizeForbiddenPattern)
6540
+ // Forbidden-pattern normalization chokepoint (Part A) + Sprint 26 FIX 3B
6541
+ // defaults-as-floor union — same semantics as monitor().
6542
+ forbiddenTargetPatterns: unionWithDefaultForbiddenPatterns(
6543
+ options.role.forbiddenTargetPatterns
6544
+ ),
6545
+ // Sprint 26 FIX 3 — same ceiling-side read carve-out as monitor(). The
6546
+ // merged role arriving here keeps ceiling exceptions (workspace ones are
6547
+ // already dropped by mergeRoles); the append is idempotent by target.
6548
+ exceptions: withPolicyReadExceptions(options.role.exceptions)
6518
6549
  };
6519
6550
  if (await this.profileManager.agentExists(agentId)) {
6520
6551
  const store = await this.profileManager.loadAgentProfile(agentId);
@@ -7318,13 +7349,13 @@ var Sentinel = class _Sentinel {
7318
7349
  type: "agent_quarantined",
7319
7350
  agentId,
7320
7351
  agentName: event.agentName ?? agentId,
7321
- description: `Agent ${agentId} is quarantined. All actions blocked. Run 'sentinel release' to restore access.`,
7352
+ description: `Agent ${agentId} is quarantined. All actions blocked. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore access.`,
7322
7353
  evidence: {
7323
7354
  action: event.action,
7324
7355
  target: event.primaryTarget,
7325
7356
  timestamp: event.timestamp
7326
7357
  },
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.`,
7358
+ recommendation: `Agent must be manually released before it can perform any actions. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore access.`,
7328
7359
  timestamp: event.timestamp
7329
7360
  }
7330
7361
  };
@@ -7339,13 +7370,13 @@ var Sentinel = class _Sentinel {
7339
7370
  type: "agent_restricted",
7340
7371
  agentId,
7341
7372
  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.`,
7373
+ 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
7374
  evidence: {
7344
7375
  action: event.action,
7345
7376
  target: event.primaryTarget,
7346
7377
  timestamp: event.timestamp
7347
7378
  },
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.`,
7379
+ recommendation: `Only file_read and tool_invocation are allowed in restricted mode. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore full access.`,
7349
7380
  timestamp: event.timestamp
7350
7381
  }
7351
7382
  };
@@ -7374,6 +7405,17 @@ var Sentinel = class _Sentinel {
7374
7405
  * Sprint 16 Prompt 3 — replaces in-memory blockCounts Map to survive
7375
7406
  * gateway restarts. Same pattern as Sprint 11 sessionCount Shape D fix.
7376
7407
  */
7408
+ /**
7409
+ * Shared escalation-eligibility predicate (Sprint 26 F-5). Single source of
7410
+ * truth for "this finding counts toward the block ladder", extracted from the
7411
+ * formerly-inline copies in getEffectiveBlockCount and maybeEscalate — behavior
7412
+ * of both is identical to before the extraction. Accepts either a
7413
+ * SecurityFinding-shaped object ({type}) or an audit entry ({findingType},
7414
+ * mapped by the caller).
7415
+ */
7416
+ static isEscalationEligible(f) {
7417
+ return _Sentinel.ESCALATION_ELIGIBLE_TYPES.has(f.type) && (f.severity === "HIGH" || f.severity === "CRITICAL") && f.kind === "actionable";
7418
+ }
7377
7419
  async getEffectiveBlockCount(agentId) {
7378
7420
  const modeChanges = await this.query(agentId, { type: "mode_change" });
7379
7421
  const releaseEntry = modeChanges.find((e) => e.mode === "normal");
@@ -7382,16 +7424,33 @@ var Sentinel = class _Sentinel {
7382
7424
  type: "finding",
7383
7425
  ...anchor ? { startTime: anchor } : {}
7384
7426
  });
7385
- return findings.filter(
7386
- (f) => _Sentinel.ESCALATION_ELIGIBLE_TYPES.has(f.findingType) && (f.severity === "HIGH" || f.severity === "CRITICAL") && f.kind === "actionable"
7387
- ).length;
7427
+ const survivors = findings.filter(
7428
+ (f) => _Sentinel.isEscalationEligible({
7429
+ type: f.findingType,
7430
+ severity: f.severity,
7431
+ kind: f.kind
7432
+ }) && f.mentionOnly !== true
7433
+ );
7434
+ const distinctKeys = /* @__PURE__ */ new Set();
7435
+ let keyless = 0;
7436
+ for (const f of survivors) {
7437
+ const tk = f.targetKey;
7438
+ const dk = f.dedupKey;
7439
+ if (typeof tk === "string" && tk.length > 0) distinctKeys.add(`t:${tk}`);
7440
+ else if (typeof dk === "string" && dk.length > 0) distinctKeys.add(`c:${dk}`);
7441
+ else keyless++;
7442
+ }
7443
+ return distinctKeys.size + keyless;
7388
7444
  }
7389
7445
  async maybeEscalate(agentId, finding) {
7390
7446
  const mode = this.getMode(agentId);
7391
7447
  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;
7448
+ if (!_Sentinel.isEscalationEligible({
7449
+ type: finding.type,
7450
+ severity: finding.severity,
7451
+ kind: finding.kind
7452
+ }))
7453
+ return;
7395
7454
  const count = await this.getEffectiveBlockCount(agentId);
7396
7455
  if (mode === "restricted") {
7397
7456
  if (count >= this.quarantineThreshold) {
@@ -7426,4 +7485,4 @@ export {
7426
7485
  createCliApproval,
7427
7486
  Sentinel
7428
7487
  };
7429
- //# sourceMappingURL=chunk-NS6ZLMDK.js.map
7488
+ //# sourceMappingURL=chunk-SSDIBY52.js.map
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=chunk-TKAKHSZ3.js.map