@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.
@@ -6,8 +6,12 @@ import {
6
6
  discoverPolicy
7
7
  } from "./chunk-FMZWHT4M.js";
8
8
  import {
9
+ FORBIDDEN_BASENAMES
10
+ } from "./chunk-JTR2E7RD.js";
11
+ import {
12
+ loadPolicy,
9
13
  loadPolicyFromString
10
- } from "./chunk-2FFMYSVC.js";
14
+ } from "./chunk-WLIDSTS4.js";
11
15
 
12
16
  // src/setup/initClaudeCode.ts
13
17
  import { access, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
@@ -17,13 +21,18 @@ import { createServer } from "http";
17
21
  import { fileURLToPath } from "url";
18
22
 
19
23
  // src/gateway/hookScriptSource.ts
24
+ var SCORER_CRITICAL_EXTRAS = ["shadow", "passwd"];
25
+ var HOOK_SENSITIVE_BASENAMES = [
26
+ .../* @__PURE__ */ new Set([...FORBIDDEN_BASENAMES, ...SCORER_CRITICAL_EXTRAS])
27
+ ];
20
28
  var HOOK_SCRIPT_SOURCE = `#!/usr/bin/env node
21
29
  // Sentinel cc hook bridge \u2014 generated by sentinel init claude-code
22
30
  // Do not edit manually; regenerate with: sentinel init claude-code --force
23
31
 
24
32
  import { readFileSync, appendFileSync, existsSync } from "node:fs";
25
- import { join } from "node:path";
33
+ import { join, resolve, sep } from "node:path";
26
34
  import { spawn } from "node:child_process";
35
+ import { createRequire } from "node:module";
27
36
 
28
37
  const GATEWAY_ENTRY_POINT = "__GATEWAY_ENTRY_POINT__";
29
38
  const PORT = 7847;
@@ -74,6 +83,17 @@ function logFallback(entry) {
74
83
  try { appendFileSync(FALLBACK_LOG, line + "\\n"); } catch { /* best effort */ }
75
84
  }
76
85
 
86
+ // Sprint 26 Fix-2 Part 1 \u2014 hardcoded fail-closed FLOOR. tiers.json may ADD
87
+ // high-tier tools but can never DOWNGRADE a floor tool to low (defeats the
88
+ // two-step tier-config rewrite). Checked BEFORE the editable tiers below.
89
+ const FLOOR_HIGH = ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task", "Skill"];
90
+
91
+ // Item D \u2014 operator allowUnknownTools escape hatch, baked at init from the
92
+ // launch policy so it survives gateway-down (the daemon allows these names
93
+ // unconditionally at the name level, so allowing them here keeps down \u2287 up).
94
+ // The marker default is [] \u2014 an un-substituted script stays strictest.
95
+ const ALLOW_UNKNOWN_TOOLS = /* __ALLOW_UNKNOWN_TOOLS__ */ [];
96
+
77
97
  // Tier config uses flat format: { high, low, mcpDefault, unknownDefault } (plan v3.1 spec'd nested but simplified during 5a)
78
98
  function loadTiers() {
79
99
  try {
@@ -90,10 +110,52 @@ function loadTiers() {
90
110
  }
91
111
 
92
112
  function isHighSensitivity(toolName, tiers) {
113
+ if (FLOOR_HIGH.includes(toolName)) return true; // floor: tiers may add high, never downgrade
93
114
  if (tiers.high && tiers.high.includes(toolName)) return true;
94
115
  if (tiers.low && tiers.low.includes(toolName)) return false;
95
116
  if (toolName.startsWith("mcp__")) return tiers.mcpDefault === "high";
96
- return tiers.unknownDefault === "high";
117
+ // Item D \u2014 operator-allowlisted names pass: gateway-up they are translated
118
+ // as known and allowed at the name level, so the escape hatch must survive
119
+ // gateway-down too.
120
+ if (ALLOW_UNKNOWN_TOOLS.includes(toolName)) return false;
121
+ // Item D floor: all other unknown names are always high-tier gateway-down.
122
+ // Deliberately stricter than gateway-up's warn default: the hook cannot
123
+ // persist findings while the daemon is down, so a warn-equivalent here
124
+ // would be allow-UNLOGGED \u2014 the exact F-8 hole. Hard-deny keeps down \u2287 up
125
+ // in both knob modes (same fail-closed stance as Fix-2's read handling),
126
+ // and a tiers.json edit (unknownDefault: "low") cannot reopen it. Named
127
+ // tools can still be tiered low explicitly via tiers.low above.
128
+ return true;
129
+ }
130
+
131
+ // Sprint 26 Fix-2 Part 2 \u2014 gateway-down safe-read exception.
132
+ // INVARIANT: on gateway-down a read is NEVER more permissive than gateway-up.
133
+ // Low-tier file reads (Read/Glob/Grep) fail CLOSED by default; only a
134
+ // structurally-provably-safe target is allowed. The guard is GENERATED from the
135
+ // canonical FORBIDDEN_BASENAMES (\u222A scorer extras), so it can't drift open.
136
+ const SENSITIVE_BASENAMES = ${JSON.stringify(HOOK_SENSITIVE_BASENAMES)};
137
+ const READ_TOOL_PATH_KEYS = { Read: "file_path", Glob: "pattern", Grep: "path" };
138
+
139
+ function isProvablySafeRead(toolName, toolInput, payloadCwd) {
140
+ const key = READ_TOOL_PATH_KEYS[toolName];
141
+ if (!key) return false; // not a path-bearing read tool \u2014 not provably safe
142
+ const raw = toolInput && toolInput[key];
143
+ if (typeof raw !== "string" || raw.length === 0) return false; // no target \u2192 not provable
144
+ if (raw[0] === "/" || raw[0] === "~") return false; // absolute / home \u2192 fail closed
145
+ const cwd = typeof payloadCwd === "string" && payloadCwd ? payloadCwd : process.cwd();
146
+ const cwdNorm = resolve(cwd);
147
+ const norm = resolve(cwd, raw);
148
+ // Must resolve strictly within cwd (rejects any .. escape).
149
+ if (norm !== cwdNorm && !norm.startsWith(cwdNorm + sep)) return false;
150
+ const segments = norm.slice(cwdNorm.length).split(sep).filter(Boolean);
151
+ for (const seg of segments) {
152
+ const low = seg.toLowerCase();
153
+ if (low[0] === ".") return false; // dotfile / dot-dir
154
+ for (const s of SENSITIVE_BASENAMES) {
155
+ if (low.includes(s.toLowerCase())) return false; // conservative sensitive-token match
156
+ }
157
+ }
158
+ return true;
97
159
  }
98
160
 
99
161
  // ---------------------------------------------------------------------------
@@ -111,15 +173,29 @@ if (mode === "pre") {
111
173
  const result = JSON.parse(resp.body);
112
174
  process.stdout.write(JSON.stringify(result), () => process.exit(0));
113
175
  } catch {
114
- // Gateway unreachable \u2014 tiered fail-closed
176
+ // Gateway unreachable \u2014 tiered fail-closed (Sprint 26 Fix-2).
115
177
  const tiers = loadTiers();
178
+ const restore = "Restart your Claude Code session to relaunch the gateway; see ~/.dahlia/gateway-fallback.log.";
116
179
  if (isHighSensitivity(toolName, tiers)) {
117
180
  logFallback({ event: "fail-closed-block", tool: toolName, tier: "high" });
118
181
  process.stderr.write(
119
- \`Sentinel gateway unreachable; high-sensitivity tool "\${toolName}" blocked per fail-closed policy\`,
182
+ \`Sentinel gateway unreachable; high-sensitivity tool "\${toolName}" blocked per fail-closed policy. \${restore}\`,
183
+ () => process.exit(2),
184
+ );
185
+ } else if (
186
+ READ_TOOL_PATH_KEYS[toolName] &&
187
+ !isProvablySafeRead(toolName, payload.tool_input || {}, payload.cwd)
188
+ ) {
189
+ // Low-tier read whose target is not provably safe \u2192 fail CLOSED. The hook
190
+ // carries no policy, so it cannot evaluate the forbid surface; a read it
191
+ // can't prove safe is treated as if the gateway would deny it.
192
+ logFallback({ event: "fail-closed-block", tool: toolName, tier: "low", reason: "read-not-provably-safe" });
193
+ process.stderr.write(
194
+ \`Sentinel gateway unreachable; read by "\${toolName}" blocked (fail-closed: target not provably safe). \${restore}\`,
120
195
  () => process.exit(2),
121
196
  );
122
197
  } else {
198
+ // Provably-safe read, or a non-read low-tier tool (e.g. WebSearch) \u2192 allow.
123
199
  logFallback({ event: "fail-closed-allow", tool: toolName, tier: "low" });
124
200
  process.stdout.write(JSON.stringify({
125
201
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
@@ -139,65 +215,37 @@ if (mode === "pre") {
139
215
  process.exit(0);
140
216
 
141
217
  } else if (mode === "session-start") {
142
- // P5 cold-start readiness poll (kept byte-equivalent to
143
- // setup/gatewayReadiness.ts \u2014 a generated string can't import it). Bounded WAIT
144
- // on /api/sentinel/health so cc's first PreToolUse hits a LISTENING gateway
145
- // instead of racing the daemon's warmup into the tiered fail-closed. Health
146
- // returns 200 only once the port is bound AFTER baseline+translator wiring, so
147
- // 200 == evaluation-ready. On timeout we exit anyway; the first pre-tool-use
148
- // uses the existing fail-closed, UNCHANGED. Never fail-opens.
149
- async function waitForGatewayReady() {
150
- const deadline = Date.now() + 5000;
151
- while (Date.now() < deadline) {
152
- try {
153
- const controller = new AbortController();
154
- const timer = setTimeout(() => controller.abort(), 500);
155
- try {
156
- const res = await fetch(BASE_URL + "/api/sentinel/health", { signal: controller.signal });
157
- if (res.ok) return; // evaluation-ready
158
- } finally { clearTimeout(timer); }
159
- } catch { /* not listening yet \u2014 retry until the bound */ }
160
- await new Promise((r) => setTimeout(r, 100));
161
- }
162
- // timed out \u2014 fall through; the first pre-tool-use uses the existing fail-closed.
163
- }
164
-
165
- // Check if gateway is already running (simple liveness; full PID validity is 5c)
166
- if (existsSync(PID_PATH)) {
218
+ // Delegate the FULL lifecycle decision \u2014 liveness, build-version check, relaunch
219
+ // on stale build, and spawn \u2014 to runSessionStart via the dual-mode daemon entry.
220
+ // One source of truth: this generated file carries NO copy of that logic
221
+ // (anti-drift). We resolve the daemon entry from the PROJECT (cwd) at
222
+ // hook-runtime so a relocated install (pnpm / version bump) launches the CURRENT
223
+ // build rather than the init-time baked path; we fall back to the baked path
224
+ // (dev tree, or package not resolvable from cwd). The launcher itself performs
225
+ // the bounded readiness wait, so awaiting its exit preserves the cold-start
226
+ // guarantee (first PreToolUse hits a ready gateway, or the existing fail-closed).
227
+ function resolveDaemonEntry() {
167
228
  try {
168
- const pid = parseInt(readFileSync(PID_PATH, "utf-8").trim(), 10);
169
- process.kill(pid, 0); // throws if process doesn't exist
170
- await waitForGatewayReady(); // alive but may still be warming \u2014 wait, bounded
171
- process.exit(0); // gateway is running (and now ready, or timed out)
172
- } catch {
173
- // Stale PID \u2014 continue to launch
174
- }
175
- }
176
-
177
- // Discover .sentinel.yaml by walking up from cwd
178
- let dir = process.cwd();
179
- let policyPath = null;
180
- while (true) {
181
- const candidate = join(dir, ".sentinel.yaml");
182
- if (existsSync(candidate)) { policyPath = candidate; break; }
183
- const parent = join(dir, "..");
184
- if (parent === dir) break; // filesystem root
185
- dir = parent;
229
+ const require = createRequire(join(process.cwd(), "package.json"));
230
+ const pkg = require.resolve("@tuent/sentinel/package.json");
231
+ const candidate = join(pkg, "..", "dist", "gatewayDaemon.js");
232
+ if (existsSync(candidate)) return candidate;
233
+ } catch { /* not resolvable from cwd \u2014 fall back */ }
234
+ return GATEWAY_ENTRY_POINT;
186
235
  }
187
236
 
188
- if (!policyPath) {
189
- process.stderr.write("Sentinel: no .sentinel.yaml found. Gateway not started.\\n", () => process.exit(0));
190
- }
191
-
192
- // Launch gateway daemon (detached). Extension-aware: an installed package
193
- // substitutes dist/gatewayDaemon.js (plain node); the dev tree substitutes a
194
- // .ts (node --import tsx/esm).
195
- const gatewayArgs = GATEWAY_ENTRY_POINT.endsWith(".ts")
196
- ? ["--import", "tsx/esm", GATEWAY_ENTRY_POINT, "--policy", policyPath, "--port", String(PORT)]
197
- : [GATEWAY_ENTRY_POINT, "--policy", policyPath, "--port", String(PORT)];
198
- const child = spawn("node", gatewayArgs, { detached: true, stdio: "ignore" });
199
- child.unref();
200
- await waitForGatewayReady(); // bounded wait for the just-spawned daemon to bind
237
+ const entry = resolveDaemonEntry();
238
+ const launchArgs = entry.endsWith(".ts")
239
+ ? ["--import", "tsx/esm", entry, "--session-start", "--port", String(PORT), "--cwd", process.cwd()]
240
+ : [entry, "--session-start", "--port", String(PORT), "--cwd", process.cwd()];
241
+ await new Promise((res) => {
242
+ const child = spawn("node", launchArgs, { stdio: "ignore" });
243
+ // Safety bound: a wedged launcher must never hang the cc session. On timeout
244
+ // we return; the daemon may still be coming up and the first call fail-closes.
245
+ const safety = setTimeout(() => { try { child.kill(); } catch {} res(undefined); }, 15000);
246
+ child.on("exit", () => { clearTimeout(safety); res(undefined); });
247
+ child.on("error", () => { clearTimeout(safety); res(undefined); });
248
+ });
201
249
  process.exit(0);
202
250
 
203
251
  } else if (mode === "session-end") {
@@ -322,6 +370,11 @@ agent:
322
370
  policy:
323
371
  allow:
324
372
  actions: [file_read, file_write, tool_invocation, network_request, command_exec]
373
+ # allow.targets is ADVISORY for file_read / file_write / tool_invocation: an
374
+ # access outside this list is logged as a MEDIUM scope_violation but still
375
+ # runs \u2014 Sentinel governs the agent's own tool calls, it is not a filesystem
376
+ # sandbox. forbid.targets below is the hard deny. network_request is the
377
+ # exception: unlisted hosts are DENIED by default (see the networkHosts note).
325
378
  targets:
326
379
  - "src/**"
327
380
  - "test/**"
@@ -378,6 +431,28 @@ policy:
378
431
  - "**/*.pem"
379
432
  - "**/*.key"
380
433
  - "/etc/**"
434
+ # Sprint 26 FIX 1 (A) \u2014 common credential stores. DRIFT: keep in sync with
435
+ # DEFAULT_FORBIDDEN_PATTERNS in defaults.ts (unification tracked separately).
436
+ - "**/.netrc"
437
+ - "**/.npmrc"
438
+ - "**/.git-credentials"
439
+ - "**/.pgpass"
440
+ - "**/.zsh_history"
441
+ - "**/.config/gh/**"
442
+ - "**/.docker/config.json"
443
+ - "**/.gnupg/**"
444
+ - "**/.config/gcloud/**"
445
+ - "**/.kube/**"
446
+ - "**/Library/Keychains/**"
447
+ # Sprint 26 FIX 1 (B) \u2014 Sentinel's own state dir (current path only).
448
+ - "**/.dahlia/**"
449
+ # Sprint 26 FIX 3 \u2014 this policy file and cc's hook-wiring settings files.
450
+ # Agent tool-writes are denied; reads stay allowed via a code-side ceiling
451
+ # exception (not authorable here \u2014 workspace-authored exceptions are
452
+ # dropped by the ceiling merge, by design). DRIFT: keep in sync with
453
+ # DEFAULT_FORBIDDEN_PATTERNS in defaults.ts.
454
+ - "**/.sentinel.yaml"
455
+ - "**/.claude/settings*.json"
381
456
  enforcement:
382
457
  restrictAfter: 3
383
458
  quarantineAfter: 5
@@ -418,7 +493,16 @@ async function runInitClaudeCode(options) {
418
493
  );
419
494
  const hookPath = join(dahliaDir, "cc-hook.mjs");
420
495
  const gatewayEntryPoint = resolveGatewayEntryPoint();
421
- const hookContent = HOOK_SCRIPT_SOURCE.replace(/__GATEWAY_ENTRY_POINT__/g, gatewayEntryPoint);
496
+ let allowUnknownTools = [];
497
+ try {
498
+ const effectivePolicy = await loadPolicy(policyPath);
499
+ allowUnknownTools = effectivePolicy.enforcement?.allowUnknownTools ?? [];
500
+ } catch {
501
+ }
502
+ const hookContent = HOOK_SCRIPT_SOURCE.replace(
503
+ /__GATEWAY_ENTRY_POINT__/g,
504
+ gatewayEntryPoint
505
+ ).replace("/* __ALLOW_UNKNOWN_TOOLS__ */ []", JSON.stringify(allowUnknownTools));
422
506
  if (hookContent.includes("__GATEWAY_ENTRY_POINT__")) {
423
507
  throw new Error("Failed to substitute all __GATEWAY_ENTRY_POINT__ placeholders");
424
508
  }
@@ -477,9 +561,22 @@ async function writeIfAbsent(path, content, force, report, mode) {
477
561
  report.created.push(path);
478
562
  }
479
563
 
564
+ // src/setup/buildId.ts
565
+ import { createHash } from "crypto";
566
+ import { readFileSync } from "fs";
567
+ function computeBuildId(entryPath) {
568
+ try {
569
+ return createHash("sha256").update(readFileSync(entryPath)).digest("hex");
570
+ } catch {
571
+ return "unknown";
572
+ }
573
+ }
574
+
480
575
  // src/setup/sessionStart.ts
481
576
  import { spawn } from "child_process";
482
577
  import { homedir } from "os";
578
+ import { openSync, closeSync, unlinkSync, statSync, mkdirSync } from "fs";
579
+ import { join as join2 } from "path";
483
580
 
484
581
  // src/setup/gatewayReadiness.ts
485
582
  var GATEWAY_READY_TIMEOUT_MS = 5e3;
@@ -508,6 +605,87 @@ async function waitForGatewayReady(port, options) {
508
605
  }
509
606
 
510
607
  // src/setup/sessionStart.ts
608
+ var KILL_GRACE_MS = 3e3;
609
+ var KILL_POLL_MS = 100;
610
+ var RELAUNCH_LOCK_STALE_MS = 15e3;
611
+ function relaunchLockPath(home) {
612
+ return join2(home, ".dahlia", "gateway-relaunch.lock");
613
+ }
614
+ async function fetchHealthBuildId(port) {
615
+ try {
616
+ const controller = new AbortController();
617
+ const timer = setTimeout(() => controller.abort(), 500);
618
+ try {
619
+ const res = await fetch(`http://localhost:${port}/api/sentinel/health`, {
620
+ signal: controller.signal
621
+ });
622
+ if (!res.ok) return null;
623
+ const body = await res.json();
624
+ return typeof body.buildId === "string" ? body.buildId : null;
625
+ } finally {
626
+ clearTimeout(timer);
627
+ }
628
+ } catch {
629
+ return null;
630
+ }
631
+ }
632
+ function isAlive(pid) {
633
+ try {
634
+ process.kill(pid, 0);
635
+ return true;
636
+ } catch {
637
+ return false;
638
+ }
639
+ }
640
+ async function terminateDaemon(pid) {
641
+ try {
642
+ process.kill(pid, "SIGTERM");
643
+ } catch {
644
+ return;
645
+ }
646
+ const deadline = Date.now() + KILL_GRACE_MS;
647
+ while (Date.now() < deadline) {
648
+ if (!isAlive(pid)) return;
649
+ await new Promise((r) => setTimeout(r, KILL_POLL_MS));
650
+ }
651
+ if (isAlive(pid)) {
652
+ try {
653
+ process.kill(pid, "SIGKILL");
654
+ } catch {
655
+ }
656
+ }
657
+ }
658
+ function acquireRelaunchLock(home) {
659
+ const path = relaunchLockPath(home);
660
+ try {
661
+ closeSync(openSync(path, "wx"));
662
+ return true;
663
+ } catch {
664
+ try {
665
+ const age = Date.now() - statSync(path).mtimeMs;
666
+ if (age > RELAUNCH_LOCK_STALE_MS) {
667
+ unlinkSync(path);
668
+ closeSync(openSync(path, "wx"));
669
+ return true;
670
+ }
671
+ } catch {
672
+ }
673
+ return false;
674
+ }
675
+ }
676
+ function releaseRelaunchLock(home) {
677
+ try {
678
+ unlinkSync(relaunchLockPath(home));
679
+ } catch {
680
+ }
681
+ }
682
+ function spawnDaemon(entry, policyPath, port, home) {
683
+ const args = entry.endsWith(".ts") ? ["--import", "tsx/esm", entry, "--policy", policyPath, "--port", String(port)] : [entry, "--policy", policyPath, "--port", String(port)];
684
+ const child = spawn("node", args, { detached: true, stdio: "ignore" });
685
+ child.unref();
686
+ if (child.pid) writePidFile(home, child.pid);
687
+ return child.pid;
688
+ }
511
689
  async function runSessionStart(options) {
512
690
  const cwd = options?.cwd ?? process.cwd();
513
691
  const home = options?.home ?? homedir();
@@ -516,24 +694,39 @@ async function runSessionStart(options) {
516
694
  if (!policyPath) {
517
695
  return { action: "no-policy" };
518
696
  }
697
+ mkdirSync(join2(home, ".dahlia"), { recursive: true });
698
+ const gatewayEntry = options?.gatewayEntry ?? resolveGatewayEntryPoint();
699
+ const localBuildId = computeBuildId(gatewayEntry);
519
700
  const lock = acquireGatewayLock(home);
520
701
  if (lock.reused) {
521
- await waitForGatewayReady(port);
522
- return { action: "reused", pid: lock.pid, policyPath };
523
- }
524
- const gatewayEntry = resolveGatewayEntryPoint();
525
- const gatewayArgs = gatewayEntry.endsWith(".ts") ? ["--import", "tsx/esm", gatewayEntry, "--policy", policyPath, "--port", String(port)] : [gatewayEntry, "--policy", policyPath, "--port", String(port)];
526
- const child = spawn("node", gatewayArgs, { detached: true, stdio: "ignore" });
527
- child.unref();
528
- if (child.pid) {
529
- writePidFile(home, child.pid);
702
+ const remoteBuildId = await fetchHealthBuildId(port);
703
+ if (remoteBuildId !== null && remoteBuildId === localBuildId) {
704
+ await waitForGatewayReady(port);
705
+ return { action: "reused", pid: lock.pid, policyPath, relaunchReason: null };
706
+ }
707
+ const reason = remoteBuildId === null ? "health-unreachable-or-unknown" : "build-mismatch";
708
+ if (!acquireRelaunchLock(home)) {
709
+ await waitForGatewayReady(port);
710
+ const peerPid = acquireGatewayLock(home).pid ?? lock.pid;
711
+ return { action: "reused", pid: peerPid, policyPath, relaunchReason: null };
712
+ }
713
+ try {
714
+ if (lock.pid) await terminateDaemon(lock.pid);
715
+ const pid2 = spawnDaemon(gatewayEntry, policyPath, port, home);
716
+ await waitForGatewayReady(port);
717
+ return { action: "relaunched", pid: pid2, policyPath, relaunchReason: reason };
718
+ } finally {
719
+ releaseRelaunchLock(home);
720
+ }
530
721
  }
722
+ const pid = spawnDaemon(gatewayEntry, policyPath, port, home);
531
723
  await waitForGatewayReady(port);
532
- return { action: "spawned", pid: child.pid, policyPath };
724
+ return { action: "spawned", pid, policyPath, relaunchReason: null };
533
725
  }
534
726
 
535
727
  export {
536
728
  runInitClaudeCode,
729
+ computeBuildId,
537
730
  runSessionStart
538
731
  };
539
- //# sourceMappingURL=chunk-WPTJBRX5.js.map
732
+ //# sourceMappingURL=chunk-2TJ5Z53T.js.map