@tuent/sentinel 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,48 +1,30 @@
1
+ import {
2
+ deriveAgentId
3
+ } from "./chunk-B5QKJHSV.js";
1
4
  import {
2
5
  discoverPolicy
3
6
  } from "./chunk-FMZWHT4M.js";
4
7
  import {
5
8
  DEFAULT_FORBIDDEN_PATTERNS,
9
+ FORBIDDEN_BASENAMES,
10
+ classifyDeny,
11
+ isPositionallySafeMention,
6
12
  matchGlobInsensitive,
7
- normalizeForbiddenPattern
8
- } from "./chunk-6MHWJATS.js";
13
+ normalizeForbiddenPattern,
14
+ scanBashCommand,
15
+ scanContentForForbiddenBasenames,
16
+ scanGlobPattern,
17
+ tokenizePaths
18
+ } from "./chunk-QIYQWOLO.js";
9
19
  import {
10
20
  loadPolicy,
11
21
  policyToConfig,
12
22
  policyToRole
13
- } from "./chunk-2FFMYSVC.js";
23
+ } from "./chunk-WLIDSTS4.js";
14
24
 
15
25
  // src/gateway/workspaceRouter.ts
16
26
  import { resolve, dirname } from "path";
17
27
 
18
- // src/workspaceIdentity.ts
19
- var AGENT_PREFIX = "claude-code";
20
- function fnv1a32Hex(s) {
21
- let h = 2166136261;
22
- for (let i = 0; i < s.length; i++) {
23
- h ^= s.charCodeAt(i);
24
- h = Math.imul(h, 16777619);
25
- }
26
- return (h >>> 0).toString(16).padStart(8, "0");
27
- }
28
- function lastSegment(path) {
29
- const parts = path.split("/").filter(Boolean);
30
- return parts.length > 0 ? parts[parts.length - 1] : "";
31
- }
32
- function slugify(s) {
33
- return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
34
- }
35
- function normalizeRoot(root) {
36
- if (root === "" || root === "/") return root;
37
- return root.replace(/\/+$/, "") || "/";
38
- }
39
- function deriveAgentId(workspaceRoot) {
40
- const root = normalizeRoot(workspaceRoot);
41
- const slug = slugify(lastSegment(root)) || "root";
42
- const hash = fnv1a32Hex(root);
43
- return `${AGENT_PREFIX}@${slug}-${hash}`;
44
- }
45
-
46
28
  // src/mergeRoles.ts
47
29
  function isWithinActiveHours(hour, range) {
48
30
  const [startHour, endHour] = range;
@@ -363,393 +345,6 @@ var TranslatorRegistry = class {
363
345
  }
364
346
  };
365
347
 
366
- // src/gateway/bashScanner.ts
367
- import { parse as shellParse } from "shell-quote";
368
- import { realpathSync } from "fs";
369
- import { dirname as dirname2, join, basename, normalize } from "path";
370
- var BRACE_PATTERN_RE = /\{[^}]*,[^}]*\}/;
371
- var MAX_BRACE_EXPANSION = 64;
372
- function fnmatchBasename(pattern, candidate) {
373
- if (pattern.length !== candidate.length) return false;
374
- for (let i = 0; i < pattern.length; i++) {
375
- const p = pattern[i].toLowerCase();
376
- const c = candidate[i].toLowerCase();
377
- if (p === "?") continue;
378
- if (p !== c) return false;
379
- }
380
- return true;
381
- }
382
- function bracketTokenMatchesForbidden(token, forbiddenBasenames) {
383
- const literals = [];
384
- let current = "";
385
- let inBracket = false;
386
- for (let i = 0; i < token.length; i++) {
387
- if (token[i] === "[" && !inBracket) {
388
- if (current) literals.push(current);
389
- current = "";
390
- inBracket = true;
391
- } else if (token[i] === "]" && inBracket) {
392
- inBracket = false;
393
- } else if (!inBracket) {
394
- current += token[i];
395
- }
396
- }
397
- if (current) literals.push(current);
398
- for (const forbidden of forbiddenBasenames) {
399
- const fl = forbidden.toLowerCase();
400
- for (const lit of literals) {
401
- if (lit.length === 0) continue;
402
- if (fl.includes(lit.toLowerCase())) return forbidden;
403
- }
404
- }
405
- return null;
406
- }
407
- function resolveBraceExpansion(token) {
408
- const match = token.match(/^(.*?)\{([^}]*,[^}]*)\}(.*)$/);
409
- if (!match) return null;
410
- const [, prefix, alternatives, suffix] = match;
411
- const parts = alternatives.split(",");
412
- if (parts.length > MAX_BRACE_EXPANSION) return null;
413
- return parts.map((p) => prefix + p + suffix);
414
- }
415
- function wildcardDispatch(token, forbiddenBasenames, metadataField) {
416
- const result = {
417
- resolvedBasenames: [],
418
- unparseable: false,
419
- metadata: {}
420
- };
421
- if (token === "*" || token === "**" || token === "?") {
422
- return result;
423
- }
424
- if (BRACE_PATTERN_RE.test(token)) {
425
- const expanded = resolveBraceExpansion(token);
426
- if (expanded === null) {
427
- result.unparseable = true;
428
- return result;
429
- }
430
- for (const alt of expanded) {
431
- const hasWildcard = /[?*[]/.test(alt);
432
- if (hasWildcard) {
433
- const sub = wildcardDispatch(alt, forbiddenBasenames, metadataField);
434
- if (sub.resolvedBasenames.length > 0) {
435
- result.resolvedBasenames.push(...sub.resolvedBasenames);
436
- result.metadata["resolvedFromBrace"] = token;
437
- Object.assign(result.metadata, sub.metadata);
438
- }
439
- if (sub.unparseable) result.unparseable = true;
440
- } else {
441
- const altLower = alt.toLowerCase();
442
- for (const forbidden of forbiddenBasenames) {
443
- if (altLower === forbidden.toLowerCase()) {
444
- result.resolvedBasenames.push(forbidden);
445
- result.metadata["resolvedFromBrace"] = token;
446
- break;
447
- }
448
- }
449
- }
450
- }
451
- return result;
452
- }
453
- const hasStar = token.includes("*");
454
- const hasQuestion = token.includes("?");
455
- const hasBracket = token.includes("[");
456
- if (hasBracket) {
457
- const matched = bracketTokenMatchesForbidden(token, forbiddenBasenames);
458
- if (matched) {
459
- result.resolvedBasenames.push(matched);
460
- result.metadata["resolvedFromBracket"] = token;
461
- } else {
462
- result.unparseable = true;
463
- }
464
- return result;
465
- }
466
- if (hasStar && !hasQuestion) {
467
- const matched = starLiteralSubstringCheck(token, forbiddenBasenames);
468
- if (matched) {
469
- result.resolvedBasenames.push(matched);
470
- result.metadata[metadataField] = token;
471
- }
472
- return result;
473
- }
474
- if (hasQuestion && !hasStar) {
475
- for (const forbidden of forbiddenBasenames) {
476
- if (fnmatchBasename(token, forbidden)) {
477
- result.resolvedBasenames.push(forbidden);
478
- result.metadata[metadataField] = token;
479
- break;
480
- }
481
- }
482
- return result;
483
- }
484
- if (hasStar && hasQuestion) {
485
- const starMatch = starLiteralSubstringCheck(token, forbiddenBasenames);
486
- if (starMatch) {
487
- result.resolvedBasenames.push(starMatch);
488
- result.metadata[metadataField] = token;
489
- return result;
490
- }
491
- const segments = token.split("*").filter((s) => s.includes("?"));
492
- for (const seg of segments) {
493
- for (const forbidden of forbiddenBasenames) {
494
- if (fnmatchBasename(seg, forbidden)) {
495
- result.resolvedBasenames.push(forbidden);
496
- result.metadata[metadataField] = token;
497
- return result;
498
- }
499
- }
500
- }
501
- return result;
502
- }
503
- return result;
504
- }
505
- function starLiteralSubstringCheck(token, forbiddenBasenames) {
506
- const literals = token.split("*").filter((s) => s.length > 0);
507
- for (const forbidden of forbiddenBasenames) {
508
- const fl = forbidden.toLowerCase();
509
- for (const lit of literals) {
510
- if (fl.includes(lit.toLowerCase())) return forbidden;
511
- }
512
- }
513
- return null;
514
- }
515
- function shouldDispatchWildcard(token) {
516
- const hasMetachar = /[?*[{]/.test(token);
517
- if (!hasMetachar) return false;
518
- if (isPathShaped(token)) return true;
519
- if (token.includes("[")) return true;
520
- if (BRACE_PATTERN_RE.test(token)) return true;
521
- return false;
522
- }
523
- var SENSITIVE_BASENAME_RE = /(?:\.env|\.ssh|secrets|credentials|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.pem|\.key)/i;
524
- var DANGEROUS_COMMAND_TOKENS = /* @__PURE__ */ new Set(["eval"]);
525
- var COMMAND_SUBSTITUTION_RE = /\$\(|`/;
526
- var DANGEROUS_RAW_RE = /<<<|<\(|>\(/;
527
- function isVarMarker(token) {
528
- return typeof token === "object" && token !== null && "__sentinel_var" in token && typeof token.__sentinel_var === "string";
529
- }
530
- function tokenizePaths(command) {
531
- const result = {
532
- paths: [],
533
- unparseable: false,
534
- hasDangerousConstruct: false
535
- };
536
- if (DANGEROUS_RAW_RE.test(command)) {
537
- result.hasDangerousConstruct = true;
538
- }
539
- if (COMMAND_SUBSTITUTION_RE.test(command)) {
540
- result.hasDangerousConstruct = true;
541
- }
542
- let tokens;
543
- try {
544
- tokens = shellParse(command, (key) => ({ __sentinel_var: key }));
545
- } catch {
546
- result.unparseable = true;
547
- return result;
548
- }
549
- if (!Array.isArray(tokens)) {
550
- result.unparseable = true;
551
- return result;
552
- }
553
- let prevToken = null;
554
- for (let i = 0; i < tokens.length; i++) {
555
- const token = tokens[i];
556
- if (isVarMarker(token)) {
557
- const nextToken = tokens[i + 1];
558
- const nextIsPathRelevant = nextToken === void 0 || // end of tokens — var is complete argument
559
- typeof nextToken === "object" && nextToken !== null && "op" in nextToken || // followed by operator — var is complete argument
560
- typeof nextToken === "string" && isPathShaped(nextToken);
561
- const prevIsPathRelevant = prevToken !== null && isPathShaped(prevToken);
562
- if (nextIsPathRelevant || prevIsPathRelevant) {
563
- result.unparseable = true;
564
- }
565
- prevToken = null;
566
- continue;
567
- }
568
- if (typeof token === "object" && token !== null) {
569
- if ("pattern" in token) {
570
- const globPattern = token.pattern;
571
- const lastSlash = globPattern.lastIndexOf("/");
572
- const dispatchTarget = lastSlash >= 0 ? globPattern.slice(lastSlash + 1) : globPattern;
573
- const dispatch = wildcardDispatch(dispatchTarget, FORBIDDEN_BASENAMES, "resolvedFromGlob");
574
- if (dispatch.resolvedBasenames.length > 0) {
575
- for (const resolved of dispatch.resolvedBasenames) {
576
- result.paths.push(resolved);
577
- }
578
- }
579
- if (dispatch.unparseable) {
580
- result.unparseable = true;
581
- }
582
- if (SENSITIVE_BASENAME_RE.test(globPattern)) {
583
- result.unparseable = true;
584
- }
585
- prevToken = null;
586
- continue;
587
- }
588
- if ("op" in token) {
589
- if (token.op === "<(") {
590
- result.hasDangerousConstruct = true;
591
- }
592
- prevToken = null;
593
- continue;
594
- }
595
- prevToken = null;
596
- continue;
597
- }
598
- if (typeof token !== "string") {
599
- prevToken = null;
600
- continue;
601
- }
602
- if (DANGEROUS_COMMAND_TOKENS.has(token.toLowerCase())) {
603
- result.hasDangerousConstruct = true;
604
- }
605
- if ((prevToken === "sh" || prevToken === "bash" || prevToken === "/bin/sh" || prevToken === "/bin/bash") && token === "-c") {
606
- result.hasDangerousConstruct = true;
607
- }
608
- if (shouldDispatchWildcard(token)) {
609
- const metaField = "resolvedFromQuotedGlob";
610
- const dispatch = wildcardDispatch(token, FORBIDDEN_BASENAMES, metaField);
611
- if (dispatch.resolvedBasenames.length > 0) {
612
- for (const resolved of dispatch.resolvedBasenames) {
613
- result.paths.push(resolved);
614
- }
615
- }
616
- if (dispatch.unparseable) {
617
- result.unparseable = true;
618
- }
619
- } else if (isPathShaped(token)) {
620
- const resolved = resolvePathToken(token);
621
- result.paths.push(resolved);
622
- }
623
- prevToken = token;
624
- }
625
- return result;
626
- }
627
- function isPathShaped(token) {
628
- if (token.includes("/")) return true;
629
- if (token.startsWith(".")) return true;
630
- if (SENSITIVE_BASENAME_RE.test(token)) return true;
631
- return false;
632
- }
633
- function resolvePathToken(token) {
634
- const normalized = normalize(token);
635
- try {
636
- return realpathSync(normalized);
637
- } catch (err) {
638
- const code = err.code;
639
- if (code === "ENOENT") {
640
- return resolveNonexistentPathToken(normalized);
641
- }
642
- return normalized;
643
- }
644
- }
645
- function resolveNonexistentPathToken(normalizedPath) {
646
- let current = normalizedPath;
647
- let suffix = "";
648
- for (let i = 0; i < 50; i++) {
649
- const parent = dirname2(current);
650
- if (parent === current) {
651
- return normalizedPath;
652
- }
653
- if (parent === ".") {
654
- return normalizedPath;
655
- }
656
- suffix = suffix ? join(basename(current), suffix) : basename(current);
657
- current = parent;
658
- try {
659
- const resolved = realpathSync(current);
660
- if (resolved !== current) {
661
- return join(resolved, suffix);
662
- }
663
- return join(resolved, suffix);
664
- } catch {
665
- continue;
666
- }
667
- }
668
- return normalizedPath;
669
- }
670
- var FORBIDDEN_BASENAMES = [
671
- ".env",
672
- ".ssh",
673
- ".aws",
674
- "secrets",
675
- "credentials",
676
- "id_rsa",
677
- "id_dsa",
678
- "id_ecdsa",
679
- "id_ed25519",
680
- ".pem",
681
- ".key"
682
- ];
683
- function scanBashCommand(command, forbiddenBasenames) {
684
- const basenames = forbiddenBasenames ?? FORBIDDEN_BASENAMES;
685
- const hits = [];
686
- for (const basename2 of basenames) {
687
- const pattern = buildPattern(basename2);
688
- if (pattern.test(command)) {
689
- hits.push(basename2);
690
- }
691
- }
692
- return { matched: hits.length > 0, hits };
693
- }
694
- function buildPattern(basename2) {
695
- const escaped = escapeRegex(basename2);
696
- if (basename2.startsWith(".") && basename2.length <= 4 && !isAlphaAfterDot(basename2)) {
697
- return new RegExp(`\\w${escaped}(?=$|[\\s;&|<>()'"=\\/])`, "i");
698
- }
699
- if (basename2.startsWith(".")) {
700
- return new RegExp(`(?:^|[\\s;&|<>()\\/'"=])${escaped}(?=$|[\\s;&|<>()\\/'"=.])`, "i");
701
- }
702
- return new RegExp(`\\b${escaped}\\b`, "i");
703
- }
704
- function scanContentForForbiddenBasenames(content, forbiddenBasenames) {
705
- const hits = [];
706
- for (const basename2 of forbiddenBasenames) {
707
- const pattern = buildContentPattern(basename2);
708
- if (pattern.test(content)) {
709
- hits.push(basename2);
710
- }
711
- }
712
- return { matched: hits.length > 0, hits };
713
- }
714
- function buildContentPattern(basename2) {
715
- const escaped = escapeRegex(basename2);
716
- if (basename2.startsWith(".") && basename2.length <= 4 && !isAlphaAfterDot(basename2)) {
717
- return new RegExp(`\\w${escaped}(?=$|[\\s;&|<>()'"=\\/])`, "i");
718
- }
719
- if (basename2.startsWith(".")) {
720
- return new RegExp(`(?:^|[\\s;&|<>()\\/'"=])${escaped}(?=$|[\\s;&|<>()\\/'"=.])`, "i");
721
- }
722
- return new RegExp(`(?<=[/\\\\]\\.?)${escaped}\\b`, "i");
723
- }
724
- function isAlphaAfterDot(s) {
725
- return /^\.[a-zA-Z]+$/.test(s);
726
- }
727
- function escapeRegex(s) {
728
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
729
- }
730
- function scanGlobPattern(pattern, forbiddenBasenames) {
731
- const basenames = forbiddenBasenames ?? FORBIDDEN_BASENAMES;
732
- const hits = [];
733
- for (const basename2 of basenames) {
734
- const re = buildGlobContextPattern(basename2);
735
- if (re.test(pattern)) {
736
- hits.push(basename2);
737
- }
738
- }
739
- return { matched: hits.length > 0, hits };
740
- }
741
- function buildGlobContextPattern(basename2) {
742
- const escaped = escapeRegex(basename2);
743
- const GLOB_DELIM = String.raw`\s;&|<>()\\/'"=.*?{}\[\]`;
744
- if (basename2.startsWith(".") && basename2.length <= 4 && !isAlphaAfterDot(basename2)) {
745
- return new RegExp(`[\\w*]${escaped}(?=$|[${GLOB_DELIM}])`, "i");
746
- }
747
- if (basename2.startsWith(".")) {
748
- return new RegExp(`(?:^|[${GLOB_DELIM}])${escaped}(?=$|[${GLOB_DELIM}])`, "i");
749
- }
750
- return new RegExp(`\\b${escaped}\\b`, "i");
751
- }
752
-
753
348
  // src/gateway/runtimeConstructionResolvers.ts
754
349
  var MAX_RECURSION_DEPTH = 3;
755
350
  var INTERPRETER_RE = /\b(?:python[23]?|node|ruby|perl|php)\s+(?:-[cer])\s+(.+)/s;
@@ -1052,7 +647,37 @@ var TOOL_MAP = {
1052
647
  WebSearch: { action: "network_request", targetKey: "query" },
1053
648
  Task: { action: "tool_invocation", targetKey: "description" },
1054
649
  Skill: { action: "tool_invocation", targetKey: "skill" },
1055
- 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" }
1056
681
  };
1057
682
  var AGENT_ID = "claude-code";
1058
683
  var AGENT_NAME = "Claude Code";
@@ -1145,7 +770,7 @@ function extractTargets(toolName, toolInput, cwd) {
1145
770
  return extractGrepTargets(toolInput, cwd);
1146
771
  default: {
1147
772
  const mapping = TOOL_MAP[toolName];
1148
- if (!mapping) return [toolName];
773
+ if (!mapping || !mapping.targetKey) return [toolName];
1149
774
  const val = toolInput[mapping.targetKey];
1150
775
  if (typeof val === "string" && val.length > 0) return [val];
1151
776
  return [toolName];
@@ -1184,6 +809,17 @@ function extractMcpTargets(toolName, toolInput) {
1184
809
  var UNKNOWN_TOOL_REASON = "tool schema unknown \u2014 sensitivity scoring and forbidden target patterns cannot evaluate this event";
1185
810
  var ClaudeCodeTranslator = class {
1186
811
  agentType = "claude-code";
812
+ /**
813
+ * Sprint 26 Gate-A Item D (F-8) — operator allowlist escape hatch. Names
814
+ * listed in the launch policy's enforcement.allowUnknownTools translate as
815
+ * KNOWN tool_invocation (no _unknownTool marker), so a new cc native tool
816
+ * can be unbricked with a one-line policy edit + daemon restart instead of
817
+ * waiting on a Sentinel release that refreshes TOOL_MAP.
818
+ */
819
+ allowUnknownTools;
820
+ constructor(options) {
821
+ this.allowUnknownTools = new Set(options?.allowUnknownTools ?? []);
822
+ }
1187
823
  translatePreToolUse(payload) {
1188
824
  const p = payload;
1189
825
  if (!p || typeof p !== "object" || !p.tool_name) return null;
@@ -1204,7 +840,7 @@ var ClaudeCodeTranslator = class {
1204
840
  metadata._policyEnforcementBypassed = "true";
1205
841
  metadata._policyBypassReason = UNKNOWN_TOOL_REASON;
1206
842
  console.warn(
1207
- `[SENTINEL] Unknown Claude Code tool "${toolName}" \u2014 allowing with WARN. ${UNKNOWN_TOOL_REASON}`
843
+ `[SENTINEL] Unknown Claude Code tool "${toolName}" \u2014 flagged for gateway disposition (enforcement.unknownTools; default warn). ${UNKNOWN_TOOL_REASON}`
1208
844
  );
1209
845
  }
1210
846
  if (isMcp) {
@@ -1362,6 +998,14 @@ var ClaudeCodeTranslator = class {
1362
998
  mcpMutating: mcp.mutating
1363
999
  };
1364
1000
  }
1001
+ if (this.allowUnknownTools.has(toolName)) {
1002
+ return {
1003
+ action: "tool_invocation",
1004
+ targets: [toolName],
1005
+ isUnknown: false,
1006
+ isMcp: false
1007
+ };
1008
+ }
1365
1009
  return {
1366
1010
  action: "tool_invocation",
1367
1011
  targets: [toolName],
@@ -1397,9 +1041,23 @@ function buildModifiedGrepInput(originalInput, exclusions) {
1397
1041
  }
1398
1042
 
1399
1043
  // src/gateway/server.ts
1044
+ import { timingSafeEqual } from "crypto";
1400
1045
  var DEFAULT_PORT = 7847;
1401
1046
  var MAX_BODY_SIZE = 1024 * 1024;
1402
1047
  var GATEWAY_VERSION = "0.1.0";
1048
+ function isLoopbackAddress(addr) {
1049
+ if (!addr) return false;
1050
+ return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1" || addr.startsWith("127.");
1051
+ }
1052
+ function constantTimeEqual(a, b) {
1053
+ const ab = Buffer.from(a, "utf-8");
1054
+ const bb = Buffer.from(b, "utf-8");
1055
+ if (ab.length !== bb.length) {
1056
+ timingSafeEqual(bb, bb);
1057
+ return false;
1058
+ }
1059
+ return timingSafeEqual(ab, bb);
1060
+ }
1403
1061
  function parseIntentLine(prompt) {
1404
1062
  if (typeof prompt !== "string") return null;
1405
1063
  const firstNonEmpty = prompt.split("\n").find((line) => line.trim().length > 0);
@@ -1440,6 +1098,9 @@ var SentinelGateway = class {
1440
1098
  workspaceIsolation;
1441
1099
  operatorCeiling;
1442
1100
  home;
1101
+ releaseToken;
1102
+ /** Item D (F-8): disposition for unknown (non-MCP, unrecognized) tool names. */
1103
+ unknownTools;
1443
1104
  server = null;
1444
1105
  running = false;
1445
1106
  signalHandlersInstalled = false;
@@ -1453,6 +1114,8 @@ var SentinelGateway = class {
1453
1114
  this.workspaceIsolation = options.workspaceIsolation ?? process.env.SENTINEL_WORKSPACE_ISOLATION === "1";
1454
1115
  this.operatorCeiling = options.operatorCeiling ?? null;
1455
1116
  this.home = options.home ?? "";
1117
+ this.releaseToken = options.releaseToken ?? null;
1118
+ this.unknownTools = options.unknownTools ?? "warn";
1456
1119
  const internal = options;
1457
1120
  if (internal.registry) {
1458
1121
  this.registry = internal.registry;
@@ -1461,7 +1124,9 @@ var SentinelGateway = class {
1461
1124
  this.registry.register(internal.translator);
1462
1125
  } else {
1463
1126
  this.registry = new TranslatorRegistry();
1464
- this.registry.register(new ClaudeCodeTranslator());
1127
+ this.registry.register(
1128
+ new ClaudeCodeTranslator({ allowUnknownTools: options.allowUnknownTools })
1129
+ );
1465
1130
  }
1466
1131
  }
1467
1132
  get port() {
@@ -1580,6 +1245,10 @@ var SentinelGateway = class {
1580
1245
  return;
1581
1246
  }
1582
1247
  }
1248
+ if (method === "POST" && url === "/api/sentinel/release") {
1249
+ this.handleReleaseRoute(req, res);
1250
+ return;
1251
+ }
1583
1252
  if (method === "POST") {
1584
1253
  const match = url.match(
1585
1254
  /^\/api\/sentinel\/(pre-tool-use|post-tool-use|session-end|user-prompt-submit)\/(.+)$/
@@ -1606,6 +1275,58 @@ var SentinelGateway = class {
1606
1275
  }
1607
1276
  this.sendJson(res, 404, { error: "not found" });
1608
1277
  }
1278
+ /**
1279
+ * Operator release route (Sprint 0.1.1). Loopback-only + token-gated. On a
1280
+ * valid request, calls sentinel.release() on the LIVE instance — flipping the
1281
+ * in-memory mode, writing mode.json, and logging the signed mode_change anchor
1282
+ * in-process (single writer). Never re-reads mode.json or trusts file content.
1283
+ */
1284
+ handleReleaseRoute(req, res) {
1285
+ const remote = req.socket?.remoteAddress;
1286
+ if (!isLoopbackAddress(remote)) {
1287
+ console.warn(`[SENTINEL GATEWAY] /release refused non-loopback origin: ${remote ?? "?"}`);
1288
+ this.sendJson(res, 403, { ok: false, error: "release is loopback-only" });
1289
+ return;
1290
+ }
1291
+ if (!this.releaseToken) {
1292
+ this.sendJson(res, 503, { ok: false, error: "release endpoint not configured" });
1293
+ return;
1294
+ }
1295
+ const provided = req.headers["x-sentinel-token"];
1296
+ const token = typeof provided === "string" ? provided : "";
1297
+ if (!constantTimeEqual(token, this.releaseToken)) {
1298
+ console.warn(`[SENTINEL GATEWAY] /release rejected: invalid token from ${remote}`);
1299
+ this.sendJson(res, 401, { ok: false, error: "invalid or missing token" });
1300
+ return;
1301
+ }
1302
+ this.readBody(req, res, (body) => {
1303
+ let payload;
1304
+ try {
1305
+ payload = JSON.parse(body);
1306
+ } catch {
1307
+ this.sendJson(res, 400, { ok: false, error: "invalid JSON" });
1308
+ return;
1309
+ }
1310
+ const p = payload ?? {};
1311
+ const agentId = typeof p.agentId === "string" ? p.agentId : "";
1312
+ if (!agentId) {
1313
+ this.sendJson(res, 400, { ok: false, error: "agentId required" });
1314
+ return;
1315
+ }
1316
+ const reason = typeof p.reason === "string" ? p.reason : "operator release (live)";
1317
+ const previousMode = this.sentinel.getMode(agentId);
1318
+ this.sentinel.release(agentId, reason).then(() => {
1319
+ this.sendJson(res, 200, {
1320
+ ok: true,
1321
+ agentId,
1322
+ previousMode,
1323
+ mode: this.sentinel.getMode(agentId)
1324
+ });
1325
+ }).catch((err) => {
1326
+ this.sendJson(res, 500, { ok: false, error: String(err.message) });
1327
+ });
1328
+ });
1329
+ }
1609
1330
  // -------------------------------------------------------------------------
1610
1331
  // Endpoint handlers
1611
1332
  // -------------------------------------------------------------------------
@@ -1740,29 +1461,26 @@ var SentinelGateway = class {
1740
1461
  routingId = routed.agentId;
1741
1462
  event.agentId = routingId;
1742
1463
  }
1743
- if (event.action === "command_exec" && event.targets && event.targets.length > 0) {
1744
- const allL2Hits = [];
1745
- for (const scanTarget of event.targets) {
1746
- const scan = scanBashCommand(scanTarget, FORBIDDEN_BASENAMES);
1747
- if (scan.matched) allL2Hits.push(...scan.hits);
1748
- }
1749
- if (allL2Hits.length > 0) {
1464
+ if (event.metadata?._unknownTool === "true") {
1465
+ const unknownName = event.metadata.ccToolName ?? event.primaryTarget;
1466
+ if (this.unknownTools === "deny") {
1750
1467
  const finding = {
1751
1468
  severity: "HIGH",
1752
1469
  kind: "actionable",
1753
- type: "unauthorized_target",
1470
+ type: "unknown_tool",
1754
1471
  agentId: event.agentId,
1755
1472
  agentName: event.agentName,
1756
- description: `Bash command references forbidden basename: ${allL2Hits.join(", ")}`,
1473
+ description: `Unknown tool "${unknownName}" denied \u2014 not in Sentinel's recognized tool set, so policy checks cannot evaluate it. If this is a legitimate tool, add it to enforcement.allowUnknownTools in the operator launch policy file and restart the gateway daemon.`,
1757
1474
  evidence: {
1758
1475
  action: event.action,
1759
- target: allL2Hits[0],
1476
+ target: unknownName,
1760
1477
  timestamp: event.timestamp,
1761
- baselineComparison: "credentials_exfil_attempt"
1478
+ baselineComparison: "unknown_tool_denied"
1762
1479
  },
1763
- recommendation: "Review the command for credential access or exfiltration. If legitimate, use existing policy exception mechanisms.",
1480
+ recommendation: `Add "${unknownName}" to enforcement.allowUnknownTools in the operator launch policy file and restart the daemon, or update @tuent/sentinel to a build whose recognized tool set includes it.`,
1764
1481
  timestamp: event.timestamp,
1765
- decision: "deny"
1482
+ decision: "deny",
1483
+ dedupKey: unknownName
1766
1484
  };
1767
1485
  await this.sentinel.handleGatewayDeny(routingId, finding);
1768
1486
  this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
@@ -1770,6 +1488,38 @@ var SentinelGateway = class {
1770
1488
  this.sendJson(res, 200, response);
1771
1489
  return;
1772
1490
  }
1491
+ const warnFinding = {
1492
+ severity: "LOW",
1493
+ kind: "informational",
1494
+ type: "unknown_tool",
1495
+ agentId: event.agentId,
1496
+ agentName: event.agentName,
1497
+ description: `Unknown tool "${unknownName}" allowed (enforcement.unknownTools: warn) \u2014 not in Sentinel's recognized tool set; policy checks could not evaluate it.`,
1498
+ evidence: {
1499
+ action: event.action,
1500
+ target: unknownName,
1501
+ timestamp: event.timestamp,
1502
+ baselineComparison: "unknown_tool_allowed_warn"
1503
+ },
1504
+ recommendation: `Add "${unknownName}" to enforcement.allowUnknownTools (or update @tuent/sentinel) to clear this warning, or switch enforcement.unknownTools to deny.`,
1505
+ timestamp: event.timestamp,
1506
+ decision: "allow",
1507
+ dedupKey: unknownName
1508
+ };
1509
+ await this.sentinel.logFinding(routingId, warnFinding);
1510
+ }
1511
+ let suppressForbiddenBasename = false;
1512
+ if (event.action === "command_exec" && event.targets && event.targets.length > 0) {
1513
+ const literalCommand = event.targets[0] ?? "";
1514
+ const decodedImplicated = event.targets.slice(1).some((t) => scanBashCommand(t, FORBIDDEN_BASENAMES).matched);
1515
+ suppressForbiddenBasename = isPositionallySafeMention(literalCommand) && !decodedImplicated;
1516
+ }
1517
+ if (event.action === "command_exec" && event.targets && event.targets.length > 0 && !suppressForbiddenBasename) {
1518
+ const allL2Hits = [];
1519
+ for (const scanTarget of event.targets) {
1520
+ const scan = scanBashCommand(scanTarget, FORBIDDEN_BASENAMES);
1521
+ if (scan.matched) allL2Hits.push(...scan.hits);
1522
+ }
1773
1523
  const allTokenPaths = [];
1774
1524
  let anyUnparseable = false;
1775
1525
  let anyDangerousConstruct = false;
@@ -1791,6 +1541,38 @@ var SentinelGateway = class {
1791
1541
  }
1792
1542
  if (matchedPath) break;
1793
1543
  }
1544
+ if (allL2Hits.length > 0) {
1545
+ const { mentionOnly } = classifyDeny(event.targets[0] ?? "", {
1546
+ l2Hits: allL2Hits,
1547
+ hasL1Hit: matchedPath !== null,
1548
+ unparseable: anyUnparseable,
1549
+ hasDangerousConstruct: anyDangerousConstruct
1550
+ });
1551
+ const finding = {
1552
+ severity: "HIGH",
1553
+ kind: "actionable",
1554
+ type: "unauthorized_target",
1555
+ agentId: event.agentId,
1556
+ agentName: event.agentName,
1557
+ description: `Bash command references forbidden basename: ${allL2Hits.join(", ")}`,
1558
+ evidence: {
1559
+ action: event.action,
1560
+ target: allL2Hits[0],
1561
+ timestamp: event.timestamp,
1562
+ baselineComparison: "credentials_exfil_attempt"
1563
+ },
1564
+ recommendation: "Review the command for credential access or exfiltration. If legitimate, use existing policy exception mechanisms.",
1565
+ timestamp: event.timestamp,
1566
+ decision: "deny",
1567
+ mentionOnly,
1568
+ dedupKey: event.primaryTarget
1569
+ };
1570
+ await this.sentinel.handleGatewayDeny(routingId, finding);
1571
+ this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
1572
+ const response = translator.formatPreToolUseResponse({ blocked: true, finding });
1573
+ this.sendJson(res, 200, response);
1574
+ return;
1575
+ }
1794
1576
  if (matchedPath) {
1795
1577
  const finding = {
1796
1578
  severity: "HIGH",
@@ -1807,7 +1589,10 @@ var SentinelGateway = class {
1807
1589
  },
1808
1590
  recommendation: "Review the command for credential or sensitive file access. If legitimate, use existing policy exception mechanisms.",
1809
1591
  timestamp: event.timestamp,
1810
- decision: "deny"
1592
+ decision: "deny",
1593
+ // A resolved path-glob hit is a file target — never a mention.
1594
+ mentionOnly: false,
1595
+ dedupKey: event.primaryTarget
1811
1596
  };
1812
1597
  await this.sentinel.handleGatewayDeny(routingId, finding);
1813
1598
  this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
@@ -2239,14 +2024,18 @@ async function runGatewayDaemon({
2239
2024
  policyPath,
2240
2025
  port = DEFAULT_PORT
2241
2026
  }) {
2242
- const { Sentinel: SentinelClass } = await import("./Sentinel-JLQL3YRD.js");
2243
- const { writePidFile } = await import("./pidManager-ZYC7SICM.js");
2027
+ const { Sentinel: SentinelClass } = await import("./Sentinel-XMSJE4DZ.js");
2028
+ const { writePidFile, writeReleaseToken } = await import("./pidManager-DOGVN6ZT.js");
2244
2029
  const { homedir } = await import("os");
2245
- const { loadPolicy: loadPolicy2, policyToRole: policyToRole2 } = await import("./policyLoader-6KR5VFVV.js");
2030
+ const { randomBytes } = await import("crypto");
2031
+ const { loadPolicy: loadPolicy2, policyToRole: policyToRole2, policyToConfig: policyToConfig2 } = await import("./policyLoader-KZL2U4M2.js");
2246
2032
  const sentinel = await SentinelClass.fromPolicy(policyPath);
2247
2033
  const baseline = await sentinel.computeBaseline("claude-code");
2248
2034
  sentinel.setBaseline("claude-code", baseline);
2249
- const operatorCeiling = policyToRole2(await loadPolicy2(policyPath));
2035
+ const operatorPolicy = await loadPolicy2(policyPath);
2036
+ const operatorCeiling = policyToRole2(operatorPolicy);
2037
+ const operatorConfig = policyToConfig2(operatorPolicy);
2038
+ const releaseToken = randomBytes(32).toString("hex");
2250
2039
  const gateway = new SentinelGateway({
2251
2040
  port,
2252
2041
  sentinel,
@@ -2254,10 +2043,15 @@ async function runGatewayDaemon({
2254
2043
  agentId: "claude-code",
2255
2044
  workspaceIsolation: process.env.SENTINEL_WORKSPACE_ISOLATION !== "0",
2256
2045
  operatorCeiling,
2257
- home: homedir()
2046
+ home: homedir(),
2047
+ releaseToken,
2048
+ unknownTools: operatorConfig.enforcement?.unknownTools,
2049
+ allowUnknownTools: operatorConfig.enforcement?.allowUnknownTools
2258
2050
  });
2259
2051
  await gateway.start();
2260
- writePidFile(homedir(), process.pid);
2052
+ const home = homedir();
2053
+ writePidFile(home, process.pid);
2054
+ writeReleaseToken(home, releaseToken, gateway.port);
2261
2055
  console.log(`[SENTINEL GATEWAY] PID ${process.pid} written`);
2262
2056
  }
2263
2057
 
@@ -2265,4 +2059,4 @@ export {
2265
2059
  SentinelGateway,
2266
2060
  runGatewayDaemon
2267
2061
  };
2268
- //# sourceMappingURL=chunk-Z3PWIJKT.js.map
2062
+ //# sourceMappingURL=chunk-L4R3LPJS.js.map