@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,13 +1,17 @@
1
1
  import {
2
2
  acquireGatewayLock,
3
3
  writePidFile
4
- } from "./chunk-CUJKNIKT.js";
4
+ } from "./chunk-LATQNIRW.js";
5
5
  import {
6
6
  discoverPolicy
7
7
  } from "./chunk-FMZWHT4M.js";
8
8
  import {
9
+ FORBIDDEN_BASENAMES
10
+ } from "./chunk-QIYQWOLO.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,12 +21,16 @@ 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";
27
35
 
28
36
  const GATEWAY_ENTRY_POINT = "__GATEWAY_ENTRY_POINT__";
@@ -74,6 +82,17 @@ function logFallback(entry) {
74
82
  try { appendFileSync(FALLBACK_LOG, line + "\\n"); } catch { /* best effort */ }
75
83
  }
76
84
 
85
+ // Sprint 26 Fix-2 Part 1 \u2014 hardcoded fail-closed FLOOR. tiers.json may ADD
86
+ // high-tier tools but can never DOWNGRADE a floor tool to low (defeats the
87
+ // two-step tier-config rewrite). Checked BEFORE the editable tiers below.
88
+ const FLOOR_HIGH = ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task", "Skill"];
89
+
90
+ // Item D \u2014 operator allowUnknownTools escape hatch, baked at init from the
91
+ // launch policy so it survives gateway-down (the daemon allows these names
92
+ // unconditionally at the name level, so allowing them here keeps down \u2287 up).
93
+ // The marker default is [] \u2014 an un-substituted script stays strictest.
94
+ const ALLOW_UNKNOWN_TOOLS = /* __ALLOW_UNKNOWN_TOOLS__ */ [];
95
+
77
96
  // Tier config uses flat format: { high, low, mcpDefault, unknownDefault } (plan v3.1 spec'd nested but simplified during 5a)
78
97
  function loadTiers() {
79
98
  try {
@@ -90,10 +109,52 @@ function loadTiers() {
90
109
  }
91
110
 
92
111
  function isHighSensitivity(toolName, tiers) {
112
+ if (FLOOR_HIGH.includes(toolName)) return true; // floor: tiers may add high, never downgrade
93
113
  if (tiers.high && tiers.high.includes(toolName)) return true;
94
114
  if (tiers.low && tiers.low.includes(toolName)) return false;
95
115
  if (toolName.startsWith("mcp__")) return tiers.mcpDefault === "high";
96
- return tiers.unknownDefault === "high";
116
+ // Item D \u2014 operator-allowlisted names pass: gateway-up they are translated
117
+ // as known and allowed at the name level, so the escape hatch must survive
118
+ // gateway-down too.
119
+ if (ALLOW_UNKNOWN_TOOLS.includes(toolName)) return false;
120
+ // Item D floor: all other unknown names are always high-tier gateway-down.
121
+ // Deliberately stricter than gateway-up's warn default: the hook cannot
122
+ // persist findings while the daemon is down, so a warn-equivalent here
123
+ // would be allow-UNLOGGED \u2014 the exact F-8 hole. Hard-deny keeps down \u2287 up
124
+ // in both knob modes (same fail-closed stance as Fix-2's read handling),
125
+ // and a tiers.json edit (unknownDefault: "low") cannot reopen it. Named
126
+ // tools can still be tiered low explicitly via tiers.low above.
127
+ return true;
128
+ }
129
+
130
+ // Sprint 26 Fix-2 Part 2 \u2014 gateway-down safe-read exception.
131
+ // INVARIANT: on gateway-down a read is NEVER more permissive than gateway-up.
132
+ // Low-tier file reads (Read/Glob/Grep) fail CLOSED by default; only a
133
+ // structurally-provably-safe target is allowed. The guard is GENERATED from the
134
+ // canonical FORBIDDEN_BASENAMES (\u222A scorer extras), so it can't drift open.
135
+ const SENSITIVE_BASENAMES = ${JSON.stringify(HOOK_SENSITIVE_BASENAMES)};
136
+ const READ_TOOL_PATH_KEYS = { Read: "file_path", Glob: "pattern", Grep: "path" };
137
+
138
+ function isProvablySafeRead(toolName, toolInput, payloadCwd) {
139
+ const key = READ_TOOL_PATH_KEYS[toolName];
140
+ if (!key) return false; // not a path-bearing read tool \u2014 not provably safe
141
+ const raw = toolInput && toolInput[key];
142
+ if (typeof raw !== "string" || raw.length === 0) return false; // no target \u2192 not provable
143
+ if (raw[0] === "/" || raw[0] === "~") return false; // absolute / home \u2192 fail closed
144
+ const cwd = typeof payloadCwd === "string" && payloadCwd ? payloadCwd : process.cwd();
145
+ const cwdNorm = resolve(cwd);
146
+ const norm = resolve(cwd, raw);
147
+ // Must resolve strictly within cwd (rejects any .. escape).
148
+ if (norm !== cwdNorm && !norm.startsWith(cwdNorm + sep)) return false;
149
+ const segments = norm.slice(cwdNorm.length).split(sep).filter(Boolean);
150
+ for (const seg of segments) {
151
+ const low = seg.toLowerCase();
152
+ if (low[0] === ".") return false; // dotfile / dot-dir
153
+ for (const s of SENSITIVE_BASENAMES) {
154
+ if (low.includes(s.toLowerCase())) return false; // conservative sensitive-token match
155
+ }
156
+ }
157
+ return true;
97
158
  }
98
159
 
99
160
  // ---------------------------------------------------------------------------
@@ -111,15 +172,29 @@ if (mode === "pre") {
111
172
  const result = JSON.parse(resp.body);
112
173
  process.stdout.write(JSON.stringify(result), () => process.exit(0));
113
174
  } catch {
114
- // Gateway unreachable \u2014 tiered fail-closed
175
+ // Gateway unreachable \u2014 tiered fail-closed (Sprint 26 Fix-2).
115
176
  const tiers = loadTiers();
177
+ const restore = "Restart your Claude Code session to relaunch the gateway; see ~/.dahlia/gateway-fallback.log.";
116
178
  if (isHighSensitivity(toolName, tiers)) {
117
179
  logFallback({ event: "fail-closed-block", tool: toolName, tier: "high" });
118
180
  process.stderr.write(
119
- \`Sentinel gateway unreachable; high-sensitivity tool "\${toolName}" blocked per fail-closed policy\`,
181
+ \`Sentinel gateway unreachable; high-sensitivity tool "\${toolName}" blocked per fail-closed policy. \${restore}\`,
182
+ () => process.exit(2),
183
+ );
184
+ } else if (
185
+ READ_TOOL_PATH_KEYS[toolName] &&
186
+ !isProvablySafeRead(toolName, payload.tool_input || {}, payload.cwd)
187
+ ) {
188
+ // Low-tier read whose target is not provably safe \u2192 fail CLOSED. The hook
189
+ // carries no policy, so it cannot evaluate the forbid surface; a read it
190
+ // can't prove safe is treated as if the gateway would deny it.
191
+ logFallback({ event: "fail-closed-block", tool: toolName, tier: "low", reason: "read-not-provably-safe" });
192
+ process.stderr.write(
193
+ \`Sentinel gateway unreachable; read by "\${toolName}" blocked (fail-closed: target not provably safe). \${restore}\`,
120
194
  () => process.exit(2),
121
195
  );
122
196
  } else {
197
+ // Provably-safe read, or a non-read low-tier tool (e.g. WebSearch) \u2192 allow.
123
198
  logFallback({ event: "fail-closed-allow", tool: toolName, tier: "low" });
124
199
  process.stdout.write(JSON.stringify({
125
200
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
@@ -322,6 +397,11 @@ agent:
322
397
  policy:
323
398
  allow:
324
399
  actions: [file_read, file_write, tool_invocation, network_request, command_exec]
400
+ # allow.targets is ADVISORY for file_read / file_write / tool_invocation: an
401
+ # access outside this list is logged as a MEDIUM scope_violation but still
402
+ # runs \u2014 Sentinel governs the agent's own tool calls, it is not a filesystem
403
+ # sandbox. forbid.targets below is the hard deny. network_request is the
404
+ # exception: unlisted hosts are DENIED by default (see the networkHosts note).
325
405
  targets:
326
406
  - "src/**"
327
407
  - "test/**"
@@ -378,6 +458,28 @@ policy:
378
458
  - "**/*.pem"
379
459
  - "**/*.key"
380
460
  - "/etc/**"
461
+ # Sprint 26 FIX 1 (A) \u2014 common credential stores. DRIFT: keep in sync with
462
+ # DEFAULT_FORBIDDEN_PATTERNS in defaults.ts (unification tracked separately).
463
+ - "**/.netrc"
464
+ - "**/.npmrc"
465
+ - "**/.git-credentials"
466
+ - "**/.pgpass"
467
+ - "**/.zsh_history"
468
+ - "**/.config/gh/**"
469
+ - "**/.docker/config.json"
470
+ - "**/.gnupg/**"
471
+ - "**/.config/gcloud/**"
472
+ - "**/.kube/**"
473
+ - "**/Library/Keychains/**"
474
+ # Sprint 26 FIX 1 (B) \u2014 Sentinel's own state dir (current path only).
475
+ - "**/.dahlia/**"
476
+ # Sprint 26 FIX 3 \u2014 this policy file and cc's hook-wiring settings files.
477
+ # Agent tool-writes are denied; reads stay allowed via a code-side ceiling
478
+ # exception (not authorable here \u2014 workspace-authored exceptions are
479
+ # dropped by the ceiling merge, by design). DRIFT: keep in sync with
480
+ # DEFAULT_FORBIDDEN_PATTERNS in defaults.ts.
481
+ - "**/.sentinel.yaml"
482
+ - "**/.claude/settings*.json"
381
483
  enforcement:
382
484
  restrictAfter: 3
383
485
  quarantineAfter: 5
@@ -418,7 +520,16 @@ async function runInitClaudeCode(options) {
418
520
  );
419
521
  const hookPath = join(dahliaDir, "cc-hook.mjs");
420
522
  const gatewayEntryPoint = resolveGatewayEntryPoint();
421
- const hookContent = HOOK_SCRIPT_SOURCE.replace(/__GATEWAY_ENTRY_POINT__/g, gatewayEntryPoint);
523
+ let allowUnknownTools = [];
524
+ try {
525
+ const effectivePolicy = await loadPolicy(policyPath);
526
+ allowUnknownTools = effectivePolicy.enforcement?.allowUnknownTools ?? [];
527
+ } catch {
528
+ }
529
+ const hookContent = HOOK_SCRIPT_SOURCE.replace(
530
+ /__GATEWAY_ENTRY_POINT__/g,
531
+ gatewayEntryPoint
532
+ ).replace("/* __ALLOW_UNKNOWN_TOOLS__ */ []", JSON.stringify(allowUnknownTools));
422
533
  if (hookContent.includes("__GATEWAY_ENTRY_POINT__")) {
423
534
  throw new Error("Failed to substitute all __GATEWAY_ENTRY_POINT__ placeholders");
424
535
  }
@@ -536,4 +647,4 @@ export {
536
647
  runInitClaudeCode,
537
648
  runSessionStart
538
649
  };
539
- //# sourceMappingURL=chunk-3U3PKD4N.js.map
650
+ //# sourceMappingURL=chunk-FWIISAZZ.js.map
@@ -1,5 +1,4 @@
1
1
  import {
2
- DEFAULT_FORBIDDEN_PATTERNS,
3
2
  DEFAULT_MAP_PATH,
4
3
  DEFAULT_MEDIUM_DISPOSITION,
5
4
  DEFAULT_OVERLAY_PATH,
@@ -16,13 +15,15 @@ import {
16
15
  removeOverlayDecision,
17
16
  saveMap,
18
17
  saveOverlay,
19
- walkForbiddenInodeRoots
20
- } from "./chunk-6MHWJATS.js";
18
+ unionWithDefaultForbiddenPatterns,
19
+ walkForbiddenInodeRoots,
20
+ withPolicyReadExceptions
21
+ } from "./chunk-QIYQWOLO.js";
21
22
  import {
22
23
  loadPolicy,
23
24
  policyToConfig,
24
25
  policyToRole
25
- } from "./chunk-2FFMYSVC.js";
26
+ } from "./chunk-WLIDSTS4.js";
26
27
  import {
27
28
  publicKeyToBase64,
28
29
  reconstructSigningPayload,
@@ -742,6 +743,12 @@ var AuditTrail = class _AuditTrail {
742
743
  if (finding.softSignal === true) {
743
744
  entry.softSignal = true;
744
745
  }
746
+ if (finding.mentionOnly === true) {
747
+ entry.mentionOnly = true;
748
+ }
749
+ if (finding.dedupKey !== void 0) {
750
+ entry.dedupKey = finding.dedupKey;
751
+ }
745
752
  await this.writeLine(entry);
746
753
  }
747
754
  /**
@@ -6438,12 +6445,16 @@ var Sentinel = class _Sentinel {
6438
6445
  "tool_invocation"
6439
6446
  ],
6440
6447
  allowedTargetPatterns: options.allowedTargetPatterns ?? ["**"],
6441
- // Forbidden-pattern normalization chokepoint (Part A): **/-prepend bare
6442
- // patterns so they match absolute paths in Check 2. Idempotent.
6443
- forbiddenTargetPatterns: (options.forbiddenTargetPatterns ?? DEFAULT_FORBIDDEN_PATTERNS).map(
6444
- normalizeForbiddenPattern
6445
- ),
6446
- exceptions: options.exceptions,
6448
+ // Forbidden-pattern normalization chokepoint (Part A) + Sprint 26 FIX 3B:
6449
+ // the defaults are a floor unioned into every role, not a fallback — a
6450
+ // policy yaml extends but cannot remove them (the `??` form let any
6451
+ // policy-supplied list supersede the defaults entirely).
6452
+ forbiddenTargetPatterns: unionWithDefaultForbiddenPatterns(options.forbiddenTargetPatterns),
6453
+ // Sprint 26 FIX 3 — ceiling-side read carve-out for the self-protection
6454
+ // forbids (policy + hook-wiring files). Appended here, not in the yaml
6455
+ // template: mergeRoles drops workspace exceptions, so this is the only
6456
+ // authorable layer — which is the self-locking property.
6457
+ exceptions: withPolicyReadExceptions(options.exceptions),
6447
6458
  networkHosts: options.networkHosts,
6448
6459
  expectedSchedule: options.expectedSchedule,
6449
6460
  maxEventsPerHour: options.maxEventsPerHour,
@@ -6513,8 +6524,15 @@ var Sentinel = class _Sentinel {
6513
6524
  ...options.role,
6514
6525
  agentId,
6515
6526
  name: options.name,
6516
- // Forbidden-pattern normalization chokepoint (Part A) idempotent.
6517
- forbiddenTargetPatterns: (options.role.forbiddenTargetPatterns ?? DEFAULT_FORBIDDEN_PATTERNS).map(normalizeForbiddenPattern)
6527
+ // Forbidden-pattern normalization chokepoint (Part A) + Sprint 26 FIX 3B
6528
+ // defaults-as-floor union — same semantics as monitor().
6529
+ forbiddenTargetPatterns: unionWithDefaultForbiddenPatterns(
6530
+ options.role.forbiddenTargetPatterns
6531
+ ),
6532
+ // Sprint 26 FIX 3 — same ceiling-side read carve-out as monitor(). The
6533
+ // merged role arriving here keeps ceiling exceptions (workspace ones are
6534
+ // already dropped by mergeRoles); the append is idempotent by target.
6535
+ exceptions: withPolicyReadExceptions(options.role.exceptions)
6518
6536
  };
6519
6537
  if (await this.profileManager.agentExists(agentId)) {
6520
6538
  const store = await this.profileManager.loadAgentProfile(agentId);
@@ -7318,13 +7336,13 @@ var Sentinel = class _Sentinel {
7318
7336
  type: "agent_quarantined",
7319
7337
  agentId,
7320
7338
  agentName: event.agentName ?? agentId,
7321
- description: `Agent ${agentId} is quarantined. All actions blocked.`,
7339
+ description: `Agent ${agentId} is quarantined. All actions blocked. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore access.`,
7322
7340
  evidence: {
7323
7341
  action: event.action,
7324
7342
  target: event.primaryTarget,
7325
7343
  timestamp: event.timestamp
7326
7344
  },
7327
- recommendation: "Agent must be manually released before it can perform any actions.",
7345
+ recommendation: `Agent must be manually released before it can perform any actions. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore access.`,
7328
7346
  timestamp: event.timestamp
7329
7347
  }
7330
7348
  };
@@ -7339,13 +7357,13 @@ var Sentinel = class _Sentinel {
7339
7357
  type: "agent_restricted",
7340
7358
  agentId,
7341
7359
  agentName: event.agentName ?? agentId,
7342
- description: `Agent ${agentId} is restricted. Action '${event.action}' is not permitted in restricted mode.`,
7360
+ description: `Agent ${agentId} is restricted. Action '${event.action}' is not permitted in restricted mode. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore full access.`,
7343
7361
  evidence: {
7344
7362
  action: event.action,
7345
7363
  target: event.primaryTarget,
7346
7364
  timestamp: event.timestamp
7347
7365
  },
7348
- recommendation: "Only file_read and tool_invocation are allowed in restricted mode. Release the agent to restore full access.",
7366
+ recommendation: `Only file_read and tool_invocation are allowed in restricted mode. Run 'npx @tuent/sentinel release --agent=${agentId}' to restore full access.`,
7349
7367
  timestamp: event.timestamp
7350
7368
  }
7351
7369
  };
@@ -7374,6 +7392,17 @@ var Sentinel = class _Sentinel {
7374
7392
  * Sprint 16 Prompt 3 — replaces in-memory blockCounts Map to survive
7375
7393
  * gateway restarts. Same pattern as Sprint 11 sessionCount Shape D fix.
7376
7394
  */
7395
+ /**
7396
+ * Shared escalation-eligibility predicate (Sprint 26 F-5). Single source of
7397
+ * truth for "this finding counts toward the block ladder", extracted from the
7398
+ * formerly-inline copies in getEffectiveBlockCount and maybeEscalate — behavior
7399
+ * of both is identical to before the extraction. Accepts either a
7400
+ * SecurityFinding-shaped object ({type}) or an audit entry ({findingType},
7401
+ * mapped by the caller).
7402
+ */
7403
+ static isEscalationEligible(f) {
7404
+ return _Sentinel.ESCALATION_ELIGIBLE_TYPES.has(f.type) && (f.severity === "HIGH" || f.severity === "CRITICAL") && f.kind === "actionable";
7405
+ }
7377
7406
  async getEffectiveBlockCount(agentId) {
7378
7407
  const modeChanges = await this.query(agentId, { type: "mode_change" });
7379
7408
  const releaseEntry = modeChanges.find((e) => e.mode === "normal");
@@ -7382,16 +7411,31 @@ var Sentinel = class _Sentinel {
7382
7411
  type: "finding",
7383
7412
  ...anchor ? { startTime: anchor } : {}
7384
7413
  });
7385
- return findings.filter(
7386
- (f) => _Sentinel.ESCALATION_ELIGIBLE_TYPES.has(f.findingType) && (f.severity === "HIGH" || f.severity === "CRITICAL") && f.kind === "actionable"
7387
- ).length;
7414
+ const survivors = findings.filter(
7415
+ (f) => _Sentinel.isEscalationEligible({
7416
+ type: f.findingType,
7417
+ severity: f.severity,
7418
+ kind: f.kind
7419
+ }) && f.mentionOnly !== true
7420
+ );
7421
+ const distinctKeys = /* @__PURE__ */ new Set();
7422
+ let keyless = 0;
7423
+ for (const f of survivors) {
7424
+ const key = f.dedupKey;
7425
+ if (typeof key === "string" && key.length > 0) distinctKeys.add(key);
7426
+ else keyless++;
7427
+ }
7428
+ return distinctKeys.size + keyless;
7388
7429
  }
7389
7430
  async maybeEscalate(agentId, finding) {
7390
7431
  const mode = this.getMode(agentId);
7391
7432
  if (mode === "quarantined") return;
7392
- if (finding.severity !== "HIGH" && finding.severity !== "CRITICAL") return;
7393
- if (!_Sentinel.ESCALATION_ELIGIBLE_TYPES.has(finding.type)) return;
7394
- if (finding.kind !== "actionable") return;
7433
+ if (!_Sentinel.isEscalationEligible({
7434
+ type: finding.type,
7435
+ severity: finding.severity,
7436
+ kind: finding.kind
7437
+ }))
7438
+ return;
7395
7439
  const count = await this.getEffectiveBlockCount(agentId);
7396
7440
  if (mode === "restricted") {
7397
7441
  if (count >= this.quarantineThreshold) {
@@ -7426,4 +7470,4 @@ export {
7426
7470
  createCliApproval,
7427
7471
  Sentinel
7428
7472
  };
7429
- //# sourceMappingURL=chunk-QFRDEISP.js.map
7473
+ //# sourceMappingURL=chunk-GRN5P3H2.js.map