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.
- package/README.md +4 -4
- package/package.json +2 -3
- package/src/agents/ai-governance/tools/base.js +19 -166
- package/src/agents/backend/tools/base.js +33 -186
- package/src/agents/code-quality/tools/base.js +27 -154
- package/src/agents/data-layer/tools/base.js +35 -180
- package/src/agents/documentation/tools/base.js +18 -146
- package/src/agents/infrastructure/tools/base.js +17 -164
- package/src/agents/observability/tools/base.js +35 -180
- package/src/agents/performance/tools/base.js +35 -205
- package/src/agents/release/tools/base.js +35 -180
- package/src/agents/reliability/tools/base.js +35 -180
- package/src/agents/security/tools/base.js +33 -187
- package/src/agents/shared-tools/domain-base.js +246 -0
- package/src/agents/supply-chain/tools/base.js +15 -144
- package/src/agents/testing/tools/base.js +33 -184
- package/src/cli.js +5 -0
- package/src/commands/mcp.js +1 -1
- package/src/commands/ring-owner.js +58 -0
- package/src/commands/session.js +703 -263
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +64 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +115 -0
- package/src/engram/tools.js +146 -0
- package/src/engram/trust.js +105 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- package/src/mcp/smoke.js +9 -8
- package/src/pocket/ring-owner.js +97 -0
- package/src/review/local-review.js +9 -5
- package/src/review/omargate-orchestrator.js +111 -14
- package/src/review/spec-binding.js +16 -1
- package/src/scan/generator.js +1 -1
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +194 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon.js +19 -124
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/listener.js +243 -20
- package/src/session/listeners.js +71 -80
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +29 -23
- package/src/session/remote-hydrate.js +5 -1
- package/src/session/sync.js +661 -11
- 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
|
+
};
|