@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.
@@ -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-JTR2E7RD.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];
@@ -755,29 +786,56 @@ function mcpVerbIsMutating(toolName) {
755
786
  const tokens = seg.split(/[_-]/).filter((t) => t.length > 0);
756
787
  return tokens.some((t) => MCP_MUTATING_VERBS.some((v) => t.startsWith(v)));
757
788
  }
789
+ var MCP_MAX_DEPTH = 6;
790
+ var MCP_MAX_NODES = 1e3;
791
+ function collectMcpStrings(value, depth, state) {
792
+ if (state.nodes >= MCP_MAX_NODES) {
793
+ state.truncated = true;
794
+ return;
795
+ }
796
+ state.nodes++;
797
+ if (typeof value === "string") {
798
+ if (value.length > 0) state.strings.push(value);
799
+ return;
800
+ }
801
+ if (value === null || typeof value !== "object") return;
802
+ if (depth >= MCP_MAX_DEPTH) {
803
+ state.truncated = true;
804
+ return;
805
+ }
806
+ const children = Array.isArray(value) ? value : Object.values(value);
807
+ for (const child of children) collectMcpStrings(child, depth + 1, state);
808
+ }
758
809
  function extractMcpTargets(toolName, toolInput) {
759
810
  const targets = [toolName];
760
811
  const keys = Object.keys(toolInput);
761
- let extractedStrings = 0;
762
- for (const key of keys) {
763
- const value = toolInput[key];
764
- if (typeof value !== "string" || value.length === 0) continue;
765
- extractedStrings++;
812
+ const state = { strings: [], nodes: 0, truncated: false };
813
+ for (const key of keys) collectMcpStrings(toolInput[key], 1, state);
814
+ for (const value of state.strings) {
766
815
  targets.push(value);
767
816
  const resolved = runResolverPipeline(value);
768
- for (const r of resolved) {
769
- targets.push(r);
770
- }
817
+ for (const r of resolved) targets.push(r);
771
818
  }
772
819
  return {
773
820
  targets,
774
- unextractable: keys.length > 0 && extractedStrings === 0,
821
+ unextractable: keys.length > 0 && (state.strings.length === 0 || state.truncated),
775
822
  mutating: mcpVerbIsMutating(toolName)
776
823
  };
777
824
  }
778
825
  var UNKNOWN_TOOL_REASON = "tool schema unknown \u2014 sensitivity scoring and forbidden target patterns cannot evaluate this event";
779
826
  var ClaudeCodeTranslator = class {
780
827
  agentType = "claude-code";
828
+ /**
829
+ * Sprint 26 Gate-A Item D (F-8) — operator allowlist escape hatch. Names
830
+ * listed in the launch policy's enforcement.allowUnknownTools translate as
831
+ * KNOWN tool_invocation (no _unknownTool marker), so a new cc native tool
832
+ * can be unbricked with a one-line policy edit + daemon restart instead of
833
+ * waiting on a Sentinel release that refreshes TOOL_MAP.
834
+ */
835
+ allowUnknownTools;
836
+ constructor(options) {
837
+ this.allowUnknownTools = new Set(options?.allowUnknownTools ?? []);
838
+ }
781
839
  translatePreToolUse(payload) {
782
840
  const p = payload;
783
841
  if (!p || typeof p !== "object" || !p.tool_name) return null;
@@ -798,7 +856,7 @@ var ClaudeCodeTranslator = class {
798
856
  metadata._policyEnforcementBypassed = "true";
799
857
  metadata._policyBypassReason = UNKNOWN_TOOL_REASON;
800
858
  console.warn(
801
- `[SENTINEL] Unknown Claude Code tool "${toolName}" \u2014 allowing with WARN. ${UNKNOWN_TOOL_REASON}`
859
+ `[SENTINEL] Unknown Claude Code tool "${toolName}" \u2014 flagged for gateway disposition (enforcement.unknownTools; default warn). ${UNKNOWN_TOOL_REASON}`
802
860
  );
803
861
  }
804
862
  if (isMcp) {
@@ -956,6 +1014,14 @@ var ClaudeCodeTranslator = class {
956
1014
  mcpMutating: mcp.mutating
957
1015
  };
958
1016
  }
1017
+ if (this.allowUnknownTools.has(toolName)) {
1018
+ return {
1019
+ action: "tool_invocation",
1020
+ targets: [toolName],
1021
+ isUnknown: false,
1022
+ isMcp: false
1023
+ };
1024
+ }
959
1025
  return {
960
1026
  action: "tool_invocation",
961
1027
  targets: [toolName],
@@ -994,7 +1060,6 @@ function buildModifiedGrepInput(originalInput, exclusions) {
994
1060
  import { timingSafeEqual } from "crypto";
995
1061
  var DEFAULT_PORT = 7847;
996
1062
  var MAX_BODY_SIZE = 1024 * 1024;
997
- var GATEWAY_VERSION = "0.1.0";
998
1063
  function isLoopbackAddress(addr) {
999
1064
  if (!addr) return false;
1000
1065
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1" || addr.startsWith("127.");
@@ -1049,6 +1114,11 @@ var SentinelGateway = class {
1049
1114
  operatorCeiling;
1050
1115
  home;
1051
1116
  releaseToken;
1117
+ /** Item D (F-8): disposition for unknown (non-MCP, unrecognized) tool names. */
1118
+ unknownTools;
1119
+ /** Daemon-staleness build identity (content hash of the launched-from entry),
1120
+ * reported via /health. "unknown" when not supplied by the launcher. */
1121
+ buildId;
1052
1122
  server = null;
1053
1123
  running = false;
1054
1124
  signalHandlersInstalled = false;
@@ -1063,6 +1133,8 @@ var SentinelGateway = class {
1063
1133
  this.operatorCeiling = options.operatorCeiling ?? null;
1064
1134
  this.home = options.home ?? "";
1065
1135
  this.releaseToken = options.releaseToken ?? null;
1136
+ this.unknownTools = options.unknownTools ?? "warn";
1137
+ this.buildId = options.buildId ?? "unknown";
1066
1138
  const internal = options;
1067
1139
  if (internal.registry) {
1068
1140
  this.registry = internal.registry;
@@ -1071,7 +1143,9 @@ var SentinelGateway = class {
1071
1143
  this.registry.register(internal.translator);
1072
1144
  } else {
1073
1145
  this.registry = new TranslatorRegistry();
1074
- this.registry.register(new ClaudeCodeTranslator());
1146
+ this.registry.register(
1147
+ new ClaudeCodeTranslator({ allowUnknownTools: options.allowUnknownTools })
1148
+ );
1075
1149
  }
1076
1150
  }
1077
1151
  get port() {
@@ -1180,7 +1254,11 @@ var SentinelGateway = class {
1180
1254
  const snap = this.telemetry.getSnapshot();
1181
1255
  this.sendJson(res, 200, {
1182
1256
  status: "running",
1183
- version: GATEWAY_VERSION,
1257
+ // Daemon-staleness build identity (content hash of the launched-from
1258
+ // entry). Replaces the former hardcoded GATEWAY_VERSION, which had
1259
+ // drifted from package.json and would have mismatched spuriously.
1260
+ // session-start compares this to the current on-disk entry hash.
1261
+ buildId: this.buildId,
1184
1262
  uptime: snap.uptime_seconds
1185
1263
  });
1186
1264
  return;
@@ -1406,35 +1484,26 @@ var SentinelGateway = class {
1406
1484
  routingId = routed.agentId;
1407
1485
  event.agentId = routingId;
1408
1486
  }
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) {
1487
+ if (event.metadata?._unknownTool === "true") {
1488
+ const unknownName = event.metadata.ccToolName ?? event.primaryTarget;
1489
+ if (this.unknownTools === "deny") {
1422
1490
  const finding = {
1423
1491
  severity: "HIGH",
1424
1492
  kind: "actionable",
1425
- type: "unauthorized_target",
1493
+ type: "unknown_tool",
1426
1494
  agentId: event.agentId,
1427
1495
  agentName: event.agentName,
1428
- description: `Bash command references forbidden basename: ${allL2Hits.join(", ")}`,
1496
+ 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
1497
  evidence: {
1430
1498
  action: event.action,
1431
- target: allL2Hits[0],
1499
+ target: unknownName,
1432
1500
  timestamp: event.timestamp,
1433
- baselineComparison: "credentials_exfil_attempt"
1501
+ baselineComparison: "unknown_tool_denied"
1434
1502
  },
1435
- recommendation: "Review the command for credential access or exfiltration. If legitimate, use existing policy exception mechanisms.",
1503
+ 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
1504
  timestamp: event.timestamp,
1437
- decision: "deny"
1505
+ decision: "deny",
1506
+ dedupKey: unknownName
1438
1507
  };
1439
1508
  await this.sentinel.handleGatewayDeny(routingId, finding);
1440
1509
  this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
@@ -1442,6 +1511,38 @@ var SentinelGateway = class {
1442
1511
  this.sendJson(res, 200, response);
1443
1512
  return;
1444
1513
  }
1514
+ const warnFinding = {
1515
+ severity: "LOW",
1516
+ kind: "informational",
1517
+ type: "unknown_tool",
1518
+ agentId: event.agentId,
1519
+ agentName: event.agentName,
1520
+ description: `Unknown tool "${unknownName}" allowed (enforcement.unknownTools: warn) \u2014 not in Sentinel's recognized tool set; policy checks could not evaluate it.`,
1521
+ evidence: {
1522
+ action: event.action,
1523
+ target: unknownName,
1524
+ timestamp: event.timestamp,
1525
+ baselineComparison: "unknown_tool_allowed_warn"
1526
+ },
1527
+ recommendation: `Add "${unknownName}" to enforcement.allowUnknownTools (or update @tuent/sentinel) to clear this warning, or switch enforcement.unknownTools to deny.`,
1528
+ timestamp: event.timestamp,
1529
+ decision: "allow",
1530
+ dedupKey: unknownName
1531
+ };
1532
+ await this.sentinel.logFinding(routingId, warnFinding);
1533
+ }
1534
+ let suppressForbiddenBasename = false;
1535
+ if (event.action === "command_exec" && event.targets && event.targets.length > 0) {
1536
+ const literalCommand = event.targets[0] ?? "";
1537
+ const decodedImplicated = event.targets.slice(1).some((t) => scanBashCommand(t, FORBIDDEN_BASENAMES).matched);
1538
+ suppressForbiddenBasename = isPositionallySafeMention(literalCommand) && !decodedImplicated;
1539
+ }
1540
+ if (event.action === "command_exec" && event.targets && event.targets.length > 0 && !suppressForbiddenBasename) {
1541
+ const allL2Hits = [];
1542
+ for (const scanTarget of event.targets) {
1543
+ const scan = scanBashCommand(scanTarget, FORBIDDEN_BASENAMES);
1544
+ if (scan.matched) allL2Hits.push(...scan.hits);
1545
+ }
1445
1546
  const allTokenPaths = [];
1446
1547
  let anyUnparseable = false;
1447
1548
  let anyDangerousConstruct = false;
@@ -1463,6 +1564,40 @@ var SentinelGateway = class {
1463
1564
  }
1464
1565
  if (matchedPath) break;
1465
1566
  }
1567
+ if (allL2Hits.length > 0) {
1568
+ const { mentionOnly } = classifyDeny(event.targets[0] ?? "", {
1569
+ l2Hits: allL2Hits,
1570
+ hasL1Hit: matchedPath !== null,
1571
+ unparseable: anyUnparseable,
1572
+ hasDangerousConstruct: anyDangerousConstruct
1573
+ });
1574
+ const targetKey = matchedPath ?? `l2:${[...new Set(allL2Hits)].sort().join(",")}`;
1575
+ const finding = {
1576
+ severity: "HIGH",
1577
+ kind: "actionable",
1578
+ type: "unauthorized_target",
1579
+ agentId: event.agentId,
1580
+ agentName: event.agentName,
1581
+ description: `Bash command references forbidden basename: ${allL2Hits.join(", ")}`,
1582
+ evidence: {
1583
+ action: event.action,
1584
+ target: allL2Hits[0],
1585
+ timestamp: event.timestamp,
1586
+ baselineComparison: "credentials_exfil_attempt"
1587
+ },
1588
+ recommendation: "Review the command for credential access or exfiltration. If legitimate, use existing policy exception mechanisms.",
1589
+ timestamp: event.timestamp,
1590
+ decision: "deny",
1591
+ mentionOnly,
1592
+ dedupKey: event.primaryTarget,
1593
+ targetKey
1594
+ };
1595
+ await this.sentinel.handleGatewayDeny(routingId, finding);
1596
+ this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
1597
+ const response = translator.formatPreToolUseResponse({ blocked: true, finding });
1598
+ this.sendJson(res, 200, response);
1599
+ return;
1600
+ }
1466
1601
  if (matchedPath) {
1467
1602
  const finding = {
1468
1603
  severity: "HIGH",
@@ -1479,7 +1614,12 @@ var SentinelGateway = class {
1479
1614
  },
1480
1615
  recommendation: "Review the command for credential or sensitive file access. If legitimate, use existing policy exception mechanisms.",
1481
1616
  timestamp: event.timestamp,
1482
- decision: "deny"
1617
+ decision: "deny",
1618
+ // A resolved path-glob hit is a file target — never a mention.
1619
+ mentionOnly: false,
1620
+ dedupKey: event.primaryTarget,
1621
+ // F-5a: the resolved forbidden path IS the target identity here.
1622
+ targetKey: matchedPath
1483
1623
  };
1484
1624
  await this.sentinel.handleGatewayDeny(routingId, finding);
1485
1625
  this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
@@ -1909,17 +2049,20 @@ var SentinelGateway = class {
1909
2049
  };
1910
2050
  async function runGatewayDaemon({
1911
2051
  policyPath,
1912
- port = DEFAULT_PORT
2052
+ port = DEFAULT_PORT,
2053
+ buildId
1913
2054
  }) {
1914
- const { Sentinel: SentinelClass } = await import("./Sentinel-QHMQ67W3.js");
2055
+ const { Sentinel: SentinelClass } = await import("./Sentinel-5CQ6HKXS.js");
1915
2056
  const { writePidFile, writeReleaseToken } = await import("./pidManager-DOGVN6ZT.js");
1916
2057
  const { homedir } = await import("os");
1917
2058
  const { randomBytes } = await import("crypto");
1918
- const { loadPolicy: loadPolicy2, policyToRole: policyToRole2 } = await import("./policyLoader-6KR5VFVV.js");
2059
+ const { loadPolicy: loadPolicy2, policyToRole: policyToRole2, policyToConfig: policyToConfig2 } = await import("./policyLoader-KZL2U4M2.js");
1919
2060
  const sentinel = await SentinelClass.fromPolicy(policyPath);
1920
2061
  const baseline = await sentinel.computeBaseline("claude-code");
1921
2062
  sentinel.setBaseline("claude-code", baseline);
1922
- const operatorCeiling = policyToRole2(await loadPolicy2(policyPath));
2063
+ const operatorPolicy = await loadPolicy2(policyPath);
2064
+ const operatorCeiling = policyToRole2(operatorPolicy);
2065
+ const operatorConfig = policyToConfig2(operatorPolicy);
1923
2066
  const releaseToken = randomBytes(32).toString("hex");
1924
2067
  const gateway = new SentinelGateway({
1925
2068
  port,
@@ -1929,7 +2072,10 @@ async function runGatewayDaemon({
1929
2072
  workspaceIsolation: process.env.SENTINEL_WORKSPACE_ISOLATION !== "0",
1930
2073
  operatorCeiling,
1931
2074
  home: homedir(),
1932
- releaseToken
2075
+ releaseToken,
2076
+ unknownTools: operatorConfig.enforcement?.unknownTools,
2077
+ allowUnknownTools: operatorConfig.enforcement?.allowUnknownTools,
2078
+ buildId
1933
2079
  });
1934
2080
  await gateway.start();
1935
2081
  const home = homedir();
@@ -1942,4 +2088,4 @@ export {
1942
2088
  SentinelGateway,
1943
2089
  runGatewayDaemon
1944
2090
  };
1945
- //# sourceMappingURL=chunk-IYC5E7RL.js.map
2091
+ //# sourceMappingURL=chunk-G74MMDKA.js.map