@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.
@@ -4,14 +4,14 @@ import {
4
4
  } from "./chunk-LATQNIRW.js";
5
5
  import {
6
6
  discoverPolicy
7
- } from "./chunk-FMZWHT4M.js";
7
+ } from "./chunk-B6S2PBS4.js";
8
8
  import {
9
9
  FORBIDDEN_BASENAMES
10
- } from "./chunk-QIYQWOLO.js";
10
+ } from "./chunk-FIEIGBYL.js";
11
11
  import {
12
12
  loadPolicy,
13
13
  loadPolicyFromString
14
- } from "./chunk-WLIDSTS4.js";
14
+ } from "./chunk-KWZ7JKKO.js";
15
15
 
16
16
  // src/setup/initClaudeCode.ts
17
17
  import { access, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
@@ -32,6 +32,7 @@ var HOOK_SCRIPT_SOURCE = `#!/usr/bin/env node
32
32
  import { readFileSync, appendFileSync, existsSync } from "node:fs";
33
33
  import { join, resolve, sep } from "node:path";
34
34
  import { spawn } from "node:child_process";
35
+ import { createRequire } from "node:module";
35
36
 
36
37
  const GATEWAY_ENTRY_POINT = "__GATEWAY_ENTRY_POINT__";
37
38
  const PORT = 7847;
@@ -93,19 +94,45 @@ const FLOOR_HIGH = ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task",
93
94
  // The marker default is [] \u2014 an un-substituted script stays strictest.
94
95
  const ALLOW_UNKNOWN_TOOLS = /* __ALLOW_UNKNOWN_TOOLS__ */ [];
95
96
 
96
- // Tier config uses flat format: { high, low, mcpDefault, unknownDefault } (plan v3.1 spec'd nested but simplified during 5a)
97
+ // Tier config uses flat format: { high, low, mcpDefault } (plan v3.1 spec'd nested but simplified during 5a)
98
+ // Validated field-by-field against safe defaults (enforcement-config sanity class):
99
+ // the hook must never block the cc session on a config error, so invalid values
100
+ // fall back to the fail-closed default AND are logged \u2014 never silently honored.
101
+ // In particular mcpDefault was compared with === "high", so any typo silently
102
+ // demoted ALL MCP tools to low tier gateway-down (allow-unlogged, the F-8 hole).
97
103
  function loadTiers() {
104
+ const DEFAULT_TIERS = {
105
+ high: ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task", "Skill"],
106
+ low: ["Read", "Glob", "Grep", "WebSearch"],
107
+ mcpDefault: "high",
108
+ };
109
+ let parsed;
98
110
  try {
99
- return JSON.parse(readFileSync(TIERS_PATH, "utf-8"));
111
+ parsed = JSON.parse(readFileSync(TIERS_PATH, "utf-8"));
100
112
  } catch {
101
- // Default tiers if config is missing
102
- return {
103
- high: ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task", "Skill"],
104
- low: ["Read", "Glob", "Grep", "WebSearch"],
105
- mcpDefault: "high",
106
- unknownDefault: "high",
107
- };
113
+ return DEFAULT_TIERS; // missing/unreadable config \u2014 safe defaults
108
114
  }
115
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
116
+ logFallback({ event: "tiers-config-invalid", reason: "top level must be an object", treatedAs: "defaults" });
117
+ return DEFAULT_TIERS;
118
+ }
119
+ const tiers = { ...DEFAULT_TIERS };
120
+ if (Array.isArray(parsed.high)) tiers.high = parsed.high.filter((t) => typeof t === "string");
121
+ else if (parsed.high !== undefined) logFallback({ event: "tiers-config-invalid", field: "high", reason: "must be an array of tool names", treatedAs: "defaults" });
122
+ if (Array.isArray(parsed.low)) tiers.low = parsed.low.filter((t) => typeof t === "string");
123
+ else if (parsed.low !== undefined) logFallback({ event: "tiers-config-invalid", field: "low", reason: "must be an array of tool names", treatedAs: "defaults" });
124
+ if (parsed.mcpDefault === "high" || parsed.mcpDefault === "low") tiers.mcpDefault = parsed.mcpDefault;
125
+ else if (parsed.mcpDefault !== undefined) logFallback({ event: "tiers-config-invalid", field: "mcpDefault", value: String(parsed.mcpDefault), reason: 'must be "high" or "low"', treatedAs: "high" });
126
+ // unknownDefault is NOT supported: unknown tools are ALWAYS high-tier
127
+ // gateway-down (see isHighSensitivity). It used to be written by init and
128
+ // read by nothing \u2014 a silent no-op knob. Warn so the operator knows.
129
+ if (parsed.unknownDefault !== undefined) logFallback({ event: "tiers-config-ignored", field: "unknownDefault", reason: "unsupported \u2014 unknown tools are always high-tier gateway-down" });
130
+ for (const key of Object.keys(parsed)) {
131
+ if (!["high", "low", "mcpDefault", "unknownDefault"].includes(key)) {
132
+ logFallback({ event: "tiers-config-unknown-key", key: key });
133
+ }
134
+ }
135
+ return tiers;
109
136
  }
110
137
 
111
138
  function isHighSensitivity(toolName, tiers) {
@@ -214,65 +241,37 @@ if (mode === "pre") {
214
241
  process.exit(0);
215
242
 
216
243
  } else if (mode === "session-start") {
217
- // P5 cold-start readiness poll (kept byte-equivalent to
218
- // setup/gatewayReadiness.ts \u2014 a generated string can't import it). Bounded WAIT
219
- // on /api/sentinel/health so cc's first PreToolUse hits a LISTENING gateway
220
- // instead of racing the daemon's warmup into the tiered fail-closed. Health
221
- // returns 200 only once the port is bound AFTER baseline+translator wiring, so
222
- // 200 == evaluation-ready. On timeout we exit anyway; the first pre-tool-use
223
- // uses the existing fail-closed, UNCHANGED. Never fail-opens.
224
- async function waitForGatewayReady() {
225
- const deadline = Date.now() + 5000;
226
- while (Date.now() < deadline) {
227
- try {
228
- const controller = new AbortController();
229
- const timer = setTimeout(() => controller.abort(), 500);
230
- try {
231
- const res = await fetch(BASE_URL + "/api/sentinel/health", { signal: controller.signal });
232
- if (res.ok) return; // evaluation-ready
233
- } finally { clearTimeout(timer); }
234
- } catch { /* not listening yet \u2014 retry until the bound */ }
235
- await new Promise((r) => setTimeout(r, 100));
236
- }
237
- // timed out \u2014 fall through; the first pre-tool-use uses the existing fail-closed.
238
- }
239
-
240
- // Check if gateway is already running (simple liveness; full PID validity is 5c)
241
- if (existsSync(PID_PATH)) {
244
+ // Delegate the FULL lifecycle decision \u2014 liveness, build-version check, relaunch
245
+ // on stale build, and spawn \u2014 to runSessionStart via the dual-mode daemon entry.
246
+ // One source of truth: this generated file carries NO copy of that logic
247
+ // (anti-drift). We resolve the daemon entry from the PROJECT (cwd) at
248
+ // hook-runtime so a relocated install (pnpm / version bump) launches the CURRENT
249
+ // build rather than the init-time baked path; we fall back to the baked path
250
+ // (dev tree, or package not resolvable from cwd). The launcher itself performs
251
+ // the bounded readiness wait, so awaiting its exit preserves the cold-start
252
+ // guarantee (first PreToolUse hits a ready gateway, or the existing fail-closed).
253
+ function resolveDaemonEntry() {
242
254
  try {
243
- const pid = parseInt(readFileSync(PID_PATH, "utf-8").trim(), 10);
244
- process.kill(pid, 0); // throws if process doesn't exist
245
- await waitForGatewayReady(); // alive but may still be warming \u2014 wait, bounded
246
- process.exit(0); // gateway is running (and now ready, or timed out)
247
- } catch {
248
- // Stale PID \u2014 continue to launch
249
- }
255
+ const require = createRequire(join(process.cwd(), "package.json"));
256
+ const pkg = require.resolve("@tuent/sentinel/package.json");
257
+ const candidate = join(pkg, "..", "dist", "gatewayDaemon.js");
258
+ if (existsSync(candidate)) return candidate;
259
+ } catch { /* not resolvable from cwd \u2014 fall back */ }
260
+ return GATEWAY_ENTRY_POINT;
250
261
  }
251
262
 
252
- // Discover .sentinel.yaml by walking up from cwd
253
- let dir = process.cwd();
254
- let policyPath = null;
255
- while (true) {
256
- const candidate = join(dir, ".sentinel.yaml");
257
- if (existsSync(candidate)) { policyPath = candidate; break; }
258
- const parent = join(dir, "..");
259
- if (parent === dir) break; // filesystem root
260
- dir = parent;
261
- }
262
-
263
- if (!policyPath) {
264
- process.stderr.write("Sentinel: no .sentinel.yaml found. Gateway not started.\\n", () => process.exit(0));
265
- }
266
-
267
- // Launch gateway daemon (detached). Extension-aware: an installed package
268
- // substitutes dist/gatewayDaemon.js (plain node); the dev tree substitutes a
269
- // .ts (node --import tsx/esm).
270
- const gatewayArgs = GATEWAY_ENTRY_POINT.endsWith(".ts")
271
- ? ["--import", "tsx/esm", GATEWAY_ENTRY_POINT, "--policy", policyPath, "--port", String(PORT)]
272
- : [GATEWAY_ENTRY_POINT, "--policy", policyPath, "--port", String(PORT)];
273
- const child = spawn("node", gatewayArgs, { detached: true, stdio: "ignore" });
274
- child.unref();
275
- await waitForGatewayReady(); // bounded wait for the just-spawned daemon to bind
263
+ const entry = resolveDaemonEntry();
264
+ const launchArgs = entry.endsWith(".ts")
265
+ ? ["--import", "tsx/esm", entry, "--session-start", "--port", String(PORT), "--cwd", process.cwd()]
266
+ : [entry, "--session-start", "--port", String(PORT), "--cwd", process.cwd()];
267
+ await new Promise((res) => {
268
+ const child = spawn("node", launchArgs, { stdio: "ignore" });
269
+ // Safety bound: a wedged launcher must never hang the cc session. On timeout
270
+ // we return; the daemon may still be coming up and the first call fail-closes.
271
+ const safety = setTimeout(() => { try { child.kill(); } catch {} res(undefined); }, 15000);
272
+ child.on("exit", () => { clearTimeout(safety); res(undefined); });
273
+ child.on("error", () => { clearTimeout(safety); res(undefined); });
274
+ });
276
275
  process.exit(0);
277
276
 
278
277
  } else if (mode === "session-end") {
@@ -487,8 +486,7 @@ enforcement:
487
486
  var FAIL_CLOSED_TIERS = {
488
487
  high: ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task", "Skill"],
489
488
  low: ["Read", "Glob", "Grep", "WebSearch"],
490
- mcpDefault: "high",
491
- unknownDefault: "high"
489
+ mcpDefault: "high"
492
490
  };
493
491
  async function runInitClaudeCode(options) {
494
492
  const force = options.force ?? false;
@@ -588,9 +586,22 @@ async function writeIfAbsent(path, content, force, report, mode) {
588
586
  report.created.push(path);
589
587
  }
590
588
 
589
+ // src/setup/buildId.ts
590
+ import { createHash } from "crypto";
591
+ import { readFileSync } from "fs";
592
+ function computeBuildId(entryPath) {
593
+ try {
594
+ return createHash("sha256").update(readFileSync(entryPath)).digest("hex");
595
+ } catch {
596
+ return "unknown";
597
+ }
598
+ }
599
+
591
600
  // src/setup/sessionStart.ts
592
601
  import { spawn } from "child_process";
593
602
  import { homedir } from "os";
603
+ import { openSync, closeSync, unlinkSync, statSync, mkdirSync } from "fs";
604
+ import { join as join2 } from "path";
594
605
 
595
606
  // src/setup/gatewayReadiness.ts
596
607
  var GATEWAY_READY_TIMEOUT_MS = 5e3;
@@ -619,6 +630,87 @@ async function waitForGatewayReady(port, options) {
619
630
  }
620
631
 
621
632
  // src/setup/sessionStart.ts
633
+ var KILL_GRACE_MS = 3e3;
634
+ var KILL_POLL_MS = 100;
635
+ var RELAUNCH_LOCK_STALE_MS = 15e3;
636
+ function relaunchLockPath(home) {
637
+ return join2(home, ".dahlia", "gateway-relaunch.lock");
638
+ }
639
+ async function fetchHealthBuildId(port) {
640
+ try {
641
+ const controller = new AbortController();
642
+ const timer = setTimeout(() => controller.abort(), 500);
643
+ try {
644
+ const res = await fetch(`http://localhost:${port}/api/sentinel/health`, {
645
+ signal: controller.signal
646
+ });
647
+ if (!res.ok) return null;
648
+ const body = await res.json();
649
+ return typeof body.buildId === "string" ? body.buildId : null;
650
+ } finally {
651
+ clearTimeout(timer);
652
+ }
653
+ } catch {
654
+ return null;
655
+ }
656
+ }
657
+ function isAlive(pid) {
658
+ try {
659
+ process.kill(pid, 0);
660
+ return true;
661
+ } catch {
662
+ return false;
663
+ }
664
+ }
665
+ async function terminateDaemon(pid) {
666
+ try {
667
+ process.kill(pid, "SIGTERM");
668
+ } catch {
669
+ return;
670
+ }
671
+ const deadline = Date.now() + KILL_GRACE_MS;
672
+ while (Date.now() < deadline) {
673
+ if (!isAlive(pid)) return;
674
+ await new Promise((r) => setTimeout(r, KILL_POLL_MS));
675
+ }
676
+ if (isAlive(pid)) {
677
+ try {
678
+ process.kill(pid, "SIGKILL");
679
+ } catch {
680
+ }
681
+ }
682
+ }
683
+ function acquireRelaunchLock(home) {
684
+ const path = relaunchLockPath(home);
685
+ try {
686
+ closeSync(openSync(path, "wx"));
687
+ return true;
688
+ } catch {
689
+ try {
690
+ const age = Date.now() - statSync(path).mtimeMs;
691
+ if (age > RELAUNCH_LOCK_STALE_MS) {
692
+ unlinkSync(path);
693
+ closeSync(openSync(path, "wx"));
694
+ return true;
695
+ }
696
+ } catch {
697
+ }
698
+ return false;
699
+ }
700
+ }
701
+ function releaseRelaunchLock(home) {
702
+ try {
703
+ unlinkSync(relaunchLockPath(home));
704
+ } catch {
705
+ }
706
+ }
707
+ function spawnDaemon(entry, policyPath, port, home) {
708
+ const args = entry.endsWith(".ts") ? ["--import", "tsx/esm", entry, "--policy", policyPath, "--port", String(port)] : [entry, "--policy", policyPath, "--port", String(port)];
709
+ const child = spawn("node", args, { detached: true, stdio: "ignore" });
710
+ child.unref();
711
+ if (child.pid) writePidFile(home, child.pid);
712
+ return child.pid;
713
+ }
622
714
  async function runSessionStart(options) {
623
715
  const cwd = options?.cwd ?? process.cwd();
624
716
  const home = options?.home ?? homedir();
@@ -627,24 +719,53 @@ async function runSessionStart(options) {
627
719
  if (!policyPath) {
628
720
  return { action: "no-policy" };
629
721
  }
722
+ mkdirSync(join2(home, ".dahlia"), { recursive: true });
723
+ const gatewayEntry = options?.gatewayEntry ?? resolveGatewayEntryPoint();
724
+ const localBuildId = computeBuildId(gatewayEntry);
630
725
  const lock = acquireGatewayLock(home);
631
726
  if (lock.reused) {
727
+ const remoteBuildId = await fetchHealthBuildId(port);
728
+ if (remoteBuildId !== null && remoteBuildId === localBuildId) {
729
+ await waitForGatewayReady(port);
730
+ return { action: "reused", pid: lock.pid, policyPath, relaunchReason: null };
731
+ }
732
+ const reason = remoteBuildId === null ? "health-unreachable-or-unknown" : "build-mismatch";
733
+ if (!acquireRelaunchLock(home)) {
734
+ await waitForGatewayReady(port);
735
+ const peerPid = acquireGatewayLock(home).pid ?? lock.pid;
736
+ return { action: "reused", pid: peerPid, policyPath, relaunchReason: null };
737
+ }
738
+ try {
739
+ if (lock.pid) await terminateDaemon(lock.pid);
740
+ const pid = spawnDaemon(gatewayEntry, policyPath, port, home);
741
+ await waitForGatewayReady(port);
742
+ return { action: "relaunched", pid, policyPath, relaunchReason: reason };
743
+ } finally {
744
+ releaseRelaunchLock(home);
745
+ }
746
+ }
747
+ if (!acquireRelaunchLock(home)) {
632
748
  await waitForGatewayReady(port);
633
- return { action: "reused", pid: lock.pid, policyPath };
749
+ const peerPid = acquireGatewayLock(home).pid ?? void 0;
750
+ return { action: "reused", pid: peerPid, policyPath, relaunchReason: null };
634
751
  }
635
- const gatewayEntry = resolveGatewayEntryPoint();
636
- const gatewayArgs = gatewayEntry.endsWith(".ts") ? ["--import", "tsx/esm", gatewayEntry, "--policy", policyPath, "--port", String(port)] : [gatewayEntry, "--policy", policyPath, "--port", String(port)];
637
- const child = spawn("node", gatewayArgs, { detached: true, stdio: "ignore" });
638
- child.unref();
639
- if (child.pid) {
640
- writePidFile(home, child.pid);
752
+ try {
753
+ const relock = acquireGatewayLock(home);
754
+ if (relock.reused) {
755
+ await waitForGatewayReady(port);
756
+ return { action: "reused", pid: relock.pid, policyPath, relaunchReason: null };
757
+ }
758
+ const pid = spawnDaemon(gatewayEntry, policyPath, port, home);
759
+ await waitForGatewayReady(port);
760
+ return { action: "spawned", pid, policyPath, relaunchReason: null };
761
+ } finally {
762
+ releaseRelaunchLock(home);
641
763
  }
642
- await waitForGatewayReady(port);
643
- return { action: "spawned", pid: child.pid, policyPath };
644
764
  }
645
765
 
646
766
  export {
647
767
  runInitClaudeCode,
768
+ computeBuildId,
648
769
  runSessionStart
649
770
  };
650
- //# sourceMappingURL=chunk-FWIISAZZ.js.map
771
+ //# sourceMappingURL=chunk-LTBVWF5H.js.map
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=chunk-TKAKHSZ3.js.map