@tuent/sentinel 0.1.2 → 0.1.4

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.
@@ -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-QIYQWOLO.js";
16
+ tokenizePaths,
17
+ unionWithDefaultForbiddenPatterns
18
+ } from "./chunk-FIEIGBYL.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-KWZ7JKKO.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,35 @@ 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
257
  const policy = await loadPolicy(policyPath);
258
+ const policyDir = dirname(resolve(policyPath));
259
+ root = policyDir;
256
260
  const repoRoot = policyToConfig(policy).repo?.root;
257
- root = repoRoot ? resolve(repoRoot) : dirname(resolve(policyPath));
261
+ if (repoRoot) {
262
+ const declared = resolve(policyDir, repoRoot);
263
+ const homeAbs = resolve(home);
264
+ const declaredPrefix = declared.endsWith(sep) ? declared : declared + sep;
265
+ const containsPolicy = policyDir === declared || policyDir.startsWith(declaredPrefix);
266
+ const aboveHome = declared !== homeAbs && homeAbs.startsWith(declaredPrefix);
267
+ if (!containsPolicy) {
268
+ warnings.push(
269
+ `repo.root "${repoRoot}" (resolves to "${declared}") does not contain the policy file "${policyPath}" \u2014 anchoring to the policy's own directory "${policyDir}" instead`
270
+ );
271
+ } else if (aboveHome) {
272
+ warnings.push(
273
+ `repo.root "${repoRoot}" (resolves to "${declared}") reaches above the home directory \u2014 anchoring to the policy's own directory "${policyDir}" instead`
274
+ );
275
+ } else {
276
+ root = declared;
277
+ }
278
+ }
258
279
  workspaceRole = policyToRole(policy);
259
280
  }
260
281
  return {
261
282
  ok: true,
262
- resolution: { root, agentId: deriveAgentId(root), policyPath, workspaceRole }
283
+ resolution: { root, agentId: deriveAgentId(root), policyPath, workspaceRole, warnings }
263
284
  };
264
285
  }
265
286
  function effectiveRole(ceiling, workspaceRole) {
@@ -347,11 +368,11 @@ var TranslatorRegistry = class {
347
368
 
348
369
  // src/gateway/runtimeConstructionResolvers.ts
349
370
  var MAX_RECURSION_DEPTH = 3;
350
- var INTERPRETER_RE = /\b(?:python[23]?|node|ruby|perl|php)\s+(?:-[cer])\s+(.+)/s;
371
+ var INTERPRETER_RE = /\b(?:python|node|ruby|perl|php)[0-9.]*\s+(?:\S+\s+)*?-[cer]/;
351
372
  var NESTED_ENCODING_RE = /chr\(\d+\)|String\.fromCharCode|\\x[0-9a-fA-F]{2}|\\[0-7]{1,3}|printf\s/;
352
373
  var PRINTF_HEX_RE = /printf\s+['"]?[^'"]*\\x[0-9a-fA-F]{2}/;
353
374
  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)/;
375
+ 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
376
  var ECHO_E_HEX_RE = /\becho\s+(?:-\w+\s+)*-\w*e\w*\b[^|;&\n]*\\x[0-9a-fA-F]{2}/;
356
377
  var ANSI_C_QUOTE_HEX_RE = /\$'[^']*\\x[0-9a-fA-F]{2}/;
357
378
  var ANSI_C_QUOTE_OCT_RE = /\$'[^']*\\[0-7]{1,3}/;
@@ -786,23 +807,39 @@ function mcpVerbIsMutating(toolName) {
786
807
  const tokens = seg.split(/[_-]/).filter((t) => t.length > 0);
787
808
  return tokens.some((t) => MCP_MUTATING_VERBS.some((v) => t.startsWith(v)));
788
809
  }
810
+ var MCP_MAX_DEPTH = 6;
811
+ var MCP_MAX_NODES = 1e3;
812
+ function collectMcpStrings(value, depth, state) {
813
+ if (state.nodes >= MCP_MAX_NODES) {
814
+ state.truncated = true;
815
+ return;
816
+ }
817
+ state.nodes++;
818
+ if (typeof value === "string") {
819
+ if (value.length > 0) state.strings.push(value);
820
+ return;
821
+ }
822
+ if (value === null || typeof value !== "object") return;
823
+ if (depth >= MCP_MAX_DEPTH) {
824
+ state.truncated = true;
825
+ return;
826
+ }
827
+ const children = Array.isArray(value) ? value : Object.values(value);
828
+ for (const child of children) collectMcpStrings(child, depth + 1, state);
829
+ }
789
830
  function extractMcpTargets(toolName, toolInput) {
790
831
  const targets = [toolName];
791
832
  const keys = Object.keys(toolInput);
792
- let extractedStrings = 0;
793
- for (const key of keys) {
794
- const value = toolInput[key];
795
- if (typeof value !== "string" || value.length === 0) continue;
796
- extractedStrings++;
833
+ const state = { strings: [], nodes: 0, truncated: false };
834
+ for (const key of keys) collectMcpStrings(toolInput[key], 1, state);
835
+ for (const value of state.strings) {
797
836
  targets.push(value);
798
837
  const resolved = runResolverPipeline(value);
799
- for (const r of resolved) {
800
- targets.push(r);
801
- }
838
+ for (const r of resolved) targets.push(r);
802
839
  }
803
840
  return {
804
841
  targets,
805
- unextractable: keys.length > 0 && extractedStrings === 0,
842
+ unextractable: keys.length > 0 && (state.strings.length === 0 || state.truncated),
806
843
  mutating: mcpVerbIsMutating(toolName)
807
844
  };
808
845
  }
@@ -1016,17 +1053,25 @@ var ClaudeCodeTranslator = class {
1016
1053
  };
1017
1054
 
1018
1055
  // src/gateway/grepRewriter.ts
1056
+ function toRelativeExclusionGlob(pattern) {
1057
+ return pattern.startsWith("/") ? "**" + pattern : pattern;
1058
+ }
1019
1059
  function buildGrepExclusions(forbiddenPatterns) {
1020
1060
  const flags = [];
1021
1061
  for (const pattern of forbiddenPatterns) {
1022
- flags.push("--glob", `!${pattern}`);
1062
+ flags.push("--glob", `!${toRelativeExclusionGlob(pattern)}`);
1023
1063
  }
1024
1064
  return flags;
1025
1065
  }
1026
1066
  function isPathItselfForbidden(searchPath, forbiddenPatterns) {
1027
1067
  const matched = [];
1028
1068
  for (const pattern of forbiddenPatterns) {
1029
- if (matchGlobInsensitive(pattern, searchPath)) {
1069
+ if (matchGlobInsensitive(pattern, searchPath) || // A directory-glob (`…/**`, e.g. `**/.ssh/**` or `/etc/**`) does NOT match
1070
+ // the bare directory itself — the trailing `/.*` requires a char after the
1071
+ // slash, so `matchGlob('**/.ssh/**', '/home/u/.ssh')` is false. When the
1072
+ // SEARCH PATH *is* the forbidden directory, treat it as forbidden so it
1073
+ // routes to DENY (no rewrite), not MODIFY.
1074
+ pattern.endsWith("/**") && matchGlobInsensitive(pattern.slice(0, -3), searchPath)) {
1030
1075
  matched.push(pattern);
1031
1076
  }
1032
1077
  }
@@ -1044,7 +1089,6 @@ function buildModifiedGrepInput(originalInput, exclusions) {
1044
1089
  import { timingSafeEqual } from "crypto";
1045
1090
  var DEFAULT_PORT = 7847;
1046
1091
  var MAX_BODY_SIZE = 1024 * 1024;
1047
- var GATEWAY_VERSION = "0.1.0";
1048
1092
  function isLoopbackAddress(addr) {
1049
1093
  if (!addr) return false;
1050
1094
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1" || addr.startsWith("127.");
@@ -1101,6 +1145,9 @@ var SentinelGateway = class {
1101
1145
  releaseToken;
1102
1146
  /** Item D (F-8): disposition for unknown (non-MCP, unrecognized) tool names. */
1103
1147
  unknownTools;
1148
+ /** Daemon-staleness build identity (content hash of the launched-from entry),
1149
+ * reported via /health. "unknown" when not supplied by the launcher. */
1150
+ buildId;
1104
1151
  server = null;
1105
1152
  running = false;
1106
1153
  signalHandlersInstalled = false;
@@ -1116,6 +1163,7 @@ var SentinelGateway = class {
1116
1163
  this.home = options.home ?? "";
1117
1164
  this.releaseToken = options.releaseToken ?? null;
1118
1165
  this.unknownTools = options.unknownTools ?? "warn";
1166
+ this.buildId = options.buildId ?? "unknown";
1119
1167
  const internal = options;
1120
1168
  if (internal.registry) {
1121
1169
  this.registry = internal.registry;
@@ -1235,7 +1283,11 @@ var SentinelGateway = class {
1235
1283
  const snap = this.telemetry.getSnapshot();
1236
1284
  this.sendJson(res, 200, {
1237
1285
  status: "running",
1238
- version: GATEWAY_VERSION,
1286
+ // Daemon-staleness build identity (content hash of the launched-from
1287
+ // entry). Replaces the former hardcoded GATEWAY_VERSION, which had
1288
+ // drifted from package.json and would have mismatched spuriously.
1289
+ // session-start compares this to the current on-disk entry hash.
1290
+ buildId: this.buildId,
1239
1291
  uptime: snap.uptime_seconds
1240
1292
  });
1241
1293
  return;
@@ -1401,6 +1453,9 @@ var SentinelGateway = class {
1401
1453
  return { ok: false, finding };
1402
1454
  }
1403
1455
  const { root, agentId, workspaceRole } = resolved.resolution;
1456
+ for (const w of resolved.resolution.warnings) {
1457
+ console.warn(`[SENTINEL GATEWAY] workspace-resolution warning (${agentId}): ${w}`);
1458
+ }
1404
1459
  const ceiling = this.operatorCeiling;
1405
1460
  if (!ceiling) {
1406
1461
  const check = {
@@ -1427,7 +1482,24 @@ var SentinelGateway = class {
1427
1482
  name: agentId
1428
1483
  });
1429
1484
  this.sentinel.touchAgent(agentId);
1430
- return { ok: true, agentId };
1485
+ return { ok: true, agentId, effectiveRole: merged.role };
1486
+ }
1487
+ /**
1488
+ * Forbidden-pattern list for ONE request: the gateway's construction-time
1489
+ * list unioned with the request's merged-role patterns (operator-ceiling ∩
1490
+ * workspace yaml). Without this, a workspace's custom forbid targets were
1491
+ * enforced by the role validator inside wrap() but never reached the
1492
+ * gateway's own bash-L1 / Grep layers, which checked only the static list.
1493
+ * Normalization is idempotent (the chokepoint globstar-prepend).
1494
+ */
1495
+ patternsForRequest(effectiveRole2) {
1496
+ if (!effectiveRole2?.forbiddenTargetPatterns?.length) return this.forbiddenPatterns;
1497
+ return [
1498
+ .../* @__PURE__ */ new Set([
1499
+ ...this.forbiddenPatterns,
1500
+ ...effectiveRole2.forbiddenTargetPatterns.map(normalizeForbiddenPattern)
1501
+ ])
1502
+ ];
1431
1503
  }
1432
1504
  async handlePreToolUse(body, res, translator) {
1433
1505
  let payload;
@@ -1443,6 +1515,7 @@ var SentinelGateway = class {
1443
1515
  return;
1444
1516
  }
1445
1517
  let routingId = this.agentId;
1518
+ let requestForbiddenPatterns = this.forbiddenPatterns;
1446
1519
  if (this.workspaceIsolation) {
1447
1520
  const routed = await this.resolveBPathRouting(
1448
1521
  event.metadata?.cwd,
@@ -1460,6 +1533,7 @@ var SentinelGateway = class {
1460
1533
  }
1461
1534
  routingId = routed.agentId;
1462
1535
  event.agentId = routingId;
1536
+ requestForbiddenPatterns = this.patternsForRequest(routed.effectiveRole);
1463
1537
  }
1464
1538
  if (event.metadata?._unknownTool === "true") {
1465
1539
  const unknownName = event.metadata.ccToolName ?? event.primaryTarget;
@@ -1532,7 +1606,7 @@ var SentinelGateway = class {
1532
1606
  let matchedPath = null;
1533
1607
  let matchedPattern = null;
1534
1608
  for (const tokenPath of allTokenPaths) {
1535
- for (const pattern of this.forbiddenPatterns) {
1609
+ for (const pattern of requestForbiddenPatterns) {
1536
1610
  if (matchGlobInsensitive(pattern, tokenPath)) {
1537
1611
  matchedPath = tokenPath;
1538
1612
  matchedPattern = pattern;
@@ -1548,6 +1622,7 @@ var SentinelGateway = class {
1548
1622
  unparseable: anyUnparseable,
1549
1623
  hasDangerousConstruct: anyDangerousConstruct
1550
1624
  });
1625
+ const targetKey = matchedPath ?? `l2:${[...new Set(allL2Hits)].sort().join(",")}`;
1551
1626
  const finding = {
1552
1627
  severity: "HIGH",
1553
1628
  kind: "actionable",
@@ -1565,7 +1640,8 @@ var SentinelGateway = class {
1565
1640
  timestamp: event.timestamp,
1566
1641
  decision: "deny",
1567
1642
  mentionOnly,
1568
- dedupKey: event.primaryTarget
1643
+ dedupKey: event.primaryTarget,
1644
+ targetKey
1569
1645
  };
1570
1646
  await this.sentinel.handleGatewayDeny(routingId, finding);
1571
1647
  this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
@@ -1592,7 +1668,9 @@ var SentinelGateway = class {
1592
1668
  decision: "deny",
1593
1669
  // A resolved path-glob hit is a file target — never a mention.
1594
1670
  mentionOnly: false,
1595
- dedupKey: event.primaryTarget
1671
+ dedupKey: event.primaryTarget,
1672
+ // F-5a: the resolved forbidden path IS the target identity here.
1673
+ targetKey: matchedPath
1596
1674
  };
1597
1675
  await this.sentinel.handleGatewayDeny(routingId, finding);
1598
1676
  this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
@@ -1710,7 +1788,7 @@ var SentinelGateway = class {
1710
1788
  if (ccToolName === "Grep") {
1711
1789
  const toolInput = parsedPayload.tool_input ?? {};
1712
1790
  const searchPath = typeof toolInput.path === "string" && toolInput.path.length > 0 ? toolInput.path : parsedPayload.cwd ?? ".";
1713
- const pathCheck = isPathItselfForbidden(searchPath, this.forbiddenPatterns);
1791
+ const pathCheck = isPathItselfForbidden(searchPath, requestForbiddenPatterns);
1714
1792
  if (pathCheck.forbidden) {
1715
1793
  const finding2 = {
1716
1794
  severity: "HIGH",
@@ -1735,7 +1813,7 @@ var SentinelGateway = class {
1735
1813
  this.sendJson(res, 200, response);
1736
1814
  return;
1737
1815
  }
1738
- const exclusions = buildGrepExclusions(this.forbiddenPatterns);
1816
+ const exclusions = buildGrepExclusions(requestForbiddenPatterns);
1739
1817
  const updatedInput = buildModifiedGrepInput(toolInput, exclusions);
1740
1818
  const finding = {
1741
1819
  severity: "MEDIUM",
@@ -1758,7 +1836,7 @@ var SentinelGateway = class {
1758
1836
  await this.sentinel.logFinding(routingId, finding);
1759
1837
  this.telemetry.recordToolCall(event.action, "pre", "allowed", 0);
1760
1838
  const ccTranslator = translator;
1761
- const excludedPatterns = this.forbiddenPatterns.join(", ");
1839
+ const excludedPatterns = requestForbiddenPatterns.join(", ");
1762
1840
  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.`;
1763
1841
  if (typeof ccTranslator.formatPreToolUseModifyResponse === "function") {
1764
1842
  const response = ccTranslator.formatPreToolUseModifyResponse({
@@ -2022,13 +2100,14 @@ var SentinelGateway = class {
2022
2100
  };
2023
2101
  async function runGatewayDaemon({
2024
2102
  policyPath,
2025
- port = DEFAULT_PORT
2103
+ port = DEFAULT_PORT,
2104
+ buildId
2026
2105
  }) {
2027
- const { Sentinel: SentinelClass } = await import("./Sentinel-XMSJE4DZ.js");
2106
+ const { Sentinel: SentinelClass } = await import("./Sentinel-4QKPFHTI.js");
2028
2107
  const { writePidFile, writeReleaseToken } = await import("./pidManager-DOGVN6ZT.js");
2029
2108
  const { homedir } = await import("os");
2030
2109
  const { randomBytes } = await import("crypto");
2031
- const { loadPolicy: loadPolicy2, policyToRole: policyToRole2, policyToConfig: policyToConfig2 } = await import("./policyLoader-KZL2U4M2.js");
2110
+ const { loadPolicy: loadPolicy2, policyToRole: policyToRole2, policyToConfig: policyToConfig2 } = await import("./policyLoader-XX6BQXNB.js");
2032
2111
  const sentinel = await SentinelClass.fromPolicy(policyPath);
2033
2112
  const baseline = await sentinel.computeBaseline("claude-code");
2034
2113
  sentinel.setBaseline("claude-code", baseline);
@@ -2046,7 +2125,12 @@ async function runGatewayDaemon({
2046
2125
  home: homedir(),
2047
2126
  releaseToken,
2048
2127
  unknownTools: operatorConfig.enforcement?.unknownTools,
2049
- allowUnknownTools: operatorConfig.enforcement?.allowUnknownTools
2128
+ allowUnknownTools: operatorConfig.enforcement?.allowUnknownTools,
2129
+ buildId,
2130
+ // The operator policy's custom forbid targets must reach the gateway's own
2131
+ // bash-L1/Grep layers (defaults-as-floor union, same chokepoint as role
2132
+ // construction) — previously the gateway saw only the built-in defaults.
2133
+ forbiddenPatterns: unionWithDefaultForbiddenPatterns(operatorCeiling.forbiddenTargetPatterns)
2050
2134
  });
2051
2135
  await gateway.start();
2052
2136
  const home = homedir();
@@ -2059,4 +2143,4 @@ export {
2059
2143
  SentinelGateway,
2060
2144
  runGatewayDaemon
2061
2145
  };
2062
- //# sourceMappingURL=chunk-L4R3LPJS.js.map
2146
+ //# sourceMappingURL=chunk-HRI2Y326.js.map