@tuent/sentinel 0.1.3 → 0.1.5

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.
@@ -16,6 +16,7 @@ interface SkillNode {
16
16
  core?: boolean;
17
17
  sharable?: boolean;
18
18
  files?: string[];
19
+ eventCount?: number;
19
20
  }
20
21
 
21
22
  interface DataPoint {
@@ -29,6 +30,7 @@ interface DataPoint {
29
30
  files?: string[];
30
31
  sharable?: boolean;
31
32
  core?: boolean;
33
+ eventCount?: number;
32
34
  }
33
35
 
34
36
  interface StoredProfile {
@@ -89,16 +91,41 @@ declare class ProfileStore {
89
91
  private petalCount;
90
92
  private engine;
91
93
  private profile;
94
+ /** Snapshot (label → serialized point) of the on-disk-synced dataPoint set,
95
+ * refreshed on every load/create/write. The base for save()'s three-way merge
96
+ * of concurrent external writes — lets us tell "I deleted this" (in baseline,
97
+ * gone from memory) from "another writer added this" (on disk, not in baseline). */
98
+ private baseline;
92
99
  constructor(options: {
93
100
  backend: StorageBackend;
94
101
  eventBus?: EventBus;
95
102
  petalCount?: number;
96
103
  });
97
104
  load(): Promise<StoredProfile | null>;
105
+ /** Snapshot the on-disk-synced dataPoint set for save()'s three-way merge. */
106
+ private captureBaseline;
98
107
  create(name: string): Promise<StoredProfile>;
99
108
  addPoint(point: DataPoint): Promise<void>;
100
109
  addPoints(points: DataPoint[]): void;
101
110
  save(): Promise<void>;
111
+ /**
112
+ * Three-way merge of a concurrent external write into the in-memory profile,
113
+ * invoked by save() when the backend reports the file changed since we last
114
+ * synced. Writers in this app are single-user but multi-process (agent scanner,
115
+ * diary watcher, dev server, CLI), so concurrent writes are real but rare.
116
+ *
117
+ * Model (base = the dataPoint set at our last load/write, see `baseline`):
118
+ * - a point present in memory → kept as-is (the SAVING process's adds/edits
119
+ * win on a same-point conflict — save() is an explicit "persist my state");
120
+ * - a disk point whose label is NEW since baseline → another writer's
121
+ * addition → preserved;
122
+ * - a point we dropped (in baseline, absent from memory) → our deletion is
123
+ * honored, UNLESS the other writer modified it (then their version is kept
124
+ * so a concurrent edit is never silently lost).
125
+ * Net: nothing vanishes except a same-point simultaneous edit, resolved
126
+ * last-writer (this save) — a deliberate, documented tie-break.
127
+ */
128
+ private mergeExternalChanges;
102
129
  build(): SkillNode[];
103
130
  getProfile(): StoredProfile | null;
104
131
  }
@@ -264,8 +291,16 @@ interface SecurityFinding {
264
291
  * its dominant real-world cause is a recognized-tool set gone stale after a
265
292
  * Claude Code update, not agent misbehavior — counting it would turn every
266
293
  * cc update into a restriction ratchet.
267
- */
268
- type: "scope_violation" | "temporal_anomaly" | "access_pattern" | "volume_spike" | "unauthorized_target" | "role_violation" | "behavioral_absence" | "agent_quarantined" | "agent_restricted" | "intent_drift" | "hook_block" | "bash_analysis" | "workspace_mismatch" | "unknown_tool";
294
+ *
295
+ * `enforcement_error` is likewise non-eligible: it records that Sentinel's
296
+ * OWN enforcement machinery threw an internal error while screening a call
297
+ * (so the call was decided by posture, not by policy). The cause is a
298
+ * Sentinel bug or broken config, not agent misbehavior — counting it would
299
+ * compound a Sentinel-internal failure into an agent lockout. It is HIGH +
300
+ * actionable so it alerts and demands operator attention, but it never
301
+ * moves the escalation ladder.
302
+ */
303
+ type: "scope_violation" | "temporal_anomaly" | "access_pattern" | "volume_spike" | "unauthorized_target" | "role_violation" | "behavioral_absence" | "agent_quarantined" | "agent_restricted" | "intent_drift" | "hook_block" | "bash_analysis" | "workspace_mismatch" | "unknown_tool" | "enforcement_error";
269
304
  agentId: string;
270
305
  agentName: string;
271
306
  description: string;
@@ -380,7 +415,6 @@ interface SentinelConfig {
380
415
  mcpLogDir?: string;
381
416
  webhookPort?: number;
382
417
  webhookApiKey?: string;
383
- roleDefinitionPath?: string;
384
418
  readExisting?: boolean;
385
419
  }[];
386
420
  alerts?: {
@@ -400,9 +434,6 @@ interface SentinelConfig {
400
434
  minKind?: "informational" | "actionable";
401
435
  }[];
402
436
  };
403
- alertWebhook?: string;
404
- baselineWindowDays?: number;
405
- checkIntervalMs?: number;
406
437
  }
407
438
  /** A declared task/intent for an agent. */
408
439
  interface TaskIntent {
@@ -447,13 +478,18 @@ interface IntentAlignmentResult {
447
478
  bypassed?: boolean;
448
479
  bypassReason?: string | null;
449
480
  }
450
- /** Configuration for intent alignment scoring. */
481
+ /**
482
+ * Configuration for intent alignment scoring.
483
+ *
484
+ * NOTE: the keywordBoost / acceptableActionBoost / baseScore /
485
+ * missingTaskScore knobs were removed — they were defined, defaulted, and
486
+ * typed but never read by any scoring path (vestiges of a pre-weighted
487
+ * scoring model superseded by SimilarityEngine.keywordSimilarity). They
488
+ * promised tunability that did not exist; the live weights are fixed in
489
+ * SimilarityEngine. defaultTtlMs is the only consumed numeric knob.
490
+ */
451
491
  interface IntentAlignmentConfig {
452
492
  defaultTtlMs: number;
453
- keywordBoost: number;
454
- acceptableActionBoost: number;
455
- baseScore: number;
456
- missingTaskScore: number;
457
493
  disableDefaultAcceptable?: boolean;
458
494
  denyAcceptable?: string[];
459
495
  similarityThreshold?: number;
@@ -864,6 +900,20 @@ declare class AuditTrail {
864
900
  * the file when an external append actually happened.
865
901
  */
866
902
  private reanchorFromDiskTail;
903
+ /**
904
+ * Scan the on-disk log backward for the last entry carrying a valid (64-hex)
905
+ * hash, SKIPPING torn/corrupt trailing lines, and return that hash (or null
906
+ * when no parseable hashed entry exists).
907
+ *
908
+ * Shared by open() (chain resume) and reanchorFromDiskTail() (live
909
+ * re-anchor). The earlier inline versions inspected only the LAST non-empty
910
+ * line and broke: a torn tail line (a partial write) left the head at genesis
911
+ * (open) or stale (reanchor), so the next append forked a fresh chain from
912
+ * genesis — destroying all prior linkage. Walking back leaves the torn line
913
+ * as a single localized gap that verify() still flags, while the chain head
914
+ * stays anchored to the last good entry.
915
+ */
916
+ private lastHashOnDisk;
867
917
  private readAllEntries;
868
918
  private loadCumulativeStats;
869
919
  /**
@@ -916,6 +966,54 @@ interface AgentClassifierConfig {
916
966
  minDurationMs: number;
917
967
  }
918
968
 
969
+ interface SensitivityRule {
970
+ pattern: string;
971
+ sensitivity: number;
972
+ category: string;
973
+ description: string;
974
+ }
975
+ interface TargetSensitivityResult {
976
+ sensitivity: number;
977
+ category: string;
978
+ description: string;
979
+ matchedRule: string;
980
+ actionMultiplier: number;
981
+ effectiveScore: number;
982
+ groundedInRepoMap: boolean;
983
+ }
984
+ declare class TargetSensitivityScorer {
985
+ private rules;
986
+ private repoMap;
987
+ private repoRoot;
988
+ private overlay;
989
+ constructor(customRules?: SensitivityRule[], repoMap?: RepoSensitivityMap, repoRoot?: string, overlay?: SensitivityOverlay);
990
+ scoreTarget(target: string, action: string): TargetSensitivityResult;
991
+ /** Pattern-only scoring (Tier 2). Used directly by reject decisions. */
992
+ private _scoreByPatterns;
993
+ /**
994
+ * URL-aware component scoring (Sprint 6b W1 fix).
995
+ * Parses the URL, extracts path/query-values/fragment, decodes each once,
996
+ * scores each against forbidden-pattern rules, takes MAX.
997
+ */
998
+ private _scoreUrlTarget;
999
+ scoreEvent(event: AgentActivityEvent): TargetSensitivityResult;
1000
+ /**
1001
+ * Replaces the active repo sensitivity map. Pass null to clear.
1002
+ * If newRepoRoot is provided, it overrides the map's repoRoot.
1003
+ */
1004
+ updateRepoMap(newMap: RepoSensitivityMap | null, newRepoRoot?: string): void;
1005
+ /**
1006
+ * Replaces the active sensitivity overlay. Pass null to clear.
1007
+ */
1008
+ updateOverlay(newOverlay: SensitivityOverlay | null): void;
1009
+ /**
1010
+ * Returns all pattern-based rules that match the target. Does NOT
1011
+ * consult the repo map. Used by RepoSensitivityScanner during
1012
+ * scan-time enumeration.
1013
+ */
1014
+ getAllMatchingRules(target: string): SensitivityRule[];
1015
+ }
1016
+
919
1017
  /** Lazy-init wrapper passed via RoleValidatorOptions so Sentinel can own the cache. */
920
1018
  interface ForbiddenInodeCacheRef {
921
1019
  getOrBuild: () => Set<bigint>;
@@ -1229,6 +1327,7 @@ declare class SentinelRunner {
1229
1327
  private _hookEngine?;
1230
1328
  private _maturityConfig?;
1231
1329
  private _validatorOptions?;
1330
+ private _sensitivityScorer?;
1232
1331
  private intentTracker;
1233
1332
  private similarityEngine;
1234
1333
  private recentAlignmentScores;
@@ -1249,6 +1348,12 @@ declare class SentinelRunner {
1249
1348
  setMaturityConfig(config: Partial<BaselineMaturityConfig>): void;
1250
1349
  /** Set RoleValidator options (exception approval fn, audit callback). */
1251
1350
  setRoleValidatorOptions(options: RoleValidatorOptions): void;
1351
+ /**
1352
+ * Inject the facade's shared sensitivity scorer (overlay + repo map aware)
1353
+ * so processEvent's validator scores identically to the facade's check().
1354
+ * Must be called BEFORE start() builds the validator.
1355
+ */
1356
+ setSensitivityScorer(scorer: TargetSensitivityScorer): void;
1252
1357
  /** Set the behavioral baseline and start periodic gap checking. */
1253
1358
  setBaseline(baseline: AgentBaseline): void;
1254
1359
  /** Declare a new task intent for this agent. */
@@ -1265,6 +1370,12 @@ declare class SentinelRunner {
1265
1370
  getActiveTask(): TaskIntent | null;
1266
1371
  /** Expose the similarity engine for pre-execution intent checks. */
1267
1372
  getSimilarityEngine(): SimilarityEngine;
1373
+ /**
1374
+ * Read-only view of the rolling misaligned-score window, for the
1375
+ * pre-execution gate (Sentinel.check) to apply the SAME sustained-drift
1376
+ * severity upgrade as the recording path — without mutating the window.
1377
+ */
1378
+ getRecentAlignmentScores(): readonly number[];
1268
1379
  /**
1269
1380
  * Side-effect-free pre-execution gate: evaluate an event and fire pre_execution
1270
1381
  * hooks WITHOUT running processEvent's full pipeline.
@@ -0,0 +1,10 @@
1
+ import {
2
+ Sentinel
3
+ } from "./chunk-7R6EA7JG.js";
4
+ import "./chunk-M5EEVMLU.js";
5
+ import "./chunk-SKE74CYZ.js";
6
+ import "./chunk-NUXSUSYY.js";
7
+ export {
8
+ Sentinel
9
+ };
10
+ //# sourceMappingURL=Sentinel-XP6NFG6Z.js.map
@@ -16,6 +16,9 @@ var LogAdapter = class {
16
16
  pendingFragment = "";
17
17
  running = false;
18
18
  polling = false;
19
+ /** The in-flight poll (if any), so stop() can let it finish emitting its
20
+ * already-read batch before persisting the cursor. */
21
+ pollInFlight = null;
19
22
  pollTimer = null;
20
23
  onEvent = null;
21
24
  readExisting;
@@ -33,6 +36,7 @@ var LogAdapter = class {
33
36
  if (this.running) return;
34
37
  this.onEvent = onEvent;
35
38
  this.running = true;
39
+ this.pendingFragment = "";
36
40
  const savedPosition = await this.loadCursor();
37
41
  if (savedPosition !== null) {
38
42
  this.lastReadPosition = savedPosition;
@@ -47,7 +51,9 @@ var LogAdapter = class {
47
51
  } else {
48
52
  this.lastReadPosition = 0;
49
53
  }
50
- this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
54
+ this.pollTimer = setInterval(() => {
55
+ this.pollInFlight = this.poll();
56
+ }, this.pollIntervalMs);
51
57
  console.log(`LogAdapter watching ${this.logPath} for agent ${this.agentId}`);
52
58
  }
53
59
  async stop() {
@@ -55,6 +61,13 @@ var LogAdapter = class {
55
61
  clearInterval(this.pollTimer);
56
62
  this.pollTimer = null;
57
63
  }
64
+ if (this.pollInFlight) {
65
+ try {
66
+ await this.pollInFlight;
67
+ } catch {
68
+ }
69
+ this.pollInFlight = null;
70
+ }
58
71
  await this.saveCursor();
59
72
  this.running = false;
60
73
  this.onEvent = null;
@@ -66,7 +79,6 @@ var LogAdapter = class {
66
79
  try {
67
80
  const lines = await this.readNewLines();
68
81
  for (const line of lines) {
69
- if (!this.running) break;
70
82
  const event = this.parseLine(line);
71
83
  if (event) {
72
84
  try {
@@ -194,10 +206,9 @@ var LogAdapter = class {
194
206
  try {
195
207
  const { writeFile, mkdir } = await import("fs/promises");
196
208
  await mkdir(this.stateDir, { recursive: true });
197
- await writeFile(
198
- path,
199
- JSON.stringify({ position: this.lastReadPosition, logPath: this.logPath }) + "\n"
200
- );
209
+ const fragmentBytes = Buffer.byteLength(this.pendingFragment, "utf-8");
210
+ const position = Math.max(0, this.lastReadPosition - fragmentBytes);
211
+ await writeFile(path, JSON.stringify({ position, logPath: this.logPath }) + "\n");
201
212
  } catch (err) {
202
213
  console.warn(`Failed to save log cursor: ${err}`);
203
214
  }
@@ -235,4 +246,4 @@ var LogAdapter = class {
235
246
  export {
236
247
  LogAdapter
237
248
  };
238
- //# sourceMappingURL=chunk-PDWWRZXF.js.map
249
+ //# sourceMappingURL=chunk-2IPSTUNH.js.map
@@ -3,9 +3,8 @@ import {
3
3
  } from "./chunk-B5QKJHSV.js";
4
4
  import {
5
5
  discoverPolicy
6
- } from "./chunk-FMZWHT4M.js";
6
+ } from "./chunk-B6S2PBS4.js";
7
7
  import {
8
- DEFAULT_FORBIDDEN_PATTERNS,
9
8
  FORBIDDEN_BASENAMES,
10
9
  classifyDeny,
11
10
  isPositionallySafeMention,
@@ -14,16 +13,18 @@ import {
14
13
  scanBashCommand,
15
14
  scanContentForForbiddenBasenames,
16
15
  scanGlobPattern,
17
- tokenizePaths
18
- } from "./chunk-JTR2E7RD.js";
16
+ tokenizePaths,
17
+ unionWithDefaultForbiddenPatterns
18
+ } from "./chunk-M5EEVMLU.js";
19
19
  import {
20
+ DEFAULT_FORBIDDEN_PATTERNS,
20
21
  loadPolicy,
21
22
  policyToConfig,
22
23
  policyToRole
23
- } from "./chunk-WLIDSTS4.js";
24
+ } from "./chunk-SKE74CYZ.js";
24
25
 
25
26
  // src/gateway/workspaceRouter.ts
26
- import { resolve, dirname } from "path";
27
+ import { resolve, dirname, sep } from "path";
27
28
 
28
29
  // src/mergeRoles.ts
29
30
  function isWithinActiveHours(hour, range) {
@@ -251,15 +252,43 @@ async function resolveWorkspace(cwd, home) {
251
252
  const policyPath = discoverPolicy(start, home);
252
253
  let root = start;
253
254
  let workspaceRole = null;
255
+ const warnings = [];
254
256
  if (policyPath) {
255
- const policy = await loadPolicy(policyPath);
257
+ let policy;
258
+ try {
259
+ policy = await loadPolicy(policyPath);
260
+ } catch (err) {
261
+ return {
262
+ ok: false,
263
+ reason: `the workspace's policy file failed to load (${err instanceof Error ? err.message : String(err)}) \u2014 refusing to serve the workspace without its policy`
264
+ };
265
+ }
266
+ const policyDir = dirname(resolve(policyPath));
267
+ root = policyDir;
256
268
  const repoRoot = policyToConfig(policy).repo?.root;
257
- root = repoRoot ? resolve(repoRoot) : dirname(resolve(policyPath));
269
+ if (repoRoot) {
270
+ const declared = resolve(policyDir, repoRoot);
271
+ const homeAbs = resolve(home);
272
+ const declaredPrefix = declared.endsWith(sep) ? declared : declared + sep;
273
+ const containsPolicy = policyDir === declared || policyDir.startsWith(declaredPrefix);
274
+ const aboveHome = declared !== homeAbs && homeAbs.startsWith(declaredPrefix);
275
+ if (!containsPolicy) {
276
+ warnings.push(
277
+ `repo.root "${repoRoot}" (resolves to "${declared}") does not contain the policy file "${policyPath}" \u2014 anchoring to the policy's own directory "${policyDir}" instead`
278
+ );
279
+ } else if (aboveHome) {
280
+ warnings.push(
281
+ `repo.root "${repoRoot}" (resolves to "${declared}") reaches above the home directory \u2014 anchoring to the policy's own directory "${policyDir}" instead`
282
+ );
283
+ } else {
284
+ root = declared;
285
+ }
286
+ }
258
287
  workspaceRole = policyToRole(policy);
259
288
  }
260
289
  return {
261
290
  ok: true,
262
- resolution: { root, agentId: deriveAgentId(root), policyPath, workspaceRole }
291
+ resolution: { root, agentId: deriveAgentId(root), policyPath, workspaceRole, warnings }
263
292
  };
264
293
  }
265
294
  function effectiveRole(ceiling, workspaceRole) {
@@ -347,11 +376,11 @@ var TranslatorRegistry = class {
347
376
 
348
377
  // src/gateway/runtimeConstructionResolvers.ts
349
378
  var MAX_RECURSION_DEPTH = 3;
350
- var INTERPRETER_RE = /\b(?:python[23]?|node|ruby|perl|php)\s+(?:-[cer])\s+(.+)/s;
379
+ var INTERPRETER_RE = /\b(?:python|node|ruby|perl|php)[0-9.]*\s+(?:\S+\s+)*?-[cer]/;
351
380
  var NESTED_ENCODING_RE = /chr\(\d+\)|String\.fromCharCode|\\x[0-9a-fA-F]{2}|\\[0-7]{1,3}|printf\s/;
352
381
  var PRINTF_HEX_RE = /printf\s+['"]?[^'"]*\\x[0-9a-fA-F]{2}/;
353
382
  var PRINTF_OCT_RE = /printf\s+['"]?[^'"]*\\[0-7]{1,3}/;
354
- var B1_CONTEXT_RE = /(?:\bbase64\s+(?:-d|--decode)\b|\bopenssl\s+(?:enc\s+)?(?:-?base64\s+(?:-\w+\s+)*-d|-d\s+(?:-\w+\s+)*-?base64)\b)/;
383
+ var B1_CONTEXT_RE = /(?:\bbase64\s+(?:--decode\b|-[a-zA-Z]*[dD][a-zA-Z]*\b)|\bopenssl\s+(?:enc\s+)?(?:-?base64\s+(?:-\w+\s+)*-d|-d\s+(?:-\w+\s+)*-?base64)\b)/;
355
384
  var ECHO_E_HEX_RE = /\becho\s+(?:-\w+\s+)*-\w*e\w*\b[^|;&\n]*\\x[0-9a-fA-F]{2}/;
356
385
  var ANSI_C_QUOTE_HEX_RE = /\$'[^']*\\x[0-9a-fA-F]{2}/;
357
386
  var ANSI_C_QUOTE_OCT_RE = /\$'[^']*\\[0-7]{1,3}/;
@@ -1032,17 +1061,25 @@ var ClaudeCodeTranslator = class {
1032
1061
  };
1033
1062
 
1034
1063
  // src/gateway/grepRewriter.ts
1064
+ function toRelativeExclusionGlob(pattern) {
1065
+ return pattern.startsWith("/") ? "**" + pattern : pattern;
1066
+ }
1035
1067
  function buildGrepExclusions(forbiddenPatterns) {
1036
1068
  const flags = [];
1037
1069
  for (const pattern of forbiddenPatterns) {
1038
- flags.push("--glob", `!${pattern}`);
1070
+ flags.push("--glob", `!${toRelativeExclusionGlob(pattern)}`);
1039
1071
  }
1040
1072
  return flags;
1041
1073
  }
1042
1074
  function isPathItselfForbidden(searchPath, forbiddenPatterns) {
1043
1075
  const matched = [];
1044
1076
  for (const pattern of forbiddenPatterns) {
1045
- if (matchGlobInsensitive(pattern, searchPath)) {
1077
+ if (matchGlobInsensitive(pattern, searchPath) || // A directory-glob (`…/**`, e.g. `**/.ssh/**` or `/etc/**`) does NOT match
1078
+ // the bare directory itself — the trailing `/.*` requires a char after the
1079
+ // slash, so `matchGlob('**/.ssh/**', '/home/u/.ssh')` is false. When the
1080
+ // SEARCH PATH *is* the forbidden directory, treat it as forbidden so it
1081
+ // routes to DENY (no rewrite), not MODIFY.
1082
+ pattern.endsWith("/**") && matchGlobInsensitive(pattern.slice(0, -3), searchPath)) {
1046
1083
  matched.push(pattern);
1047
1084
  }
1048
1085
  }
@@ -1116,6 +1153,8 @@ var SentinelGateway = class {
1116
1153
  releaseToken;
1117
1154
  /** Item D (F-8): disposition for unknown (non-MCP, unrecognized) tool names. */
1118
1155
  unknownTools;
1156
+ /** Posture for an internal wrap() error: allow (fail-open, default) or block. */
1157
+ onInternalError;
1119
1158
  /** Daemon-staleness build identity (content hash of the launched-from entry),
1120
1159
  * reported via /health. "unknown" when not supplied by the launcher. */
1121
1160
  buildId;
@@ -1134,6 +1173,7 @@ var SentinelGateway = class {
1134
1173
  this.home = options.home ?? "";
1135
1174
  this.releaseToken = options.releaseToken ?? null;
1136
1175
  this.unknownTools = options.unknownTools ?? "warn";
1176
+ this.onInternalError = options.onInternalError ?? "fail-open";
1137
1177
  this.buildId = options.buildId ?? "unknown";
1138
1178
  const internal = options;
1139
1179
  if (internal.registry) {
@@ -1424,6 +1464,9 @@ var SentinelGateway = class {
1424
1464
  return { ok: false, finding };
1425
1465
  }
1426
1466
  const { root, agentId, workspaceRole } = resolved.resolution;
1467
+ for (const w of resolved.resolution.warnings) {
1468
+ console.warn(`[SENTINEL GATEWAY] workspace-resolution warning (${agentId}): ${w}`);
1469
+ }
1427
1470
  const ceiling = this.operatorCeiling;
1428
1471
  if (!ceiling) {
1429
1472
  const check = {
@@ -1450,7 +1493,24 @@ var SentinelGateway = class {
1450
1493
  name: agentId
1451
1494
  });
1452
1495
  this.sentinel.touchAgent(agentId);
1453
- return { ok: true, agentId };
1496
+ return { ok: true, agentId, effectiveRole: merged.role };
1497
+ }
1498
+ /**
1499
+ * Forbidden-pattern list for ONE request: the gateway's construction-time
1500
+ * list unioned with the request's merged-role patterns (operator-ceiling ∩
1501
+ * workspace yaml). Without this, a workspace's custom forbid targets were
1502
+ * enforced by the role validator inside wrap() but never reached the
1503
+ * gateway's own bash-L1 / Grep layers, which checked only the static list.
1504
+ * Normalization is idempotent (the chokepoint globstar-prepend).
1505
+ */
1506
+ patternsForRequest(effectiveRole2) {
1507
+ if (!effectiveRole2?.forbiddenTargetPatterns?.length) return this.forbiddenPatterns;
1508
+ return [
1509
+ .../* @__PURE__ */ new Set([
1510
+ ...this.forbiddenPatterns,
1511
+ ...effectiveRole2.forbiddenTargetPatterns.map(normalizeForbiddenPattern)
1512
+ ])
1513
+ ];
1454
1514
  }
1455
1515
  async handlePreToolUse(body, res, translator) {
1456
1516
  let payload;
@@ -1460,12 +1520,43 @@ var SentinelGateway = class {
1460
1520
  this.sendJson(res, 400, { error: "invalid JSON" });
1461
1521
  return;
1462
1522
  }
1463
- const event = translator.translatePreToolUse(payload);
1523
+ let event;
1524
+ try {
1525
+ event = translator.translatePreToolUse(payload);
1526
+ } catch (err) {
1527
+ console.error("[SENTINEL GATEWAY] translator error (pre-tool-use):", err);
1528
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1529
+ const finding = {
1530
+ severity: "HIGH",
1531
+ kind: "actionable",
1532
+ type: "enforcement_error",
1533
+ agentId: this.agentId,
1534
+ agentName: this.agentId,
1535
+ description: `Enforcement error \u2014 the ${translator.agentType} translator threw while parsing a pre-tool-use payload: ${err instanceof Error ? err.message : String(err)}. The request was answered with an error (HTTP 500), not an allow.`,
1536
+ evidence: {
1537
+ action: "tool_invocation",
1538
+ target: "(untranslatable pre-tool-use payload)",
1539
+ timestamp: ts,
1540
+ baselineComparison: "enforcement_internal_error"
1541
+ },
1542
+ recommendation: "Investigate the gateway daemon log for the underlying translator exception \u2014 the payload could not be screened.",
1543
+ timestamp: ts,
1544
+ decision: "deny"
1545
+ };
1546
+ try {
1547
+ await this.sentinel.logFinding(this.agentId, finding);
1548
+ } catch (logErr) {
1549
+ console.error("[SENTINEL GATEWAY] failed to persist enforcement_error finding:", logErr);
1550
+ }
1551
+ this.sendJson(res, 500, { error: "translator error" });
1552
+ return;
1553
+ }
1464
1554
  if (!event) {
1465
1555
  this.sendJson(res, 400, { error: "unable to translate payload" });
1466
1556
  return;
1467
1557
  }
1468
1558
  let routingId = this.agentId;
1559
+ let requestForbiddenPatterns = this.forbiddenPatterns;
1469
1560
  if (this.workspaceIsolation) {
1470
1561
  const routed = await this.resolveBPathRouting(
1471
1562
  event.metadata?.cwd,
@@ -1483,6 +1574,7 @@ var SentinelGateway = class {
1483
1574
  }
1484
1575
  routingId = routed.agentId;
1485
1576
  event.agentId = routingId;
1577
+ requestForbiddenPatterns = this.patternsForRequest(routed.effectiveRole);
1486
1578
  }
1487
1579
  if (event.metadata?._unknownTool === "true") {
1488
1580
  const unknownName = event.metadata.ccToolName ?? event.primaryTarget;
@@ -1555,7 +1647,7 @@ var SentinelGateway = class {
1555
1647
  let matchedPath = null;
1556
1648
  let matchedPattern = null;
1557
1649
  for (const tokenPath of allTokenPaths) {
1558
- for (const pattern of this.forbiddenPatterns) {
1650
+ for (const pattern of requestForbiddenPatterns) {
1559
1651
  if (matchGlobInsensitive(pattern, tokenPath)) {
1560
1652
  matchedPath = tokenPath;
1561
1653
  matchedPattern = pattern;
@@ -1737,7 +1829,7 @@ var SentinelGateway = class {
1737
1829
  if (ccToolName === "Grep") {
1738
1830
  const toolInput = parsedPayload.tool_input ?? {};
1739
1831
  const searchPath = typeof toolInput.path === "string" && toolInput.path.length > 0 ? toolInput.path : parsedPayload.cwd ?? ".";
1740
- const pathCheck = isPathItselfForbidden(searchPath, this.forbiddenPatterns);
1832
+ const pathCheck = isPathItselfForbidden(searchPath, requestForbiddenPatterns);
1741
1833
  if (pathCheck.forbidden) {
1742
1834
  const finding2 = {
1743
1835
  severity: "HIGH",
@@ -1762,7 +1854,7 @@ var SentinelGateway = class {
1762
1854
  this.sendJson(res, 200, response);
1763
1855
  return;
1764
1856
  }
1765
- const exclusions = buildGrepExclusions(this.forbiddenPatterns);
1857
+ const exclusions = buildGrepExclusions(requestForbiddenPatterns);
1766
1858
  const updatedInput = buildModifiedGrepInput(toolInput, exclusions);
1767
1859
  const finding = {
1768
1860
  severity: "MEDIUM",
@@ -1785,7 +1877,7 @@ var SentinelGateway = class {
1785
1877
  await this.sentinel.logFinding(routingId, finding);
1786
1878
  this.telemetry.recordToolCall(event.action, "pre", "allowed", 0);
1787
1879
  const ccTranslator = translator;
1788
- const excludedPatterns = this.forbiddenPatterns.join(", ");
1880
+ const excludedPatterns = requestForbiddenPatterns.join(", ");
1789
1881
  const additionalContext = `Sentinel: this Grep search was modified to exclude forbidden paths. Excluded patterns: ${excludedPatterns}. To search specific forbidden files, use Read with explicit approval.`;
1790
1882
  if (typeof ccTranslator.formatPreToolUseModifyResponse === "function") {
1791
1883
  const response = ccTranslator.formatPreToolUseModifyResponse({
@@ -1883,7 +1975,37 @@ var SentinelGateway = class {
1883
1975
  this.sendJson(res, 200, response);
1884
1976
  } catch (err) {
1885
1977
  console.error("[SENTINEL GATEWAY] wrap() error:", err);
1886
- const response = translator.formatPreToolUseResponse({ blocked: false });
1978
+ const failClosed = this.onInternalError === "fail-closed";
1979
+ const errMessage = err instanceof Error ? err.message : String(err);
1980
+ const finding = {
1981
+ severity: "HIGH",
1982
+ kind: "actionable",
1983
+ type: "enforcement_error",
1984
+ agentId: routingId,
1985
+ agentName: event.agentName,
1986
+ description: `Enforcement error \u2014 sentinel.wrap() threw while screening "${event.action}": ${errMessage}. The call was ${failClosed ? "BLOCKED" : "ALLOWED WITHOUT SCREENING"} (enforcement.onInternalError: ${this.onInternalError}).`,
1987
+ evidence: {
1988
+ action: event.action,
1989
+ target: event.primaryTarget,
1990
+ timestamp: event.timestamp,
1991
+ baselineComparison: "enforcement_internal_error"
1992
+ },
1993
+ recommendation: `Investigate the gateway daemon log for the underlying exception \u2014 Sentinel's own enforcement failed, so this call was decided by posture, not policy. Set enforcement.onInternalError: "fail-closed" to block instead of allow while the cause is open.`,
1994
+ timestamp: event.timestamp,
1995
+ decision: failClosed ? "deny" : "allow"
1996
+ };
1997
+ try {
1998
+ await this.sentinel.logFinding(routingId, finding);
1999
+ } catch (logErr) {
2000
+ console.error("[SENTINEL GATEWAY] failed to persist enforcement_error finding:", logErr);
2001
+ }
2002
+ this.telemetry.recordToolCall(
2003
+ event.action,
2004
+ "pre",
2005
+ failClosed ? "blocked" : "allowed",
2006
+ Date.now() - start
2007
+ );
2008
+ const response = failClosed ? translator.formatPreToolUseResponse({ blocked: true, finding }) : translator.formatPreToolUseResponse({ blocked: false });
1887
2009
  this.sendJson(res, 200, response);
1888
2010
  }
1889
2011
  }
@@ -2052,11 +2174,11 @@ async function runGatewayDaemon({
2052
2174
  port = DEFAULT_PORT,
2053
2175
  buildId
2054
2176
  }) {
2055
- const { Sentinel: SentinelClass } = await import("./Sentinel-5CQ6HKXS.js");
2177
+ const { Sentinel: SentinelClass } = await import("./Sentinel-XP6NFG6Z.js");
2056
2178
  const { writePidFile, writeReleaseToken } = await import("./pidManager-DOGVN6ZT.js");
2057
2179
  const { homedir } = await import("os");
2058
2180
  const { randomBytes } = await import("crypto");
2059
- const { loadPolicy: loadPolicy2, policyToRole: policyToRole2, policyToConfig: policyToConfig2 } = await import("./policyLoader-KZL2U4M2.js");
2181
+ const { loadPolicy: loadPolicy2, policyToRole: policyToRole2, policyToConfig: policyToConfig2 } = await import("./policyLoader-NUPBBRKH.js");
2060
2182
  const sentinel = await SentinelClass.fromPolicy(policyPath);
2061
2183
  const baseline = await sentinel.computeBaseline("claude-code");
2062
2184
  sentinel.setBaseline("claude-code", baseline);
@@ -2075,7 +2197,12 @@ async function runGatewayDaemon({
2075
2197
  releaseToken,
2076
2198
  unknownTools: operatorConfig.enforcement?.unknownTools,
2077
2199
  allowUnknownTools: operatorConfig.enforcement?.allowUnknownTools,
2078
- buildId
2200
+ onInternalError: operatorConfig.enforcement?.onInternalError,
2201
+ buildId,
2202
+ // The operator policy's custom forbid targets must reach the gateway's own
2203
+ // bash-L1/Grep layers (defaults-as-floor union, same chokepoint as role
2204
+ // construction) — previously the gateway saw only the built-in defaults.
2205
+ forbiddenPatterns: unionWithDefaultForbiddenPatterns(operatorCeiling.forbiddenTargetPatterns)
2079
2206
  });
2080
2207
  await gateway.start();
2081
2208
  const home = homedir();
@@ -2088,4 +2215,4 @@ export {
2088
2215
  SentinelGateway,
2089
2216
  runGatewayDaemon
2090
2217
  };
2091
- //# sourceMappingURL=chunk-G74MMDKA.js.map
2218
+ //# sourceMappingURL=chunk-3WT3K5TH.js.map