@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.
- package/SECURITY_MODEL.md +9 -0
- package/dist/Sentinel-4QKPFHTI.js +10 -0
- package/dist/{Sentinel-xFCyXH45.d.ts → Sentinel-DT0IyGQi.d.ts} +127 -9
- package/dist/{chunk-PDWWRZXF.js → chunk-2IPSTUNH.js} +18 -7
- package/dist/chunk-B6S2PBS4.js +47 -0
- package/dist/{chunk-QIYQWOLO.js → chunk-FIEIGBYL.js} +387 -242
- package/dist/{chunk-L4R3LPJS.js → chunk-HRI2Y326.js} +119 -35
- package/dist/{chunk-GRN5P3H2.js → chunk-I2FVDDSG.js} +242 -94
- package/dist/{chunk-WLIDSTS4.js → chunk-KWZ7JKKO.js} +221 -27
- package/dist/{chunk-FWIISAZZ.js → chunk-LTBVWF5H.js} +201 -80
- package/dist/chunk-TKAKHSZ3.js +1 -0
- package/dist/cli.js +33 -35
- package/dist/gateway/index.d.ts +20 -1
- package/dist/gateway/index.js +4 -4
- package/dist/gatewayDaemon.js +38 -16
- package/dist/index.d.ts +31 -19
- package/dist/index.js +9 -8
- package/dist/logAdapter-WM43W3S7.js +7 -0
- package/dist/{mcpAdapter-R47GX2P3.js → mcpAdapter-WYAXUE7T.js} +2 -2
- package/dist/{policyLoader-KZL2U4M2.js → policyLoader-XX6BQXNB.js} +8 -4
- package/package.json +1 -1
- package/dist/Sentinel-XMSJE4DZ.js +0 -10
- package/dist/chunk-FMZWHT4M.js +0 -20
- package/dist/logAdapter-IB6ZDEV2.js +0 -7
|
@@ -4,14 +4,14 @@ import {
|
|
|
4
4
|
} from "./chunk-LATQNIRW.js";
|
|
5
5
|
import {
|
|
6
6
|
discoverPolicy
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-B6S2PBS4.js";
|
|
8
8
|
import {
|
|
9
9
|
FORBIDDEN_BASENAMES
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-FIEIGBYL.js";
|
|
11
11
|
import {
|
|
12
12
|
loadPolicy,
|
|
13
13
|
loadPolicyFromString
|
|
14
|
-
} from "./chunk-
|
|
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
|
|
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
|
-
|
|
111
|
+
parsed = JSON.parse(readFileSync(TIERS_PATH, "utf-8"));
|
|
100
112
|
} catch {
|
|
101
|
-
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
} catch {
|
|
248
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
749
|
+
const peerPid = acquireGatewayLock(home).pid ?? void 0;
|
|
750
|
+
return { action: "reused", pid: peerPid, policyPath, relaunchReason: null };
|
|
634
751
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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-
|
|
771
|
+
//# sourceMappingURL=chunk-LTBVWF5H.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=chunk-TKAKHSZ3.js.map
|