@tuent/sentinel 0.1.0

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.
@@ -0,0 +1,539 @@
1
+ import {
2
+ acquireGatewayLock,
3
+ writePidFile
4
+ } from "./chunk-CUJKNIKT.js";
5
+ import {
6
+ discoverPolicy
7
+ } from "./chunk-FMZWHT4M.js";
8
+ import {
9
+ loadPolicyFromString
10
+ } from "./chunk-2FFMYSVC.js";
11
+
12
+ // src/setup/initClaudeCode.ts
13
+ import { access, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
14
+ import { accessSync } from "fs";
15
+ import { join, resolve, dirname as dirname2 } from "path";
16
+ import { createServer } from "http";
17
+ import { fileURLToPath } from "url";
18
+
19
+ // src/gateway/hookScriptSource.ts
20
+ var HOOK_SCRIPT_SOURCE = `#!/usr/bin/env node
21
+ // Sentinel cc hook bridge \u2014 generated by sentinel init claude-code
22
+ // Do not edit manually; regenerate with: sentinel init claude-code --force
23
+
24
+ import { readFileSync, appendFileSync, existsSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { spawn } from "node:child_process";
27
+
28
+ const GATEWAY_ENTRY_POINT = "__GATEWAY_ENTRY_POINT__";
29
+ const PORT = 7847;
30
+ const BASE_URL = \`http://localhost:\${PORT}\`;
31
+ const TIMEOUT_MS = 2000;
32
+ const HOME = process.env.HOME || process.env.USERPROFILE || "";
33
+ const TIERS_PATH = join(HOME, ".dahlia", "fail-closed-tiers.json");
34
+ const FALLBACK_LOG = join(HOME, ".dahlia", "gateway-fallback.log");
35
+ const PID_PATH = join(HOME, ".dahlia", "sentinel-gateway.pid");
36
+
37
+ const mode = process.argv[2];
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function readStdin() {
44
+ return new Promise((resolve) => {
45
+ let data = "";
46
+ process.stdin.setEncoding("utf-8");
47
+ process.stdin.on("data", (chunk) => { data += chunk; });
48
+ process.stdin.on("end", () => resolve(data));
49
+ });
50
+ }
51
+
52
+ async function postToGateway(path, body) {
53
+ const http = await import("node:http");
54
+ return new Promise((resolve, reject) => {
55
+ const req = http.request(
56
+ { hostname: "localhost", port: PORT, path, method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ timeout: TIMEOUT_MS },
59
+ (res) => {
60
+ let data = "";
61
+ res.on("data", (c) => { data += c; });
62
+ res.on("end", () => resolve({ status: res.statusCode, body: data }));
63
+ },
64
+ );
65
+ req.on("error", reject);
66
+ req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
67
+ req.write(body);
68
+ req.end();
69
+ });
70
+ }
71
+
72
+ function logFallback(entry) {
73
+ const line = JSON.stringify({ ...entry, timestamp: new Date().toISOString() });
74
+ try { appendFileSync(FALLBACK_LOG, line + "\\n"); } catch { /* best effort */ }
75
+ }
76
+
77
+ // Tier config uses flat format: { high, low, mcpDefault, unknownDefault } (plan v3.1 spec'd nested but simplified during 5a)
78
+ function loadTiers() {
79
+ try {
80
+ return JSON.parse(readFileSync(TIERS_PATH, "utf-8"));
81
+ } catch {
82
+ // Default tiers if config is missing
83
+ return {
84
+ high: ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task", "Skill"],
85
+ low: ["Read", "Glob", "Grep", "WebSearch"],
86
+ mcpDefault: "high",
87
+ unknownDefault: "high",
88
+ };
89
+ }
90
+ }
91
+
92
+ function isHighSensitivity(toolName, tiers) {
93
+ if (tiers.high && tiers.high.includes(toolName)) return true;
94
+ if (tiers.low && tiers.low.includes(toolName)) return false;
95
+ if (toolName.startsWith("mcp__")) return tiers.mcpDefault === "high";
96
+ return tiers.unknownDefault === "high";
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Subcommands
101
+ // ---------------------------------------------------------------------------
102
+
103
+ if (mode === "pre") {
104
+ const input = await readStdin();
105
+ let payload;
106
+ try { payload = JSON.parse(input); } catch { process.exit(0); }
107
+ const toolName = payload.tool_name || "unknown";
108
+
109
+ try {
110
+ const resp = await postToGateway("/api/sentinel/pre-tool-use/claude-code", input);
111
+ const result = JSON.parse(resp.body);
112
+ process.stdout.write(JSON.stringify(result), () => process.exit(0));
113
+ } catch {
114
+ // Gateway unreachable \u2014 tiered fail-closed
115
+ const tiers = loadTiers();
116
+ if (isHighSensitivity(toolName, tiers)) {
117
+ logFallback({ event: "fail-closed-block", tool: toolName, tier: "high" });
118
+ process.stderr.write(
119
+ \`Sentinel gateway unreachable; high-sensitivity tool "\${toolName}" blocked per fail-closed policy\`,
120
+ () => process.exit(2),
121
+ );
122
+ } else {
123
+ logFallback({ event: "fail-closed-allow", tool: toolName, tier: "low" });
124
+ process.stdout.write(JSON.stringify({
125
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
126
+ }), () => process.exit(0));
127
+ }
128
+ }
129
+
130
+ } else if (mode === "post") {
131
+ const input = await readStdin();
132
+ try {
133
+ await postToGateway("/api/sentinel/post-tool-use/claude-code", input);
134
+ } catch {
135
+ let payload;
136
+ try { payload = JSON.parse(input); } catch { /* ignore */ }
137
+ logFallback({ event: "post-fallback", tool: payload?.tool_name || "unknown" });
138
+ }
139
+ process.exit(0);
140
+
141
+ } 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)) {
167
+ 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;
186
+ }
187
+
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
201
+ process.exit(0);
202
+
203
+ } else if (mode === "session-end") {
204
+ const input = await readStdin();
205
+ try {
206
+ await postToGateway("/api/sentinel/session-end/claude-code", input);
207
+ } catch {
208
+ logFallback({ event: "session-end-fallback" });
209
+ }
210
+ process.exit(0);
211
+
212
+ } else if (mode === "prompt") {
213
+ // UserPromptSubmit \u2014 automatic per-prompt intent capture (Sprint 23 P1).
214
+ // AWAIT the gateway so intent declaration completes before cc proceeds to the
215
+ // first tool call (cc blocks on hook completion). Declaration is best-effort:
216
+ // an unreachable gateway never blocks the prompt. Write nothing to stdout so
217
+ // we don't inject context into the conversation.
218
+ const input = await readStdin();
219
+ try {
220
+ await postToGateway("/api/sentinel/user-prompt-submit/claude-code", input);
221
+ } catch {
222
+ logFallback({ event: "prompt-fallback" });
223
+ }
224
+ process.exit(0);
225
+
226
+ } else {
227
+ process.stderr.write(\`Unknown subcommand: \${mode}. Use: pre, post, session-start, session-end, prompt\\n\`, () => process.exit(1));
228
+ }
229
+ `;
230
+
231
+ // src/setup/settingsMerge.ts
232
+ import { readFile, writeFile, mkdir } from "fs/promises";
233
+ import { dirname } from "path";
234
+ var HOOK_EVENTS = [
235
+ "PreToolUse",
236
+ "PostToolUse",
237
+ "SessionStart",
238
+ "SessionEnd",
239
+ "UserPromptSubmit"
240
+ ];
241
+ var COMMAND_MAP = {
242
+ PreToolUse: "node ~/.dahlia/cc-hook.mjs pre",
243
+ PostToolUse: "node ~/.dahlia/cc-hook.mjs post",
244
+ SessionStart: "node ~/.dahlia/cc-hook.mjs session-start",
245
+ SessionEnd: "node ~/.dahlia/cc-hook.mjs session-end",
246
+ // Sprint 23 P1 — automatic per-prompt intent capture.
247
+ UserPromptSubmit: "node ~/.dahlia/cc-hook.mjs prompt"
248
+ };
249
+ async function mergeClaudeSettings(filePath) {
250
+ const report = { created: false, added: [], alreadyPresent: [] };
251
+ let settings;
252
+ try {
253
+ const raw = await readFile(filePath, "utf-8");
254
+ try {
255
+ settings = JSON.parse(raw);
256
+ } catch (err) {
257
+ throw new Error(
258
+ `settings.local.json exists but is not valid JSON: ${err.message}. Manual intervention required.`,
259
+ { cause: err }
260
+ );
261
+ }
262
+ } catch (err) {
263
+ if (err.code === "ENOENT") {
264
+ settings = {};
265
+ report.created = true;
266
+ } else {
267
+ throw err;
268
+ }
269
+ }
270
+ if (!("hooks" in settings)) {
271
+ settings.hooks = {};
272
+ } else if (typeof settings.hooks !== "object" || settings.hooks === null || Array.isArray(settings.hooks)) {
273
+ throw new Error(
274
+ "settings.local.json has a 'hooks' field that is not an object. Manual intervention required."
275
+ );
276
+ }
277
+ const hooks = settings.hooks;
278
+ for (const event of HOOK_EVENTS) {
279
+ const expectedCommand = COMMAND_MAP[event];
280
+ if (!(event in hooks)) {
281
+ hooks[event] = [];
282
+ } else if (!Array.isArray(hooks[event])) {
283
+ throw new Error(
284
+ `settings.local.json hooks.${event} is not an array. Manual intervention required.`
285
+ );
286
+ }
287
+ let eventArray = hooks[event];
288
+ eventArray = eventArray.filter((entry) => {
289
+ const isOldFormat = !Array.isArray(entry.hooks);
290
+ const isSentinelCommand = entry.command === expectedCommand;
291
+ return !(isOldFormat && isSentinelCommand);
292
+ });
293
+ hooks[event] = eventArray;
294
+ const exists = eventArray.some((wrapper) => {
295
+ const inner = wrapper.hooks;
296
+ if (!Array.isArray(inner)) return false;
297
+ return inner.some((h) => h.command === expectedCommand);
298
+ });
299
+ if (exists) {
300
+ report.alreadyPresent.push(event);
301
+ } else {
302
+ eventArray.push({
303
+ matcher: "*",
304
+ hooks: [{ type: "command", command: expectedCommand }]
305
+ });
306
+ report.added.push(event);
307
+ }
308
+ }
309
+ await mkdir(dirname(filePath), { recursive: true });
310
+ await writeFile(filePath, JSON.stringify(settings, null, 2) + "\n");
311
+ return report;
312
+ }
313
+
314
+ // src/setup/initClaudeCode.ts
315
+ var __filename = fileURLToPath(import.meta.url);
316
+ var __dirname = dirname2(__filename);
317
+ var STARTER_POLICY = `version: "1.0"
318
+ agent:
319
+ id: claude-code
320
+ name: Claude Code
321
+ description: Anthropic's command-line coding agent, monitored by Sentinel
322
+ policy:
323
+ allow:
324
+ actions: [file_read, file_write, tool_invocation, network_request, command_exec]
325
+ targets:
326
+ - "src/**"
327
+ - "test/**"
328
+ - "**/*.md"
329
+ # Hosts the agent is explicitly allowed to reach via network_request actions.
330
+ # When this list is unset or the host isn't listed, network_request actions
331
+ # emit a MEDIUM scope_violation and are denied by default per the runtime's
332
+ # DEFAULT_MEDIUM_DISPOSITION map. The starter list below covers common
333
+ # developer-tooling hosts cc tends to fetch during normal workflows.
334
+ #
335
+ # Add or remove entries per your security posture. Literal hostname match
336
+ # only \u2014 wildcards not supported in v1. Hosts NOT in this list will deny;
337
+ # to permit a new host, add it explicitly.
338
+ #
339
+ # Denylist controls (RFC1918, link-local, loopback CIDRs and dangerous
340
+ # schemes like file://, data://, javascript://) apply regardless of this
341
+ # allowlist and cannot be overridden by adding entries here.
342
+ networkHosts:
343
+ # Anthropic
344
+ - "api.anthropic.com"
345
+ - "docs.anthropic.com"
346
+ - "console.anthropic.com"
347
+ # GitHub
348
+ - "github.com"
349
+ - "raw.githubusercontent.com"
350
+ - "gist.github.com"
351
+ - "api.github.com"
352
+ - "objects.githubusercontent.com"
353
+ # Package registries
354
+ - "registry.npmjs.org"
355
+ - "www.npmjs.com"
356
+ - "pypi.org"
357
+ - "files.pythonhosted.org"
358
+ # Developer documentation
359
+ - "developer.mozilla.org"
360
+ - "nodejs.org"
361
+ - "www.python.org"
362
+ - "docs.python.org"
363
+ - "www.typescriptlang.org"
364
+ # Q&A and reference
365
+ - "stackoverflow.com"
366
+ - "api.stackexchange.com"
367
+ forbid:
368
+ targets:
369
+ - ".env*"
370
+ - "secrets/**"
371
+ - ".ssh/**"
372
+ - ".aws/**"
373
+ - "**/credentials/**"
374
+ - "**/id_rsa*"
375
+ - "**/id_dsa*"
376
+ - "**/id_ecdsa*"
377
+ - "**/id_ed25519*"
378
+ - "**/*.pem"
379
+ - "**/*.key"
380
+ - "/etc/**"
381
+ enforcement:
382
+ restrictAfter: 3
383
+ quarantineAfter: 5
384
+ `;
385
+ var FAIL_CLOSED_TIERS = {
386
+ high: ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task", "Skill"],
387
+ low: ["Read", "Glob", "Grep", "WebSearch"],
388
+ mcpDefault: "high",
389
+ unknownDefault: "high"
390
+ };
391
+ async function runInitClaudeCode(options) {
392
+ const force = options.force ?? false;
393
+ const port = options.port ?? 7847;
394
+ const cwd = options.cwd ?? process.cwd();
395
+ const { homedir: homedir2 } = await import("os");
396
+ const home = options.home ?? homedir2();
397
+ const report = { created: [], skipped: [], merged: [], errors: [] };
398
+ await checkPortAvailable(port);
399
+ const dahliaDir = join(home, ".dahlia");
400
+ await mkdir2(dahliaDir, { recursive: true, mode: 448 });
401
+ try {
402
+ loadPolicyFromString(STARTER_POLICY);
403
+ } catch (err) {
404
+ throw new Error(
405
+ `Starter policy template failed to parse (this is a bug): ${err.message}`,
406
+ { cause: err }
407
+ );
408
+ }
409
+ const policyPath = join(cwd, ".sentinel.yaml");
410
+ await writeIfAbsent(policyPath, STARTER_POLICY, force, report);
411
+ const tiersPath = join(dahliaDir, "fail-closed-tiers.json");
412
+ await writeIfAbsent(
413
+ tiersPath,
414
+ JSON.stringify(FAIL_CLOSED_TIERS, null, 2) + "\n",
415
+ force,
416
+ report,
417
+ 420
418
+ );
419
+ const hookPath = join(dahliaDir, "cc-hook.mjs");
420
+ const gatewayEntryPoint = resolveGatewayEntryPoint();
421
+ const hookContent = HOOK_SCRIPT_SOURCE.replace(/__GATEWAY_ENTRY_POINT__/g, gatewayEntryPoint);
422
+ if (hookContent.includes("__GATEWAY_ENTRY_POINT__")) {
423
+ throw new Error("Failed to substitute all __GATEWAY_ENTRY_POINT__ placeholders");
424
+ }
425
+ await writeIfAbsent(hookPath, hookContent, force, report, 493);
426
+ const settingsPath = join(cwd, ".claude", "settings.local.json");
427
+ await mergeClaudeSettings(settingsPath);
428
+ report.merged.push(settingsPath);
429
+ return report;
430
+ }
431
+ function resolveGatewayEntryPoint() {
432
+ const candidates = [
433
+ resolve(__dirname, "gatewayDaemon.js"),
434
+ // installed (bundled, dist/ sibling)
435
+ resolve(__dirname, "..", "gatewayDaemon.ts")
436
+ // dev (src/setup → src/)
437
+ ];
438
+ for (const candidate of candidates) {
439
+ try {
440
+ accessSync(candidate);
441
+ return candidate;
442
+ } catch {
443
+ }
444
+ }
445
+ throw new Error(
446
+ `Gateway daemon entry not found (looked in: ${candidates.join(", ")}). Sentinel installation may be incomplete.`
447
+ );
448
+ }
449
+ function checkPortAvailable(port) {
450
+ return new Promise((resolve2, reject) => {
451
+ const server = createServer();
452
+ server.once("error", (err) => {
453
+ if (err.code === "EADDRINUSE") {
454
+ reject(
455
+ new Error(`Port ${port} is in use. Either free the port or run with --port <override>.`)
456
+ );
457
+ } else {
458
+ reject(err);
459
+ }
460
+ });
461
+ server.listen(port, () => {
462
+ server.close(() => resolve2());
463
+ });
464
+ });
465
+ }
466
+ async function writeIfAbsent(path, content, force, report, mode) {
467
+ if (!force) {
468
+ try {
469
+ await access(path);
470
+ report.skipped.push(path);
471
+ return;
472
+ } catch {
473
+ }
474
+ }
475
+ await mkdir2(dirname2(path), { recursive: true });
476
+ await writeFile2(path, content, { mode: mode ?? 420 });
477
+ report.created.push(path);
478
+ }
479
+
480
+ // src/setup/sessionStart.ts
481
+ import { spawn } from "child_process";
482
+ import { homedir } from "os";
483
+
484
+ // src/setup/gatewayReadiness.ts
485
+ var GATEWAY_READY_TIMEOUT_MS = 5e3;
486
+ var GATEWAY_READY_INTERVAL_MS = 100;
487
+ var GATEWAY_READY_PROBE_MS = 500;
488
+ async function waitForGatewayReady(port, options) {
489
+ const timeoutMs = options?.timeoutMs ?? GATEWAY_READY_TIMEOUT_MS;
490
+ const intervalMs = options?.intervalMs ?? GATEWAY_READY_INTERVAL_MS;
491
+ const url = `http://localhost:${port}/api/sentinel/health`;
492
+ const deadline = Date.now() + timeoutMs;
493
+ while (Date.now() < deadline) {
494
+ try {
495
+ const controller = new AbortController();
496
+ const timer = setTimeout(() => controller.abort(), GATEWAY_READY_PROBE_MS);
497
+ try {
498
+ const res = await fetch(url, { signal: controller.signal });
499
+ if (res.ok) return true;
500
+ } finally {
501
+ clearTimeout(timer);
502
+ }
503
+ } catch {
504
+ }
505
+ await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
506
+ }
507
+ return false;
508
+ }
509
+
510
+ // src/setup/sessionStart.ts
511
+ async function runSessionStart(options) {
512
+ const cwd = options?.cwd ?? process.cwd();
513
+ const home = options?.home ?? homedir();
514
+ const port = options?.port ?? 7847;
515
+ const policyPath = discoverPolicy(cwd, home);
516
+ if (!policyPath) {
517
+ return { action: "no-policy" };
518
+ }
519
+ const lock = acquireGatewayLock(home);
520
+ 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);
530
+ }
531
+ await waitForGatewayReady(port);
532
+ return { action: "spawned", pid: child.pid, policyPath };
533
+ }
534
+
535
+ export {
536
+ runInitClaudeCode,
537
+ runSessionStart
538
+ };
539
+ //# sourceMappingURL=chunk-3U3PKD4N.js.map