sentinelayer-cli 0.39.2 → 0.41.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.
Files changed (62) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -3
  3. package/src/agents/ai-governance/tools/base.js +19 -166
  4. package/src/agents/backend/tools/base.js +33 -186
  5. package/src/agents/code-quality/tools/base.js +27 -154
  6. package/src/agents/data-layer/tools/base.js +35 -180
  7. package/src/agents/documentation/tools/base.js +18 -146
  8. package/src/agents/infrastructure/tools/base.js +17 -164
  9. package/src/agents/observability/tools/base.js +35 -180
  10. package/src/agents/performance/tools/base.js +35 -205
  11. package/src/agents/release/tools/base.js +35 -180
  12. package/src/agents/reliability/tools/base.js +35 -180
  13. package/src/agents/security/tools/base.js +33 -187
  14. package/src/agents/shared-tools/domain-base.js +246 -0
  15. package/src/agents/supply-chain/tools/base.js +15 -144
  16. package/src/agents/testing/tools/base.js +33 -184
  17. package/src/cli.js +5 -0
  18. package/src/commands/mcp.js +1 -1
  19. package/src/commands/ring-owner.js +58 -0
  20. package/src/commands/session.js +703 -263
  21. package/src/engram/governance.js +67 -0
  22. package/src/engram/index.js +64 -0
  23. package/src/engram/namespace.js +89 -0
  24. package/src/engram/observations.js +34 -0
  25. package/src/engram/sla.js +104 -0
  26. package/src/engram/store.js +115 -0
  27. package/src/engram/tools.js +146 -0
  28. package/src/engram/trust.js +105 -0
  29. package/src/legacy-cli.js +6 -3
  30. package/src/mcp/registry.js +27 -10
  31. package/src/mcp/session-stdio-server.js +209 -18
  32. package/src/mcp/smoke.js +9 -8
  33. package/src/pocket/ring-owner.js +97 -0
  34. package/src/review/local-review.js +9 -5
  35. package/src/review/omargate-orchestrator.js +111 -14
  36. package/src/review/spec-binding.js +16 -1
  37. package/src/scan/generator.js +1 -1
  38. package/src/session/agent-registry.js +22 -63
  39. package/src/session/checkpoints.js +194 -0
  40. package/src/session/coordination-guidance.js +6 -6
  41. package/src/session/daemon.js +19 -124
  42. package/src/session/file-lease-integrations.js +1300 -0
  43. package/src/session/file-locks.js +967 -464
  44. package/src/session/listener.js +243 -20
  45. package/src/session/listeners.js +71 -80
  46. package/src/session/paths.js +2 -0
  47. package/src/session/recall/embedder.js +181 -0
  48. package/src/session/recall/entities.js +0 -0
  49. package/src/session/recall/index-build.js +38 -0
  50. package/src/session/recall/index-core.js +108 -0
  51. package/src/session/recall/index.js +131 -0
  52. package/src/session/recall/needle-eval.js +279 -0
  53. package/src/session/recall/observation-core.js +183 -0
  54. package/src/session/recall/observations.js +21 -0
  55. package/src/session/recall/provenance.js +153 -0
  56. package/src/session/recall/retrieve.js +374 -0
  57. package/src/session/recall/text.js +0 -0
  58. package/src/session/recall/token-stats.js +81 -0
  59. package/src/session/recap.js +29 -23
  60. package/src/session/remote-hydrate.js +5 -1
  61. package/src/session/sync.js +661 -11
  62. package/src/session/wake/pump.js +8 -3
@@ -0,0 +1,1300 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ import {
7
+ guardFileLeases,
8
+ listFileLocks,
9
+ releaseFileLocksForAgent,
10
+ } from "./file-locks.js";
11
+
12
+ const FILE_LEASE_ENFORCEMENT_SCHEMA_VERSION = "2.0.0";
13
+ const FILE_LEASE_ENFORCEMENT_MANAGER = "sentinelayer-cli";
14
+ const FILE_LEASE_ENFORCEMENT_FEATURE = "session-file-lease-guard";
15
+ const MANAGED_SCRIPT_PATHS = Object.freeze([
16
+ ".sentinelayer/hooks/file-lease-guard.sh",
17
+ ".sentinelayer/hooks/file-lease-guard.ps1",
18
+ ".sentinelayer/hooks/file-lease-exec.sh",
19
+ ".sentinelayer/hooks/file-lease-exec.ps1",
20
+ ]);
21
+ const CLAUDE_HOOK_MATCHER = "Edit|Write|NotebookEdit";
22
+ const VSCODE_GUARD_TASK_LABEL = "SentinelLayer: Guard current file lease";
23
+ const VSCODE_RENEW_TASK_LABEL = "SentinelLayer: Renew current file lease";
24
+ const MAX_HOOK_INPUT_BYTES = 1_048_576;
25
+ const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
26
+ const SAFE_AGENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,31}$/;
27
+
28
+ function normalizeString(value) {
29
+ return String(value || "").trim();
30
+ }
31
+
32
+ function canonicalJson(value) {
33
+ if (Array.isArray(value)) {
34
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
35
+ }
36
+ if (value && typeof value === "object") {
37
+ return `{${Object.keys(value)
38
+ .sort()
39
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
40
+ .join(",")}}`;
41
+ }
42
+ return JSON.stringify(value);
43
+ }
44
+
45
+ function sha256(value) {
46
+ return createHash("sha256").update(String(value), "utf-8").digest("hex");
47
+ }
48
+
49
+ function valueFingerprint(value) {
50
+ return sha256(canonicalJson(value));
51
+ }
52
+
53
+ function workspaceRelative(workspaceRoot, filePath) {
54
+ return path.relative(workspaceRoot, filePath).replaceAll("\\", "/");
55
+ }
56
+
57
+ function normalizeInstallSessionId(value) {
58
+ const normalized = normalizeString(value);
59
+ if (!SAFE_SESSION_ID_PATTERN.test(normalized)) {
60
+ throw new Error(
61
+ "sessionId must be safe for editor hook commands and match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$.",
62
+ );
63
+ }
64
+ return normalized;
65
+ }
66
+
67
+ function normalizeInstallAgentId(value) {
68
+ const normalized = normalizeString(value).toLowerCase();
69
+ if (!SAFE_AGENT_ID_PATTERN.test(normalized)) {
70
+ throw new Error(
71
+ "agentId must be safe for editor hook commands and match ^[A-Za-z0-9][A-Za-z0-9._:-]{0,31}$.",
72
+ );
73
+ }
74
+ return normalized;
75
+ }
76
+
77
+ function stripJsonComments(source) {
78
+ let output = "";
79
+ let inString = false;
80
+ let escaped = false;
81
+ let lineComment = false;
82
+ let blockComment = false;
83
+ for (let index = 0; index < source.length; index += 1) {
84
+ const character = source[index];
85
+ const next = source[index + 1];
86
+ if (lineComment) {
87
+ if (character === "\n" || character === "\r") {
88
+ lineComment = false;
89
+ output += character;
90
+ } else {
91
+ output += " ";
92
+ }
93
+ continue;
94
+ }
95
+ if (blockComment) {
96
+ if (character === "*" && next === "/") {
97
+ output += " ";
98
+ blockComment = false;
99
+ index += 1;
100
+ } else {
101
+ output += character === "\n" || character === "\r" ? character : " ";
102
+ }
103
+ continue;
104
+ }
105
+ if (inString) {
106
+ output += character;
107
+ if (escaped) {
108
+ escaped = false;
109
+ } else if (character === "\\") {
110
+ escaped = true;
111
+ } else if (character === "\"") {
112
+ inString = false;
113
+ }
114
+ continue;
115
+ }
116
+ if (character === "\"") {
117
+ inString = true;
118
+ output += character;
119
+ continue;
120
+ }
121
+ if (character === "/" && next === "/") {
122
+ output += " ";
123
+ lineComment = true;
124
+ index += 1;
125
+ continue;
126
+ }
127
+ if (character === "/" && next === "*") {
128
+ output += " ";
129
+ blockComment = true;
130
+ index += 1;
131
+ continue;
132
+ }
133
+ output += character;
134
+ }
135
+ if (inString || blockComment) {
136
+ throw new Error("JSON/JSONC integration file is truncated.");
137
+ }
138
+ return output;
139
+ }
140
+
141
+ function stripTrailingJsonCommas(source) {
142
+ let output = "";
143
+ let inString = false;
144
+ let escaped = false;
145
+ for (let index = 0; index < source.length; index += 1) {
146
+ const character = source[index];
147
+ if (inString) {
148
+ output += character;
149
+ if (escaped) {
150
+ escaped = false;
151
+ } else if (character === "\\") {
152
+ escaped = true;
153
+ } else if (character === "\"") {
154
+ inString = false;
155
+ }
156
+ continue;
157
+ }
158
+ if (character === "\"") {
159
+ inString = true;
160
+ output += character;
161
+ continue;
162
+ }
163
+ if (character === ",") {
164
+ let lookahead = index + 1;
165
+ while (lookahead < source.length && /\s/u.test(source[lookahead])) {
166
+ lookahead += 1;
167
+ }
168
+ if (source[lookahead] === "}" || source[lookahead] === "]") {
169
+ continue;
170
+ }
171
+ }
172
+ output += character;
173
+ }
174
+ return output;
175
+ }
176
+
177
+ function parseJsonDocument(source, filePath) {
178
+ try {
179
+ return JSON.parse(stripTrailingJsonCommas(stripJsonComments(source)));
180
+ } catch (error) {
181
+ throw new Error(`Unable to safely parse integration JSON at ${filePath}.`, {
182
+ cause: error,
183
+ });
184
+ }
185
+ }
186
+
187
+ async function readJsonObject(filePath) {
188
+ try {
189
+ const source = await fsp.readFile(filePath, "utf-8");
190
+ const parsed = parseJsonDocument(source, filePath);
191
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
192
+ throw new Error(`Integration JSON root must be an object at ${filePath}.`);
193
+ }
194
+ return parsed;
195
+ } catch (error) {
196
+ if (error && typeof error === "object" && error.code === "ENOENT") {
197
+ return {};
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ async function writeFileAtomic(filePath, contents, { mode = 0o600 } = {}) {
204
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
205
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
206
+ try {
207
+ await fsp.writeFile(temporaryPath, contents, {
208
+ encoding: "utf-8",
209
+ mode,
210
+ });
211
+ await fsp.chmod(temporaryPath, mode).catch(() => {});
212
+ await fsp.rename(temporaryPath, filePath);
213
+ await fsp.chmod(filePath, mode).catch(() => {});
214
+ } catch (error) {
215
+ await fsp.rm(temporaryPath, { force: true }).catch(() => {});
216
+ throw error;
217
+ }
218
+ }
219
+
220
+ async function lstatIfPresent(filePath) {
221
+ try {
222
+ return await fsp.lstat(filePath);
223
+ } catch (error) {
224
+ if (error && typeof error === "object" && error.code === "ENOENT") {
225
+ return null;
226
+ }
227
+ throw error;
228
+ }
229
+ }
230
+
231
+ async function assertManagedPathContained(workspaceRoot, targetPath) {
232
+ const relativePath = path.relative(workspaceRoot, targetPath);
233
+ if (
234
+ !relativePath
235
+ || relativePath === ".."
236
+ || relativePath.startsWith(`..${path.sep}`)
237
+ || path.isAbsolute(relativePath)
238
+ ) {
239
+ throw new Error(
240
+ `Managed integration path is outside the workspace: ${targetPath}.`,
241
+ );
242
+ }
243
+ const segments = relativePath.split(path.sep).filter(Boolean);
244
+ let currentPath = workspaceRoot;
245
+ for (let index = 0; index < segments.length; index += 1) {
246
+ currentPath = path.join(currentPath, segments[index]);
247
+ const stat = await lstatIfPresent(currentPath);
248
+ if (!stat) break;
249
+ if (stat.isSymbolicLink()) {
250
+ throw new Error(
251
+ `Managed integration path uses symbolic-link or junction indirection: ${currentPath}; no integration files were changed.`,
252
+ );
253
+ }
254
+ if (index < segments.length - 1 && !stat.isDirectory()) {
255
+ throw new Error(
256
+ `Managed integration ancestor is not a directory: ${currentPath}; no integration files were changed.`,
257
+ );
258
+ }
259
+ }
260
+ }
261
+
262
+ async function acquireIntegrationMutex(workspaceRoot) {
263
+ const mutexPath = path.join(
264
+ workspaceRoot,
265
+ ".sentinelayer",
266
+ "file-lease-integration.lock",
267
+ );
268
+ const mutexDirectory = path.dirname(mutexPath);
269
+ await assertManagedPathContained(workspaceRoot, mutexPath);
270
+ const mutexDirectoryExisted = (await lstatIfPresent(mutexDirectory)) !== null;
271
+ await fsp.mkdir(mutexDirectory, { recursive: true });
272
+ let handle;
273
+ try {
274
+ handle = await fsp.open(mutexPath, "wx", 0o600);
275
+ await handle.writeFile(
276
+ `${JSON.stringify({
277
+ pid: process.pid,
278
+ acquiredAt: new Date().toISOString(),
279
+ })}\n`,
280
+ "utf-8",
281
+ );
282
+ } catch (error) {
283
+ await handle?.close().catch(() => {});
284
+ if (error && typeof error === "object" && error.code === "EEXIST") {
285
+ throw new Error(
286
+ "Another SentinelLayer file-lease integration change is in progress; no integration files were changed.",
287
+ );
288
+ }
289
+ throw error;
290
+ }
291
+ return async () => {
292
+ await handle.close().catch(() => {});
293
+ await fsp.rm(mutexPath, { force: true }).catch(() => {});
294
+ if (!mutexDirectoryExisted) {
295
+ await fsp.rmdir(mutexDirectory).catch(() => {});
296
+ }
297
+ };
298
+ }
299
+
300
+ async function statIfPresent(filePath) {
301
+ try {
302
+ return await fsp.stat(filePath);
303
+ } catch (error) {
304
+ if (error && typeof error === "object" && error.code === "ENOENT") {
305
+ return null;
306
+ }
307
+ throw error;
308
+ }
309
+ }
310
+
311
+ async function snapshotFile(filePath) {
312
+ const stat = await statIfPresent(filePath);
313
+ if (!stat) return { exists: false, contents: null, mode: null };
314
+ if (!stat.isFile()) {
315
+ throw new Error(
316
+ `Managed integration target is not a regular file: ${filePath}; no integration files were changed.`,
317
+ );
318
+ }
319
+ return {
320
+ exists: true,
321
+ contents: await fsp.readFile(filePath),
322
+ mode: stat.mode & 0o777,
323
+ };
324
+ }
325
+
326
+ async function restoreSnapshots(snapshots) {
327
+ for (const [filePath, snapshot] of [...snapshots.entries()].reverse()) {
328
+ if (snapshot.exists) {
329
+ await writeFileAtomic(filePath, snapshot.contents, {
330
+ mode: snapshot.mode || 0o600,
331
+ });
332
+ } else {
333
+ await fsp.rm(filePath, { force: true });
334
+ }
335
+ }
336
+ }
337
+
338
+ function isSentinelayerClaudeGuardHook(hook) {
339
+ const command = normalizeString(hook?.command);
340
+ return (
341
+ hook?.type === "command" &&
342
+ /(?:^|\s)sl\s+session\s+guard-hook(?:\s|$)/u.test(command)
343
+ );
344
+ }
345
+
346
+ function managedClaudeHookDefinition(command) {
347
+ return {
348
+ type: "command",
349
+ command,
350
+ timeout: 30,
351
+ };
352
+ }
353
+
354
+ function mergeClaudeHookSettings(settings, command) {
355
+ const next = {
356
+ ...(settings && typeof settings === "object" ? settings : {}),
357
+ };
358
+ const hooks =
359
+ next.hooks && typeof next.hooks === "object" && !Array.isArray(next.hooks)
360
+ ? { ...next.hooks }
361
+ : {};
362
+ const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
363
+ const hookDefinition = managedClaudeHookDefinition(command);
364
+ for (const group of existing) {
365
+ for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
366
+ if (
367
+ isSentinelayerClaudeGuardHook(hook)
368
+ && canonicalJson(hook) !== canonicalJson(hookDefinition)
369
+ ) {
370
+ throw new Error(
371
+ "An unowned SentinelLayer Claude guard hook already exists; no integration files were changed.",
372
+ );
373
+ }
374
+ }
375
+ }
376
+ let inserted = false;
377
+ const merged = [];
378
+ for (const group of existing) {
379
+ if (!group || typeof group !== "object" || Array.isArray(group)) {
380
+ merged.push(group);
381
+ continue;
382
+ }
383
+ const groupHooks = Array.isArray(group.hooks) ? group.hooks : [];
384
+ const filteredHooks = groupHooks.filter(
385
+ (hook) => !isSentinelayerClaudeGuardHook(hook),
386
+ );
387
+ if (normalizeString(group.matcher) === CLAUDE_HOOK_MATCHER && !inserted) {
388
+ filteredHooks.push(hookDefinition);
389
+ inserted = true;
390
+ }
391
+ if (filteredHooks.length > 0 || !groupHooks.some(isSentinelayerClaudeGuardHook)) {
392
+ merged.push({
393
+ ...group,
394
+ hooks: filteredHooks,
395
+ });
396
+ }
397
+ }
398
+ if (!inserted) {
399
+ merged.push({
400
+ matcher: CLAUDE_HOOK_MATCHER,
401
+ hooks: [hookDefinition],
402
+ });
403
+ }
404
+ hooks.PreToolUse = merged;
405
+ next.hooks = hooks;
406
+ return next;
407
+ }
408
+
409
+ function mergeVsCodeTasks(tasksDocument, { sessionId, agentId }) {
410
+ const next = {
411
+ ...(tasksDocument && typeof tasksDocument === "object" ? tasksDocument : {}),
412
+ version: normalizeString(tasksDocument?.version) || "2.0.0",
413
+ };
414
+ const managedTasks = managedVsCodeTaskDefinitions({ sessionId, agentId });
415
+ const existingTasks = Array.isArray(next.tasks) ? next.tasks : [];
416
+ for (const task of existingTasks) {
417
+ const desired = managedTasks.find(
418
+ (candidate) => candidate.label === normalizeString(task?.label),
419
+ );
420
+ if (desired && canonicalJson(task) !== canonicalJson(desired)) {
421
+ throw new Error(
422
+ `An unowned VS Code task named "${desired.label}" already exists; no integration files were changed.`,
423
+ );
424
+ }
425
+ }
426
+ const retainedTasks = existingTasks.filter(
427
+ (task) => !managedTasks.some(
428
+ (candidate) => canonicalJson(task) === canonicalJson(candidate),
429
+ ),
430
+ );
431
+ retainedTasks.push(...managedTasks);
432
+ next.tasks = retainedTasks;
433
+ return next;
434
+ }
435
+
436
+ function managedVsCodeTaskDefinitions({ sessionId, agentId }) {
437
+ return [
438
+ {
439
+ label: VSCODE_GUARD_TASK_LABEL,
440
+ type: "process",
441
+ command: "sl",
442
+ args: [
443
+ "session",
444
+ "guard",
445
+ sessionId,
446
+ "--agent",
447
+ agentId,
448
+ "--path",
449
+ "${workspaceFolder}",
450
+ "--json",
451
+ "--",
452
+ "${file}",
453
+ ],
454
+ problemMatcher: [],
455
+ presentation: {
456
+ reveal: "silent",
457
+ close: true,
458
+ },
459
+ },
460
+ {
461
+ label: VSCODE_RENEW_TASK_LABEL,
462
+ type: "process",
463
+ command: "sl",
464
+ args: [
465
+ "session",
466
+ "renew",
467
+ sessionId,
468
+ "--agent",
469
+ agentId,
470
+ "--path",
471
+ "${workspaceFolder}",
472
+ "--json",
473
+ "--",
474
+ "${file}",
475
+ ],
476
+ problemMatcher: [],
477
+ },
478
+ ];
479
+ }
480
+
481
+ function posixGuardScript({ sessionId, agentId }) {
482
+ return `#!/usr/bin/env sh
483
+ set -eu
484
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
485
+ WORKSPACE_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
486
+ exec sl session guard ${sessionId} --agent ${agentId} --path "$WORKSPACE_ROOT" --json -- "$@"
487
+ `;
488
+ }
489
+
490
+ function powershellGuardScript({ sessionId, agentId }) {
491
+ return `$ErrorActionPreference = "Stop"
492
+ $WorkspaceRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..\\..")).Path
493
+ $SlCommand = (Get-Command "sl" -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
494
+ & $SlCommand session guard ${sessionId} --agent ${agentId} --path $WorkspaceRoot --json -- @args
495
+ exit $LASTEXITCODE
496
+ `;
497
+ }
498
+
499
+ function posixGuardedExecScript({ sessionId, agentId }) {
500
+ return `#!/usr/bin/env sh
501
+ set -eu
502
+ if [ "$#" -lt 3 ] || [ "$2" != "--" ]; then
503
+ echo "usage: file-lease-exec.sh <target-file> -- <command> [args...]" >&2
504
+ exit 64
505
+ fi
506
+ TARGET_FILE=$1
507
+ shift 2
508
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
509
+ WORKSPACE_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
510
+ GUARD_STATUS=0
511
+ GUARD_OUTPUT=$(sl session guard ${sessionId} --agent ${agentId} --path "$WORKSPACE_ROOT" --json -- "$TARGET_FILE" 2>&1) || GUARD_STATUS=$?
512
+ if [ "$GUARD_STATUS" -ne 0 ]; then
513
+ printf '%s\\n' "$GUARD_OUTPUT" >&2
514
+ exit "$GUARD_STATUS"
515
+ fi
516
+ exec "$@"
517
+ `;
518
+ }
519
+
520
+ function powershellGuardedExecScript({ sessionId, agentId }) {
521
+ return `$ErrorActionPreference = "Stop"
522
+ if ($args.Count -lt 3 -or $args[1] -ne "--") {
523
+ [Console]::Error.WriteLine("usage: file-lease-exec.ps1 <target-file> -- <command> [args...]")
524
+ exit 64
525
+ }
526
+ $TargetFile = $args[0]
527
+ $Command = $args[2]
528
+ $CommandArgs = if ($args.Count -gt 3) { $args[3..($args.Count - 1)] } else { @() }
529
+ $WorkspaceRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..\\..")).Path
530
+ $SlCommand = (Get-Command "sl" -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
531
+ $GuardOutput = @(& $SlCommand session guard ${sessionId} --agent ${agentId} --path $WorkspaceRoot --json -- $TargetFile 2>&1)
532
+ $GuardStatus = $LASTEXITCODE
533
+ if ($GuardStatus -ne 0) {
534
+ foreach ($Line in $GuardOutput) {
535
+ [Console]::Error.WriteLine([string]$Line)
536
+ }
537
+ exit $GuardStatus
538
+ }
539
+ & $Command @CommandArgs
540
+ exit $LASTEXITCODE
541
+ `;
542
+ }
543
+
544
+ export async function installFileLeaseIntegrations(
545
+ sessionId,
546
+ agentId,
547
+ {
548
+ targetPath = process.cwd(),
549
+ listLeases = listFileLocks,
550
+ } = {},
551
+ ) {
552
+ const normalizedSessionId = normalizeInstallSessionId(sessionId);
553
+ const normalizedAgentId = normalizeInstallAgentId(agentId);
554
+ const workspaceRoot = await fsp.realpath(path.resolve(String(targetPath || ".")));
555
+ for (const relativePath of [
556
+ ".claude/settings.local.json",
557
+ ".vscode/tasks.json",
558
+ ".sentinelayer/file-lease-enforcement.json",
559
+ ...MANAGED_SCRIPT_PATHS,
560
+ ]) {
561
+ await assertManagedPathContained(
562
+ workspaceRoot,
563
+ path.resolve(workspaceRoot, relativePath),
564
+ );
565
+ }
566
+ const releaseMutex = await acquireIntegrationMutex(workspaceRoot);
567
+ try {
568
+ const claudeSettingsPath = path.join(
569
+ workspaceRoot,
570
+ ".claude",
571
+ "settings.local.json",
572
+ );
573
+ const vscodeTasksPath = path.join(workspaceRoot, ".vscode", "tasks.json");
574
+ const enforcementConfigPath = path.join(
575
+ workspaceRoot,
576
+ ".sentinelayer",
577
+ "file-lease-enforcement.json",
578
+ );
579
+ const posixScriptPath = path.join(
580
+ workspaceRoot,
581
+ ".sentinelayer",
582
+ "hooks",
583
+ "file-lease-guard.sh",
584
+ );
585
+ const powershellScriptPath = path.join(
586
+ workspaceRoot,
587
+ ".sentinelayer",
588
+ "hooks",
589
+ "file-lease-guard.ps1",
590
+ );
591
+ const posixExecScriptPath = path.join(
592
+ workspaceRoot,
593
+ ".sentinelayer",
594
+ "hooks",
595
+ "file-lease-exec.sh",
596
+ );
597
+ const powershellExecScriptPath = path.join(
598
+ workspaceRoot,
599
+ ".sentinelayer",
600
+ "hooks",
601
+ "file-lease-exec.ps1",
602
+ );
603
+
604
+ const claudeSettings = await readJsonObject(claudeSettingsPath);
605
+ const vscodeTasks = await readJsonObject(vscodeTasksPath);
606
+ const previousEnforcementConfig = await readJsonObject(enforcementConfigPath);
607
+ const previousEnforcementExists =
608
+ (await statIfPresent(enforcementConfigPath)) !== null;
609
+ const hookCommand =
610
+ `sl session guard-hook ${normalizedSessionId} ` +
611
+ `--agent ${normalizedAgentId} --path .`;
612
+ const mergedClaudeSettings = mergeClaudeHookSettings(
613
+ claudeSettings,
614
+ hookCommand,
615
+ );
616
+ const mergedVsCodeTasks = mergeVsCodeTasks(vscodeTasks, {
617
+ sessionId: normalizedSessionId,
618
+ agentId: normalizedAgentId,
619
+ });
620
+ const posixScript = posixGuardScript({
621
+ sessionId: normalizedSessionId,
622
+ agentId: normalizedAgentId,
623
+ });
624
+ const powershellScript = powershellGuardScript({
625
+ sessionId: normalizedSessionId,
626
+ agentId: normalizedAgentId,
627
+ });
628
+ const posixExecScript = posixGuardedExecScript({
629
+ sessionId: normalizedSessionId,
630
+ agentId: normalizedAgentId,
631
+ });
632
+ const powershellExecScript = powershellGuardedExecScript({
633
+ sessionId: normalizedSessionId,
634
+ agentId: normalizedAgentId,
635
+ });
636
+ const scriptTargets = new Map([
637
+ [posixScriptPath, posixScript],
638
+ [powershellScriptPath, powershellScript],
639
+ [posixExecScriptPath, posixExecScript],
640
+ [powershellExecScriptPath, powershellExecScript],
641
+ ]);
642
+ for (const parentPath of new Set([
643
+ path.dirname(claudeSettingsPath),
644
+ path.dirname(vscodeTasksPath),
645
+ path.dirname(enforcementConfigPath),
646
+ path.dirname(posixScriptPath),
647
+ ])) {
648
+ const parentStat = await statIfPresent(parentPath);
649
+ if (parentStat && !parentStat.isDirectory()) {
650
+ throw new Error(
651
+ `Integration parent path is not a directory: ${parentPath}; no integration files were changed.`,
652
+ );
653
+ }
654
+ }
655
+ const previousManifestOwned =
656
+ previousEnforcementConfig?.schemaVersion === FILE_LEASE_ENFORCEMENT_SCHEMA_VERSION
657
+ && previousEnforcementConfig?.managedBy === FILE_LEASE_ENFORCEMENT_MANAGER
658
+ && previousEnforcementConfig?.feature === FILE_LEASE_ENFORCEMENT_FEATURE
659
+ && previousEnforcementConfig?.sessionId === normalizedSessionId
660
+ && previousEnforcementConfig?.holderId === normalizedAgentId;
661
+ const previousManagedFileArtifacts = Array.isArray(
662
+ previousEnforcementConfig?.managedArtifacts?.files,
663
+ )
664
+ ? previousEnforcementConfig.managedArtifacts.files
665
+ : [];
666
+ const previousManagedFiles = new Map(
667
+ previousManagedFileArtifacts.map((artifact) => [
668
+ normalizeString(artifact?.path).replaceAll("\\", "/"),
669
+ normalizeString(artifact?.sha256),
670
+ ]),
671
+ );
672
+ if (previousEnforcementExists) {
673
+ const previousManagedFilePaths = [...previousManagedFiles.keys()];
674
+ const exactPreviousFileSet =
675
+ previousManifestOwned
676
+ && previousManagedFileArtifacts.length === MANAGED_SCRIPT_PATHS.length
677
+ && previousManagedFilePaths.length === MANAGED_SCRIPT_PATHS.length
678
+ && MANAGED_SCRIPT_PATHS.every((expectedPath) =>
679
+ previousManagedFiles.has(expectedPath))
680
+ && [...previousManagedFiles.values()].every((digest) =>
681
+ /^[a-f0-9]{64}$/u.test(digest));
682
+ const previousHook = previousEnforcementConfig?.managedArtifacts?.claudeHook;
683
+ const expectedHook = managedClaudeHookDefinition(hookCommand);
684
+ const exactPreviousHook =
685
+ normalizeString(previousHook?.command) === hookCommand
686
+ && normalizeString(previousHook?.sha256) === valueFingerprint(expectedHook);
687
+ const expectedTasks = managedVsCodeTaskDefinitions({
688
+ sessionId: normalizedSessionId,
689
+ agentId: normalizedAgentId,
690
+ });
691
+ const previousTasks = Array.isArray(
692
+ previousEnforcementConfig?.managedArtifacts?.vscodeTasks,
693
+ )
694
+ ? previousEnforcementConfig.managedArtifacts.vscodeTasks
695
+ : [];
696
+ const previousTaskMap = new Map(
697
+ previousTasks.map((artifact) => [
698
+ normalizeString(artifact?.label),
699
+ normalizeString(artifact?.sha256),
700
+ ]),
701
+ );
702
+ const exactPreviousTasks =
703
+ previousTasks.length === expectedTasks.length
704
+ && previousTaskMap.size === expectedTasks.length
705
+ && expectedTasks.every((task) =>
706
+ previousTaskMap.get(task.label) === valueFingerprint(task));
707
+ if (
708
+ !exactPreviousFileSet
709
+ || !exactPreviousHook
710
+ || !exactPreviousTasks
711
+ || !["installed", "uninstalled"].includes(
712
+ normalizeString(previousEnforcementConfig?.state),
713
+ )
714
+ ) {
715
+ throw new Error(
716
+ "An unowned or invalid file-lease enforcement manifest already exists; no integration files were changed.",
717
+ );
718
+ }
719
+ }
720
+ for (const [scriptPath] of scriptTargets) {
721
+ const existingContents = await readTextIfPresent(scriptPath);
722
+ if (existingContents === null) continue;
723
+ const relativePath = workspaceRelative(workspaceRoot, scriptPath);
724
+ if (
725
+ !previousManifestOwned
726
+ || previousManagedFiles.get(relativePath) !== sha256(existingContents)
727
+ ) {
728
+ throw new Error(
729
+ `An unowned generated script target already exists at ${relativePath}; no integration files were changed.`,
730
+ );
731
+ }
732
+ }
733
+ // Fail before touching editor configuration when the API-backed authority is
734
+ // not actually live. A local hook without its server authority would block
735
+ // edits while providing no valid coordination guarantee.
736
+ await listLeases(normalizedSessionId, { targetPath: workspaceRoot });
737
+ const managedClaudeHook = mergedClaudeSettings.hooks.PreToolUse
738
+ .flatMap((group) => Array.isArray(group?.hooks) ? group.hooks : [])
739
+ .find((hook) => normalizeString(hook?.command) === hookCommand);
740
+ const managedVsCodeTasks = mergedVsCodeTasks.tasks.filter((task) =>
741
+ [VSCODE_GUARD_TASK_LABEL, VSCODE_RENEW_TASK_LABEL].includes(
742
+ normalizeString(task?.label),
743
+ ));
744
+ const reusableInstallId =
745
+ previousEnforcementConfig?.managedBy === FILE_LEASE_ENFORCEMENT_MANAGER
746
+ && previousEnforcementConfig?.feature === FILE_LEASE_ENFORCEMENT_FEATURE
747
+ && previousEnforcementConfig?.sessionId === normalizedSessionId
748
+ && previousEnforcementConfig?.holderId === normalizedAgentId
749
+ ? normalizeString(previousEnforcementConfig.installId)
750
+ : "";
751
+ const enforcementConfig = {
752
+ schemaVersion: FILE_LEASE_ENFORCEMENT_SCHEMA_VERSION,
753
+ managedBy: FILE_LEASE_ENFORCEMENT_MANAGER,
754
+ feature: FILE_LEASE_ENFORCEMENT_FEATURE,
755
+ installId: reusableInstallId || randomUUID(),
756
+ state: "installed",
757
+ authority: "sentinelayer-api",
758
+ localConfigIsAuthority: false,
759
+ sessionId: normalizedSessionId,
760
+ holderId: normalizedAgentId,
761
+ installedAt: new Date().toISOString(),
762
+ integrations: {
763
+ claudePreToolUse: true,
764
+ terminalPreflightScripts: true,
765
+ terminalGuardedExecScripts: true,
766
+ vscodePreflightTasks: true,
767
+ vscodeNativeSaveBlocking: false,
768
+ },
769
+ securityBoundary: {
770
+ rawShellCanBypass: true,
771
+ sameOsUserCanReadCapabilityCache: true,
772
+ hardIsolationRequiresSeparateOsUsersOrContainers: true,
773
+ },
774
+ managedArtifacts: {
775
+ claudeHook: {
776
+ command: hookCommand,
777
+ sha256: valueFingerprint(managedClaudeHook),
778
+ },
779
+ vscodeTasks: managedVsCodeTasks.map((task) => ({
780
+ label: normalizeString(task.label),
781
+ sha256: valueFingerprint(task),
782
+ })),
783
+ files: [
784
+ { path: workspaceRelative(workspaceRoot, posixScriptPath), sha256: sha256(posixScript) },
785
+ { path: workspaceRelative(workspaceRoot, powershellScriptPath), sha256: sha256(powershellScript) },
786
+ { path: workspaceRelative(workspaceRoot, posixExecScriptPath), sha256: sha256(posixExecScript) },
787
+ { path: workspaceRelative(workspaceRoot, powershellExecScriptPath), sha256: sha256(powershellExecScript) },
788
+ ],
789
+ },
790
+ };
791
+
792
+ const mutationTargets = [
793
+ ...scriptTargets.keys(),
794
+ claudeSettingsPath,
795
+ vscodeTasksPath,
796
+ enforcementConfigPath,
797
+ ];
798
+ const snapshots = new Map();
799
+ for (const filePath of mutationTargets) {
800
+ snapshots.set(filePath, await snapshotFile(filePath));
801
+ }
802
+ try {
803
+ // Scripts become available before editor activation; the ownership
804
+ // manifest is committed last. Any failure restores every exact prior byte.
805
+ for (const [scriptPath, contents] of scriptTargets) {
806
+ await writeFileAtomic(scriptPath, contents, { mode: 0o700 });
807
+ }
808
+ await writeFileAtomic(
809
+ claudeSettingsPath,
810
+ `${JSON.stringify(mergedClaudeSettings, null, 2)}\n`,
811
+ { mode: 0o600 },
812
+ );
813
+ await writeFileAtomic(
814
+ vscodeTasksPath,
815
+ `${JSON.stringify(mergedVsCodeTasks, null, 2)}\n`,
816
+ { mode: 0o644 },
817
+ );
818
+ await writeFileAtomic(
819
+ enforcementConfigPath,
820
+ `${JSON.stringify(enforcementConfig, null, 2)}\n`,
821
+ { mode: 0o600 },
822
+ );
823
+ } catch (error) {
824
+ await restoreSnapshots(snapshots);
825
+ throw error;
826
+ }
827
+
828
+ return {
829
+ ok: true,
830
+ sessionId: normalizedSessionId,
831
+ agentId: normalizedAgentId,
832
+ workspaceRoot,
833
+ files: {
834
+ claudeSettingsPath,
835
+ vscodeTasksPath,
836
+ enforcementConfigPath,
837
+ posixScriptPath,
838
+ powershellScriptPath,
839
+ posixExecScriptPath,
840
+ powershellExecScriptPath,
841
+ },
842
+ enforcement: {
843
+ claudeEditsBlockedPreToolUse: true,
844
+ terminalScriptBlocksOnGuardExit: true,
845
+ terminalGuardedExecChecksImmediatelyBeforeSpawn: true,
846
+ vscodeTaskBlocksDependentTasks: true,
847
+ vscodeNativeSaveBlocking: false,
848
+ },
849
+ limitations: [
850
+ "VS Code does not expose a reliable native-save cancellation hook; use the guard task as a preflight or a guarded task dependency.",
851
+ "Processes running as the same OS user can bypass hooks or read local capability material.",
852
+ "Hard enforcement against raw-shell writes requires separate OS users, containers, or a mediated filesystem.",
853
+ ],
854
+ };
855
+ } finally {
856
+ await releaseMutex();
857
+ }
858
+ }
859
+
860
+ function removeManagedClaudeHook(settings, artifact) {
861
+ const next = structuredClone(settings);
862
+ const groups = Array.isArray(next?.hooks?.PreToolUse)
863
+ ? next.hooks.PreToolUse
864
+ : [];
865
+ let removed = false;
866
+ let residual = false;
867
+ for (const group of groups) {
868
+ if (!Array.isArray(group?.hooks)) continue;
869
+ group.hooks = group.hooks.filter((hook) => {
870
+ if (normalizeString(hook?.command) !== normalizeString(artifact?.command)) {
871
+ return true;
872
+ }
873
+ if (valueFingerprint(hook) !== normalizeString(artifact?.sha256)) {
874
+ residual = true;
875
+ return true;
876
+ }
877
+ removed = true;
878
+ return false;
879
+ });
880
+ }
881
+ return { next, removed, residual };
882
+ }
883
+
884
+ function removeManagedVsCodeTasks(tasksDocument, artifacts) {
885
+ const next = structuredClone(tasksDocument);
886
+ const expected = new Map(
887
+ (Array.isArray(artifacts) ? artifacts : []).map((artifact) => [
888
+ normalizeString(artifact?.label),
889
+ normalizeString(artifact?.sha256),
890
+ ]),
891
+ );
892
+ let removed = 0;
893
+ const residuals = [];
894
+ next.tasks = (Array.isArray(next.tasks) ? next.tasks : []).filter((task) => {
895
+ const label = normalizeString(task?.label);
896
+ if (!expected.has(label)) return true;
897
+ if (valueFingerprint(task) !== expected.get(label)) {
898
+ residuals.push(label);
899
+ return true;
900
+ }
901
+ removed += 1;
902
+ return false;
903
+ });
904
+ return { next, removed, residuals };
905
+ }
906
+
907
+ async function readTextIfPresent(filePath) {
908
+ try {
909
+ return await fsp.readFile(filePath, "utf-8");
910
+ } catch (error) {
911
+ if (error && typeof error === "object" && error.code === "ENOENT") {
912
+ return null;
913
+ }
914
+ throw error;
915
+ }
916
+ }
917
+
918
+ export async function uninstallFileLeaseIntegrations(
919
+ sessionId,
920
+ agentId,
921
+ {
922
+ targetPath = process.cwd(),
923
+ listLeases = listFileLocks,
924
+ releaseAgentLeases = releaseFileLocksForAgent,
925
+ } = {},
926
+ ) {
927
+ const normalizedSessionId = normalizeInstallSessionId(sessionId);
928
+ const normalizedAgentId = normalizeInstallAgentId(agentId);
929
+ const workspaceRoot = await fsp.realpath(path.resolve(String(targetPath || ".")));
930
+ for (const relativePath of [
931
+ ".claude/settings.local.json",
932
+ ".vscode/tasks.json",
933
+ ".sentinelayer/file-lease-enforcement.json",
934
+ ...MANAGED_SCRIPT_PATHS,
935
+ ]) {
936
+ await assertManagedPathContained(
937
+ workspaceRoot,
938
+ path.resolve(workspaceRoot, relativePath),
939
+ );
940
+ }
941
+ const releaseMutex = await acquireIntegrationMutex(workspaceRoot);
942
+ try {
943
+ const enforcementConfigPath = path.join(
944
+ workspaceRoot,
945
+ ".sentinelayer",
946
+ "file-lease-enforcement.json",
947
+ );
948
+ const manifest = await readJsonObject(enforcementConfigPath);
949
+ if (
950
+ manifest?.schemaVersion !== FILE_LEASE_ENFORCEMENT_SCHEMA_VERSION
951
+ || manifest?.managedBy !== FILE_LEASE_ENFORCEMENT_MANAGER
952
+ || manifest?.feature !== FILE_LEASE_ENFORCEMENT_FEATURE
953
+ || manifest?.sessionId !== normalizedSessionId
954
+ || manifest?.holderId !== normalizedAgentId
955
+ || !normalizeString(manifest?.installId)
956
+ ) {
957
+ throw new Error(
958
+ "A matching SentinelLayer file-lease ownership manifest is required; no integration files were changed.",
959
+ );
960
+ }
961
+ const managedFiles = Array.isArray(manifest?.managedArtifacts?.files)
962
+ ? manifest.managedArtifacts.files
963
+ : [];
964
+ const managedFilePaths = managedFiles.map((artifact) =>
965
+ normalizeString(artifact?.path).replaceAll("\\", "/"));
966
+ const exactManagedPathSet =
967
+ managedFilePaths.length === MANAGED_SCRIPT_PATHS.length
968
+ && new Set(managedFilePaths).size === MANAGED_SCRIPT_PATHS.length
969
+ && MANAGED_SCRIPT_PATHS.every((expectedPath) =>
970
+ managedFilePaths.includes(expectedPath))
971
+ && managedFiles.every((artifact) =>
972
+ /^[a-f0-9]{64}$/u.test(normalizeString(artifact?.sha256)));
973
+ if (!exactManagedPathSet) {
974
+ throw new Error(
975
+ "The SentinelLayer file-lease ownership manifest has an invalid managed-script set; no integration files were changed.",
976
+ );
977
+ }
978
+ const expectedHookCommand =
979
+ `sl session guard-hook ${normalizedSessionId} ` +
980
+ `--agent ${normalizedAgentId} --path .`;
981
+ const expectedHook = managedClaudeHookDefinition(expectedHookCommand);
982
+ const manifestHook = manifest?.managedArtifacts?.claudeHook;
983
+ if (
984
+ normalizeString(manifestHook?.command) !== expectedHookCommand
985
+ || normalizeString(manifestHook?.sha256) !== valueFingerprint(expectedHook)
986
+ ) {
987
+ throw new Error(
988
+ "The SentinelLayer file-lease ownership manifest has an invalid Claude hook fingerprint; no integration files were changed.",
989
+ );
990
+ }
991
+ const expectedTasks = managedVsCodeTaskDefinitions({
992
+ sessionId: normalizedSessionId,
993
+ agentId: normalizedAgentId,
994
+ });
995
+ const manifestTasks = Array.isArray(manifest?.managedArtifacts?.vscodeTasks)
996
+ ? manifest.managedArtifacts.vscodeTasks
997
+ : [];
998
+ const manifestTaskMap = new Map(
999
+ manifestTasks.map((artifact) => [
1000
+ normalizeString(artifact?.label),
1001
+ normalizeString(artifact?.sha256),
1002
+ ]),
1003
+ );
1004
+ const exactManagedTaskSet =
1005
+ manifestTasks.length === expectedTasks.length
1006
+ && manifestTaskMap.size === expectedTasks.length
1007
+ && expectedTasks.every((task) =>
1008
+ manifestTaskMap.get(task.label) === valueFingerprint(task));
1009
+ if (!exactManagedTaskSet) {
1010
+ throw new Error(
1011
+ "The SentinelLayer file-lease ownership manifest has an invalid VS Code task set; no integration files were changed.",
1012
+ );
1013
+ }
1014
+
1015
+ const release = await releaseAgentLeases(
1016
+ normalizedSessionId,
1017
+ normalizedAgentId,
1018
+ {
1019
+ reason: "guard_uninstall",
1020
+ targetPath: workspaceRoot,
1021
+ },
1022
+ );
1023
+ if (
1024
+ release?.authority?.authoritative !== true
1025
+ || release?.failures?.length > 0
1026
+ || release?.unresolvedKnown !== true
1027
+ || release?.unresolved?.length > 0
1028
+ ) {
1029
+ return {
1030
+ ok: false,
1031
+ uninstalled: false,
1032
+ reason: "lease_release_incomplete",
1033
+ sessionId: normalizedSessionId,
1034
+ agentId: normalizedAgentId,
1035
+ release,
1036
+ activeLeases: release?.unresolved || [],
1037
+ residuals: [],
1038
+ };
1039
+ }
1040
+ const activeLeases = await listLeases(normalizedSessionId, {
1041
+ targetPath: workspaceRoot,
1042
+ });
1043
+ if (activeLeases.length > 0) {
1044
+ return {
1045
+ ok: false,
1046
+ uninstalled: false,
1047
+ reason: "active_session_leases",
1048
+ sessionId: normalizedSessionId,
1049
+ agentId: normalizedAgentId,
1050
+ release,
1051
+ activeLeases,
1052
+ residuals: [],
1053
+ };
1054
+ }
1055
+
1056
+ const claudeSettingsPath = path.join(
1057
+ workspaceRoot,
1058
+ ".claude",
1059
+ "settings.local.json",
1060
+ );
1061
+ const vscodeTasksPath = path.join(workspaceRoot, ".vscode", "tasks.json");
1062
+ const claudeSettings = await readJsonObject(claudeSettingsPath);
1063
+ const vscodeTasks = await readJsonObject(vscodeTasksPath);
1064
+ const claudeRemoval = removeManagedClaudeHook(
1065
+ claudeSettings,
1066
+ manifest?.managedArtifacts?.claudeHook,
1067
+ );
1068
+ const vscodeRemoval = removeManagedVsCodeTasks(
1069
+ vscodeTasks,
1070
+ manifest?.managedArtifacts?.vscodeTasks,
1071
+ );
1072
+ const residuals = [];
1073
+ if (claudeRemoval.residual) {
1074
+ residuals.push({
1075
+ artifact: "claudeHook",
1076
+ reason: "fingerprint_mismatch",
1077
+ });
1078
+ }
1079
+ for (const label of vscodeRemoval.residuals) {
1080
+ residuals.push({
1081
+ artifact: "vscodeTask",
1082
+ label,
1083
+ reason: "fingerprint_mismatch",
1084
+ });
1085
+ }
1086
+
1087
+ const removableFiles = [];
1088
+ for (const artifact of managedFiles) {
1089
+ const relativePath = normalizeString(artifact?.path).replaceAll("\\", "/");
1090
+ const resolvedPath = path.resolve(workspaceRoot, relativePath);
1091
+ await assertManagedPathContained(workspaceRoot, resolvedPath);
1092
+ if (
1093
+ !relativePath
1094
+ || (
1095
+ resolvedPath !== workspaceRoot
1096
+ && !resolvedPath.startsWith(`${workspaceRoot}${path.sep}`)
1097
+ )
1098
+ ) {
1099
+ residuals.push({
1100
+ artifact: "file",
1101
+ path: relativePath,
1102
+ reason: "path_outside_workspace",
1103
+ });
1104
+ continue;
1105
+ }
1106
+ const contents = await readTextIfPresent(resolvedPath);
1107
+ if (contents === null) continue;
1108
+ if (sha256(contents) !== normalizeString(artifact?.sha256)) {
1109
+ residuals.push({
1110
+ artifact: "file",
1111
+ path: relativePath,
1112
+ reason: "fingerprint_mismatch",
1113
+ });
1114
+ continue;
1115
+ }
1116
+ removableFiles.push(resolvedPath);
1117
+ }
1118
+
1119
+ if (claudeRemoval.removed) {
1120
+ await writeFileAtomic(
1121
+ claudeSettingsPath,
1122
+ `${JSON.stringify(claudeRemoval.next, null, 2)}\n`,
1123
+ { mode: 0o600 },
1124
+ );
1125
+ }
1126
+ if (vscodeRemoval.removed > 0) {
1127
+ await writeFileAtomic(
1128
+ vscodeTasksPath,
1129
+ `${JSON.stringify(vscodeRemoval.next, null, 2)}\n`,
1130
+ { mode: 0o644 },
1131
+ );
1132
+ }
1133
+ for (const removablePath of removableFiles) {
1134
+ await assertManagedPathContained(workspaceRoot, removablePath);
1135
+ await fsp.rm(removablePath, { force: true });
1136
+ }
1137
+
1138
+ const state = residuals.length > 0 ? "residual" : "uninstalled";
1139
+ const completedAt = new Date().toISOString();
1140
+ const nextManifest = {
1141
+ ...manifest,
1142
+ state,
1143
+ uninstalledAt: completedAt,
1144
+ uninstallResult: {
1145
+ removedClaudeHook: claudeRemoval.removed,
1146
+ removedVsCodeTasks: vscodeRemoval.removed,
1147
+ removedFiles: removableFiles.map((filePath) =>
1148
+ workspaceRelative(workspaceRoot, filePath)),
1149
+ residuals,
1150
+ },
1151
+ };
1152
+ await writeFileAtomic(
1153
+ enforcementConfigPath,
1154
+ `${JSON.stringify(nextManifest, null, 2)}\n`,
1155
+ { mode: 0o600 },
1156
+ );
1157
+ return {
1158
+ ok: residuals.length === 0,
1159
+ uninstalled: residuals.length === 0,
1160
+ reason: residuals.length === 0 ? "uninstalled" : "residual_artifacts",
1161
+ sessionId: normalizedSessionId,
1162
+ agentId: normalizedAgentId,
1163
+ release,
1164
+ activeLeases: [],
1165
+ residuals,
1166
+ files: nextManifest.uninstallResult,
1167
+ };
1168
+ } finally {
1169
+ await releaseMutex();
1170
+ }
1171
+ }
1172
+
1173
+ async function readHookInput(stream = process.stdin) {
1174
+ let totalBytes = 0;
1175
+ const chunks = [];
1176
+ for await (const chunk of stream) {
1177
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
1178
+ totalBytes += buffer.length;
1179
+ if (totalBytes > MAX_HOOK_INPUT_BYTES) {
1180
+ throw new Error("Claude hook input exceeded the 1 MiB safety limit.");
1181
+ }
1182
+ chunks.push(buffer);
1183
+ }
1184
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
1185
+ if (!raw) {
1186
+ throw new Error("Claude hook input is required.");
1187
+ }
1188
+ try {
1189
+ return JSON.parse(raw);
1190
+ } catch (error) {
1191
+ throw new Error("Claude hook input was not valid JSON.", { cause: error });
1192
+ }
1193
+ }
1194
+
1195
+ function hookFilePath(payload) {
1196
+ const toolInput =
1197
+ payload?.tool_input && typeof payload.tool_input === "object"
1198
+ ? payload.tool_input
1199
+ : {};
1200
+ return normalizeString(
1201
+ toolInput.file_path ||
1202
+ toolInput.notebook_path ||
1203
+ toolInput.path,
1204
+ );
1205
+ }
1206
+
1207
+ export function safeFileLeaseErrorMessage(error) {
1208
+ return (
1209
+ normalizeString(error?.message)
1210
+ .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]+/giu, "bearer [REDACTED]")
1211
+ .replace(
1212
+ /\b(?:leaseToken|authorization|token|secret|password|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token)\b["']?\s*[:=]\s*["']?[^"'\s,;}\]]+["']?/giu,
1213
+ "credential=[REDACTED]",
1214
+ )
1215
+ .replace(/\b[A-Za-z0-9_-]{43,128}\b/gu, "[REDACTED]")
1216
+ .slice(0, 500) || "authoritative file-lease guard failed"
1217
+ );
1218
+ }
1219
+
1220
+ export function formatFileLeaseDenialSummary(result) {
1221
+ const denials = Array.isArray(result?.denials) ? result.denials : [];
1222
+ if (denials.length === 0) {
1223
+ return "authoritative guard did not grant the edit";
1224
+ }
1225
+ return denials
1226
+ .map((denial) => {
1227
+ const file = normalizeString(denial?.path) || "(unknown path)";
1228
+ const reason = normalizeString(denial?.reason) || "denied";
1229
+ const holder = normalizeString(
1230
+ denial?.lease?.holderId || denial?.lease?.agentId,
1231
+ );
1232
+ const intent = normalizeString(
1233
+ denial?.lease?.intent || denial?.lease?.purpose || denial?.lease?.note,
1234
+ );
1235
+ const expiresAt = normalizeString(
1236
+ denial?.lease?.expiresAt
1237
+ || denial?.lease?.validUntil
1238
+ || denial?.lease?.expires_at
1239
+ || denial?.lease?.valid_until,
1240
+ );
1241
+ const details = [
1242
+ holder ? `held by ${holder}` : "",
1243
+ intent ? `intent: ${intent}` : "",
1244
+ expiresAt ? `expires ${expiresAt}` : "",
1245
+ ].filter(Boolean);
1246
+ return `${file}: ${reason}${details.length > 0 ? ` (${details.join(", ")})` : ""}`;
1247
+ })
1248
+ .join("; ");
1249
+ }
1250
+
1251
+ export async function runFileLeaseGuardHook({
1252
+ sessionId,
1253
+ agentId,
1254
+ targetPath = process.cwd(),
1255
+ inputPayload = null,
1256
+ inputStream = process.stdin,
1257
+ errorStream = process.stderr,
1258
+ guard = guardFileLeases,
1259
+ } = {}) {
1260
+ try {
1261
+ const payload = inputPayload || (await readHookInput(inputStream));
1262
+ const filePath = hookFilePath(payload);
1263
+ if (!filePath) {
1264
+ throw new Error("Claude edit hook did not include a target file path.");
1265
+ }
1266
+ const result = await guard(sessionId, agentId, [filePath], {
1267
+ targetPath,
1268
+ });
1269
+ if (result?.authoritative !== true) {
1270
+ throw new Error(
1271
+ "File-lease guard did not return an authoritative API decision.",
1272
+ );
1273
+ }
1274
+ if (!result?.allowed) {
1275
+ const message = formatFileLeaseDenialSummary(result);
1276
+ errorStream.write(`[SentinelLayer] Edit blocked: ${message}\n`);
1277
+ return { exitCode: 2, allowed: false, result };
1278
+ }
1279
+ return { exitCode: 0, allowed: true, result };
1280
+ } catch (error) {
1281
+ const message = safeFileLeaseErrorMessage(error);
1282
+ errorStream.write(`[SentinelLayer] Edit blocked: ${message}\n`);
1283
+ return {
1284
+ exitCode: 2,
1285
+ allowed: false,
1286
+ error: {
1287
+ code: normalizeString(error?.code) || "FILE_LEASE_GUARD_FAILED",
1288
+ message,
1289
+ },
1290
+ };
1291
+ }
1292
+ }
1293
+
1294
+ export {
1295
+ CLAUDE_HOOK_MATCHER,
1296
+ VSCODE_GUARD_TASK_LABEL,
1297
+ VSCODE_RENEW_TASK_LABEL,
1298
+ mergeClaudeHookSettings,
1299
+ mergeVsCodeTasks,
1300
+ };