@dsh-electron/dsh-plugin-multi-root-workspace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +156 -0
- package/README.md +156 -0
- package/cordis.patch.yml +44 -0
- package/lib/client.js +6512 -0
- package/lib/client.js.map +1 -0
- package/lib/command-BSkrYOAf.js +727 -0
- package/lib/command.js +2 -0
- package/lib/fs-D7UoCb7T.js +152 -0
- package/lib/fs.js +2 -0
- package/lib/index.js +17 -0
- package/lib/registry.js +466 -0
- package/lib/roots-DyT2jgEP.js +345 -0
- package/lib/sandbox-BhuMTDgH.js +243 -0
- package/lib/sandbox.js +2 -0
- package/lib/scope.js +143 -0
- package/lib/types/client/WorkspaceFoldersAction.d.ts +43 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/locales.d.ts +118 -0
- package/lib/types/client/panel-client.d.ts +59 -0
- package/lib/types/client/styles.d.ts +14 -0
- package/lib/types/command.d.ts +66 -0
- package/lib/types/containment.d.ts +36 -0
- package/lib/types/contract.d.ts +164 -0
- package/lib/types/dialects.d.ts +92 -0
- package/lib/types/fs.d.ts +90 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/registry.d.ts +293 -0
- package/lib/types/roots.d.ts +238 -0
- package/lib/types/sandbox.d.ts +65 -0
- package/lib/types/scope.d.ts +120 -0
- package/package.json +145 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
|
|
2
|
+
import { isAbsolute, join, sep } from "node:path";
|
|
3
|
+
import { statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
//#region src/roots.ts
|
|
6
|
+
/**
|
|
7
|
+
* The root vocabulary: identities, the durable record shape, the status a
|
|
8
|
+
* stored record is reported with, and the validation rules every write and
|
|
9
|
+
* every startup re-read passes through.
|
|
10
|
+
*
|
|
11
|
+
* This module is the single home of "what makes a directory a legal additional
|
|
12
|
+
* root" (requirement §15). Both the registry (which persists) and the command
|
|
13
|
+
* and panel surfaces (which report) speak these types, so a rule can never be
|
|
14
|
+
* enforced on one path and forgotten on another.
|
|
15
|
+
*
|
|
16
|
+
* Rules, applied in this order — the first failure names the reason:
|
|
17
|
+
*
|
|
18
|
+
* 1. `not-absolute` — a relative input (after `~` expansion) is a
|
|
19
|
+
* misconfiguration, not a path to guess at.
|
|
20
|
+
* 2. `missing` — the directory does not exist.
|
|
21
|
+
* 3. `not-a-directory` — it exists but is not a directory.
|
|
22
|
+
* 4. `equals-primary` — the canonical candidate IS the session's workspace
|
|
23
|
+
* root, which is already granted by the upstream policy.
|
|
24
|
+
* 5. `primary-overlap` — the candidate lies under the workspace root, or
|
|
25
|
+
* contains it. The union grants nothing new, and the displayed list would
|
|
26
|
+
* stop matching the enforced root set.
|
|
27
|
+
* 6. `duplicate` — another registered root canonicalizes to the same path.
|
|
28
|
+
* 7. `nested` — the candidate lies under a registered root, or contains one.
|
|
29
|
+
* Same reasoning as rule 5, applied to the additional roots.
|
|
30
|
+
*
|
|
31
|
+
* The stored side of the same rules lives in {@link classifyStoredRoots}, which
|
|
32
|
+
* must never throw: a registration that stopped being usable is REPORTED, not
|
|
33
|
+
* deleted, so the operator can see it and remove it deliberately.
|
|
34
|
+
*
|
|
35
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/roots
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Brand one generated uuid as an {@link AdditionalRootId}.
|
|
39
|
+
* @param value - the raw uuid.
|
|
40
|
+
* @returns the branded id.
|
|
41
|
+
*/
|
|
42
|
+
function additionalRootId(value) {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/** A rejected root operation; `code` is the stable contract, `message` the fallback prose. */
|
|
46
|
+
var RootValidationError = class extends Error {
|
|
47
|
+
code;
|
|
48
|
+
detail;
|
|
49
|
+
/**
|
|
50
|
+
* @param code - stable failure code.
|
|
51
|
+
* @param message - human-readable fallback text (English; the panel localizes by code).
|
|
52
|
+
* @param detail - structured context, e.g. the conflicting root.
|
|
53
|
+
*/
|
|
54
|
+
constructor(code, message, detail) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.code = code;
|
|
57
|
+
this.detail = detail;
|
|
58
|
+
this.name = "RootValidationError";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Expand the one shell shorthand an operator may reasonably type, and nothing
|
|
63
|
+
* else: a leading `~` (alone or as `~/…`). Every other relative spelling is
|
|
64
|
+
* left intact so validation can reject it as `not-absolute` instead of
|
|
65
|
+
* silently resolving it against some unrelated process cwd.
|
|
66
|
+
* @param raw - the raw operator input.
|
|
67
|
+
* @param home - the home directory to expand against; defaults to `os.homedir()`.
|
|
68
|
+
* @returns the expanded path (still unvalidated).
|
|
69
|
+
*/
|
|
70
|
+
function expandRootInput(raw, home = homedir()) {
|
|
71
|
+
const trimmed = raw.trim();
|
|
72
|
+
if (trimmed === "~") return home;
|
|
73
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return join(home, trimmed.slice(2));
|
|
74
|
+
return trimmed;
|
|
75
|
+
}
|
|
76
|
+
/** Whether lexical comparison preserves case on this platform. */
|
|
77
|
+
const CASE_SENSITIVE = process.platform !== "win32";
|
|
78
|
+
/** Compare two canonical path spellings under the platform's case convention. */
|
|
79
|
+
function comparable(path) {
|
|
80
|
+
return CASE_SENSITIVE ? path : path.toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
/** Whether two canonical paths name the same directory on this platform. */
|
|
83
|
+
function samePath(left, right) {
|
|
84
|
+
return comparable(left) === comparable(right);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether `candidate` is `root` itself or lies beneath it. Both sides must
|
|
88
|
+
* already be canonical: symlinks are resolved by then, so the lexical test is
|
|
89
|
+
* exact for the alias cases (8.3 names, casing) the fence handles separately.
|
|
90
|
+
* @param candidate - canonical path to test.
|
|
91
|
+
* @param root - canonical root to test against.
|
|
92
|
+
* @returns whether the candidate is contained by the root.
|
|
93
|
+
*/
|
|
94
|
+
function isCanonicallyUnder(candidate, root) {
|
|
95
|
+
const target = comparable(candidate);
|
|
96
|
+
const base = comparable(root);
|
|
97
|
+
if (target === base) return true;
|
|
98
|
+
return target.startsWith(base.endsWith(sep) ? base : base + sep);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Canonicalize a directory path the way the enforcement layers do (`realpath`),
|
|
102
|
+
* leaving an unreachable path untouched so the caller can report it as missing.
|
|
103
|
+
* @param path - the path to canonicalize.
|
|
104
|
+
* @returns the canonical path.
|
|
105
|
+
*/
|
|
106
|
+
function canonicalRoot(path) {
|
|
107
|
+
return canonicalPath(path);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Judge one candidate root and return its canonical spelling.
|
|
111
|
+
* @param raw - the raw operator input (a path, possibly `~`-prefixed).
|
|
112
|
+
* @param context - the primary root and the currently granted roots.
|
|
113
|
+
* @returns the canonical path the caller must store (and record as `recordedPath`).
|
|
114
|
+
* @throws {RootValidationError} with the first rule that failed.
|
|
115
|
+
*/
|
|
116
|
+
function validateRootCandidate(raw, context) {
|
|
117
|
+
const expanded = expandRootInput(raw, context.home);
|
|
118
|
+
if (expanded === "") throw new RootValidationError("not-absolute", "a root path is required");
|
|
119
|
+
if (!isAbsolute(expanded)) throw new RootValidationError("not-absolute", `"${expanded}" is not an absolute path; give an absolute directory or one starting with "~/"`, { reference: expanded });
|
|
120
|
+
const canonical = canonicalRoot(expanded);
|
|
121
|
+
assertDirectory(canonical, expanded);
|
|
122
|
+
const primaryRoot = canonicalRoot(context.primaryRoot);
|
|
123
|
+
if (samePath(canonical, primaryRoot)) throw new RootValidationError("equals-primary", `"${canonical}" is this session's workspace root and is already writable`, {
|
|
124
|
+
conflict: primaryRoot,
|
|
125
|
+
reference: canonical
|
|
126
|
+
});
|
|
127
|
+
if (isCanonicallyUnder(canonical, primaryRoot) || isCanonicallyUnder(primaryRoot, canonical)) throw new RootValidationError("primary-overlap", `"${canonical}" overlaps this session's workspace root "${primaryRoot}"`, {
|
|
128
|
+
conflict: primaryRoot,
|
|
129
|
+
reference: canonical
|
|
130
|
+
});
|
|
131
|
+
for (const existing of context.existing) if (samePath(canonical, existing)) throw new RootValidationError("duplicate", `"${canonical}" is already registered`, {
|
|
132
|
+
conflict: existing,
|
|
133
|
+
reference: canonical
|
|
134
|
+
});
|
|
135
|
+
for (const existing of context.existing) {
|
|
136
|
+
if (isCanonicallyUnder(canonical, existing)) throw new RootValidationError("nested", `"${canonical}" is already covered by the registered root "${existing}"`, {
|
|
137
|
+
conflict: existing,
|
|
138
|
+
reference: canonical
|
|
139
|
+
});
|
|
140
|
+
if (isCanonicallyUnder(existing, canonical)) throw new RootValidationError("nested", `"${canonical}" contains the registered root "${existing}"; remove that root first`, {
|
|
141
|
+
conflict: existing,
|
|
142
|
+
reference: canonical
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return canonical;
|
|
146
|
+
}
|
|
147
|
+
/** Assert that a canonical path is an existing directory. */
|
|
148
|
+
function assertDirectory(canonical, asTyped) {
|
|
149
|
+
let stats;
|
|
150
|
+
try {
|
|
151
|
+
stats = statSync(canonical);
|
|
152
|
+
} catch {
|
|
153
|
+
throw new RootValidationError("missing", `"${asTyped}" does not exist`, { reference: asTyped });
|
|
154
|
+
}
|
|
155
|
+
if (!stats.isDirectory()) throw new RootValidationError("not-a-directory", `"${asTyped}" is not a directory`, { reference: asTyped });
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Re-judge every stored record of one primary root — the startup and refresh
|
|
159
|
+
* path, which must never throw: a registration that stopped being usable is
|
|
160
|
+
* REPORTED, not deleted, so the operator can see it and remove it deliberately.
|
|
161
|
+
*
|
|
162
|
+
* Existence and reachability are the only rules a record may recover from on
|
|
163
|
+
* its own, hence `missing` and `redirected` (kept, withheld) rather than
|
|
164
|
+
* `invalid` (kept, unusable).
|
|
165
|
+
* @param primaryRoot - the session workspace root the records belong to.
|
|
166
|
+
* @param records - the stored records, in registry order.
|
|
167
|
+
* @returns one status per record, in the same order.
|
|
168
|
+
*/
|
|
169
|
+
function classifyStoredRoots(primaryRoot, records) {
|
|
170
|
+
const primary = canonicalRoot(primaryRoot);
|
|
171
|
+
const claimed = [];
|
|
172
|
+
const duplicated = duplicatedIds(records);
|
|
173
|
+
const statuses = [];
|
|
174
|
+
for (const [index, record] of records.entries()) {
|
|
175
|
+
const status = classifyStoredRoot(record, primary, claimed, duplicated.has(record.id), index);
|
|
176
|
+
statuses.push(status);
|
|
177
|
+
if (status.state === "available") claimed.push(status.path);
|
|
178
|
+
}
|
|
179
|
+
return statuses;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The ids that more than one record claims. Computed for the whole list up
|
|
183
|
+
* front, because "this id is duplicated" is a property of the SET: deciding it
|
|
184
|
+
* while walking would make the first occurrence look fine and only the later
|
|
185
|
+
* ones broken, and the first match is precisely the ambiguity being reported.
|
|
186
|
+
*/
|
|
187
|
+
function duplicatedIds(records) {
|
|
188
|
+
const seen = /* @__PURE__ */ new Set();
|
|
189
|
+
const duplicated = /* @__PURE__ */ new Set();
|
|
190
|
+
for (const record of records) if (seen.has(record.id)) duplicated.add(record.id);
|
|
191
|
+
else seen.add(record.id);
|
|
192
|
+
return duplicated;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Judge one stored record against the primary root and the roots claimed before
|
|
196
|
+
* it.
|
|
197
|
+
*
|
|
198
|
+
* Identity and the recorded directory are checked BEFORE the current
|
|
199
|
+
* resolution is used for anything: a record whose path now resolves elsewhere
|
|
200
|
+
* must not have its new (attacker-influenced) target participate in the
|
|
201
|
+
* overlap rules.
|
|
202
|
+
*/
|
|
203
|
+
function classifyStoredRoot(record, primary, claimed, duplicatedId, position) {
|
|
204
|
+
const invalid = (detail) => ({
|
|
205
|
+
...record,
|
|
206
|
+
state: "invalid",
|
|
207
|
+
detail
|
|
208
|
+
});
|
|
209
|
+
if (typeof record.path !== "string" || record.path === "") return invalid("the record has no path");
|
|
210
|
+
if (typeof record.id !== "string" || record.id === "") return invalid("the record has no id");
|
|
211
|
+
if (duplicatedId) return invalid(`the id "${record.id}" is used by more than one record (entry ${position + 1})`);
|
|
212
|
+
if (!isAbsolute(record.path)) return invalid(`"${record.path}" is not an absolute path`);
|
|
213
|
+
const recorded = record.recordedPath;
|
|
214
|
+
if (typeof recorded !== "string" || recorded === "") return invalid("the record does not say which directory it was granted for");
|
|
215
|
+
const canonical = canonicalRoot(record.path);
|
|
216
|
+
if (!samePath(canonical, recorded)) return {
|
|
217
|
+
...record,
|
|
218
|
+
state: "redirected",
|
|
219
|
+
detail: `the path now resolves to "${canonical}" instead of the registered "${recorded}"`
|
|
220
|
+
};
|
|
221
|
+
if (samePath(canonical, primary)) return invalid(`"${canonical}" is this session's workspace root`);
|
|
222
|
+
if (isCanonicallyUnder(canonical, primary) || isCanonicallyUnder(primary, canonical)) return invalid(`"${canonical}" overlaps this session's workspace root`);
|
|
223
|
+
if (claimed.some((existing) => samePath(existing, canonical))) return invalid(`"${canonical}" is registered twice`);
|
|
224
|
+
for (const existing of claimed) if (isCanonicallyUnder(canonical, existing) || isCanonicallyUnder(existing, canonical)) return invalid(`"${canonical}" overlaps the registered root "${existing}"`);
|
|
225
|
+
let isDirectory = false;
|
|
226
|
+
try {
|
|
227
|
+
isDirectory = statSync(canonical).isDirectory();
|
|
228
|
+
} catch {
|
|
229
|
+
isDirectory = false;
|
|
230
|
+
}
|
|
231
|
+
if (!isDirectory) return {
|
|
232
|
+
...record,
|
|
233
|
+
path: canonical,
|
|
234
|
+
state: "missing",
|
|
235
|
+
detail: "the directory is not present right now"
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
...record,
|
|
239
|
+
path: canonical,
|
|
240
|
+
state: "available"
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The canonical roots a status list grants, in registry order.
|
|
245
|
+
* @param statuses - the statuses to project.
|
|
246
|
+
* @returns the canonical paths of every `available` root.
|
|
247
|
+
*/
|
|
248
|
+
function availableRoots(statuses) {
|
|
249
|
+
return statuses.filter((status) => status.state === "available").map((status) => status.path);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Pair every status with the 1-based ordinal the surfaces accept for it. The
|
|
253
|
+
* ordinal belongs to the POSITION, so it stays correct even for records whose
|
|
254
|
+
* id is duplicated or missing — which is exactly the case where a positional
|
|
255
|
+
* removal is the only unambiguous way to delete one of them.
|
|
256
|
+
* @param statuses - the statuses to index.
|
|
257
|
+
* @returns one entry per status, in the same order.
|
|
258
|
+
*/
|
|
259
|
+
function indexedStatuses(statuses) {
|
|
260
|
+
return statuses.map((status, index) => ({
|
|
261
|
+
ordinal: index + 1,
|
|
262
|
+
status
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Resolve one operator-supplied reference against a status list.
|
|
267
|
+
* @param statuses - the statuses to search, in registry order.
|
|
268
|
+
* @param ref - the reference to resolve.
|
|
269
|
+
* @returns the matched status.
|
|
270
|
+
* @throws {RootValidationError} `not-found` when nothing matches.
|
|
271
|
+
*/
|
|
272
|
+
function resolveRootRef(statuses, ref) {
|
|
273
|
+
return statuses[resolveRootIndex(statuses, ref)];
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Resolve one reference to its exact position.
|
|
277
|
+
*
|
|
278
|
+
* Id/path references are accepted only when unique. An `entry` reference also
|
|
279
|
+
* verifies the row identity captured by the surface, so a concurrent reorder
|
|
280
|
+
* cannot turn a click into an operation on a different record.
|
|
281
|
+
*/
|
|
282
|
+
function resolveRootIndex(statuses, ref) {
|
|
283
|
+
const reference = describeRef(ref);
|
|
284
|
+
const matches = (predicate) => {
|
|
285
|
+
const result = [];
|
|
286
|
+
for (const [index, status] of statuses.entries()) if (predicate(status)) result.push(index);
|
|
287
|
+
return result;
|
|
288
|
+
};
|
|
289
|
+
const indices = (() => {
|
|
290
|
+
switch (ref.kind) {
|
|
291
|
+
case "id": return matches((status) => status.id === ref.id);
|
|
292
|
+
case "ordinal": return ref.ordinal >= 1 && ref.ordinal <= statuses.length ? [ref.ordinal - 1] : [];
|
|
293
|
+
case "path": {
|
|
294
|
+
const canonical = canonicalRoot(expandRootInput(ref.path));
|
|
295
|
+
return matches((status) => samePath(status.path, canonical));
|
|
296
|
+
}
|
|
297
|
+
case "entry": {
|
|
298
|
+
const sameEntry = (status) => status.id === ref.id && samePath(status.path, ref.path) && status.addedAt === ref.addedAt;
|
|
299
|
+
const found = matches(sameEntry);
|
|
300
|
+
if (found.length !== 1) return found;
|
|
301
|
+
const expected = ref.ordinal - 1;
|
|
302
|
+
return expected === found[0] ? [expected] : found;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
})();
|
|
306
|
+
if (indices.length === 0) throw new RootValidationError("not-found", `no registered root matches ${reference}`, { reference });
|
|
307
|
+
if (indices.length > 1) throw new RootValidationError("invalid-ref", `${reference} matches more than one registered root; use its displayed number`, { reference });
|
|
308
|
+
return indices[0];
|
|
309
|
+
}
|
|
310
|
+
/** Capture a list row as a mutation-safe reference. */
|
|
311
|
+
function entryRootRef(status, ordinal) {
|
|
312
|
+
return {
|
|
313
|
+
kind: "entry",
|
|
314
|
+
ordinal,
|
|
315
|
+
id: status.id,
|
|
316
|
+
path: status.path,
|
|
317
|
+
addedAt: status.addedAt
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Remove exactly ONE record — the one the reference names — from a status list.
|
|
322
|
+
*
|
|
323
|
+
* Removing by id is rejected when it is ambiguous. Exact entry or ordinal
|
|
324
|
+
* references address one record, so a duplicate-id pair can be cleaned up one
|
|
325
|
+
* entry at a time without turning one operation into several.
|
|
326
|
+
* @param statuses - the statuses to remove from.
|
|
327
|
+
* @param ref - which record to remove.
|
|
328
|
+
* @returns a new list without that record, order preserved.
|
|
329
|
+
* @throws {RootValidationError} `not-found` when the reference matches nothing.
|
|
330
|
+
*/
|
|
331
|
+
function removeStatusAt(statuses, ref) {
|
|
332
|
+
const index = resolveRootIndex(statuses, ref);
|
|
333
|
+
return statuses.filter((_status, position) => position !== index);
|
|
334
|
+
}
|
|
335
|
+
/** Render one reference for messages. */
|
|
336
|
+
function describeRef(ref) {
|
|
337
|
+
switch (ref.kind) {
|
|
338
|
+
case "id": return `id "${ref.id}"`;
|
|
339
|
+
case "ordinal": return `#${ref.ordinal}`;
|
|
340
|
+
case "path": return `"${ref.path}"`;
|
|
341
|
+
case "entry": return `entry #${ref.ordinal} (${ref.path})`;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
export { classifyStoredRoots as a, indexedStatuses as c, resolveRootIndex as d, resolveRootRef as f, canonicalRoot as i, isCanonicallyUnder as l, additionalRootId as n, entryRootRef as o, validateRootCandidate as p, availableRoots as r, expandRootInput as s, RootValidationError as t, removeStatusAt as u };
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
|
|
2
|
+
import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
|
|
3
|
+
//#region src/dialects.ts
|
|
4
|
+
/**
|
|
5
|
+
* Raised when a confined argv cannot be recognized or widened. The provider
|
|
6
|
+
* converts this into the fail-closed `SandboxUnavailableError`: an unrecognized
|
|
7
|
+
* dialect must never degrade into "the primary root was granted after all".
|
|
8
|
+
*/
|
|
9
|
+
var DialectUnrecognizedError = class extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "DialectUnrecognizedError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/** The one SBPL form that carries the writable subpath grants. */
|
|
16
|
+
const SBPL_SUBPATH_FORM = /\(allow file-write\*\s*(?:\(subpath\s+"(?:[^"\\]|\\.)*"\)\s*)+\)/;
|
|
17
|
+
/** One SBPL `(subpath "…")` token inside that form. */
|
|
18
|
+
const SBPL_SUBPATH_TOKEN = /\(subpath\s+("(?:[^"\\]|\\.)*")\)/g;
|
|
19
|
+
/**
|
|
20
|
+
* Split a `confine()` result at the separator by arithmetic, then VERIFY the
|
|
21
|
+
* split: `confine` builds `[...profileArgs, '--', ...argv]`, so the separator
|
|
22
|
+
* sits exactly one element before the caller's argv. A mismatch means the
|
|
23
|
+
* provider no longer wraps argv the way this module assumes.
|
|
24
|
+
* @param confinedArgv - the argv `confine` returned.
|
|
25
|
+
* @param commandArgv - the argv the caller passed to `confine`.
|
|
26
|
+
* @returns the recognized dialect and the split parts.
|
|
27
|
+
*/
|
|
28
|
+
function splitConfined(confinedArgv, commandArgv) {
|
|
29
|
+
const separator = confinedArgv.length - commandArgv.length - 1;
|
|
30
|
+
if (separator < 1 || confinedArgv[separator] !== "--") throw new DialectUnrecognizedError(`confined argv is not "[...profile, --, ...argv]" (separator expected at ${separator}, got ${JSON.stringify(confinedArgv[separator])})`);
|
|
31
|
+
const commandArgs = confinedArgv.slice(separator + 1);
|
|
32
|
+
if (commandArgs.length !== commandArgv.length || commandArgs.some((argument, index) => argument !== commandArgv[index])) throw new DialectUnrecognizedError("confined argv does not end with the caller's argv verbatim");
|
|
33
|
+
const profileArgs = confinedArgv.slice(0, separator);
|
|
34
|
+
return {
|
|
35
|
+
dialect: detectDialect(profileArgs),
|
|
36
|
+
profileArgs,
|
|
37
|
+
separator,
|
|
38
|
+
commandArgs
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Recognize the runner dialect from the profile arguments alone. Exactly one
|
|
43
|
+
* marker must match; zero or several are both unrecognized.
|
|
44
|
+
* @param profileArgs - everything before the separator.
|
|
45
|
+
* @returns the dialect.
|
|
46
|
+
*/
|
|
47
|
+
function detectDialect(profileArgs) {
|
|
48
|
+
const matches = [];
|
|
49
|
+
if (profileArgs.length >= 2 && profileArgs[profileArgs.length - 2] === "-p") matches.push("seatbelt");
|
|
50
|
+
if (profileArgs.includes("--mode")) matches.push("windows-acl");
|
|
51
|
+
if (profileArgs.includes("--rw")) matches.push("landlock");
|
|
52
|
+
if (profileArgs.includes("--ro-bind")) matches.push("bwrap");
|
|
53
|
+
const [first, ...rest] = matches;
|
|
54
|
+
if (first === void 0) throw new DialectUnrecognizedError(`unrecognized sandbox profile arguments: ${JSON.stringify(profileArgs)}`);
|
|
55
|
+
if (rest.length > 0) throw new DialectUnrecognizedError(`ambiguous sandbox profile arguments (${matches.join(", ")}): ${JSON.stringify(profileArgs)}`);
|
|
56
|
+
return first;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Widen one dialect's profile arguments with the additional roots.
|
|
60
|
+
*
|
|
61
|
+
* Only `workspace-write` grants roots, mirroring the upstream mode meaning and
|
|
62
|
+
* the filesystem fence: `read-only` grants nothing, and `danger-full-access`
|
|
63
|
+
* never confines at all (bash and the PTY backend skip `confine` in that mode).
|
|
64
|
+
*
|
|
65
|
+
* A root the dialect ALREADY grants is not granted twice — that keeps this
|
|
66
|
+
* function aligned with the fence's own deduplication and, on bwrap, avoids
|
|
67
|
+
* binding the real `/tmp` over the ephemeral `--tmpfs /tmp` mount.
|
|
68
|
+
* @param dialect - the recognized dialect.
|
|
69
|
+
* @param profileArgs - the profile arguments to widen.
|
|
70
|
+
* @param policy - the per-call policy (its root is the policy root, never a new grant).
|
|
71
|
+
* @param additionalRoots - canonical additional roots, in scope order.
|
|
72
|
+
* @returns the widened profile arguments.
|
|
73
|
+
*/
|
|
74
|
+
function widenProfileArgs(dialect, profileArgs, policy, additionalRoots) {
|
|
75
|
+
if (policy.mode !== "workspace-write") throw new DialectUnrecognizedError(`dialect "${dialect}" grants write roots only under workspace-write, got mode "${policy.mode}"`);
|
|
76
|
+
if (additionalRoots.length === 0) return [...profileArgs];
|
|
77
|
+
switch (dialect) {
|
|
78
|
+
case "seatbelt": return widenSeatbelt(profileArgs, additionalRoots);
|
|
79
|
+
case "bwrap": return widenBwrap(profileArgs, policy.workspaceRoot, additionalRoots);
|
|
80
|
+
case "landlock": return widenLandlock(profileArgs, policy.workspaceRoot, additionalRoots);
|
|
81
|
+
case "windows-acl": return [...profileArgs];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Quote one path exactly the way the upstream Seatbelt profile builder does. */
|
|
85
|
+
function sbplString(path) {
|
|
86
|
+
return `"${path.replaceAll("\\", String.raw`\\`).replaceAll("\"", String.raw`\"`)}"`;
|
|
87
|
+
}
|
|
88
|
+
/** Unquote one SBPL string literal (including its surrounding quotes). */
|
|
89
|
+
function sbplValue(literal) {
|
|
90
|
+
return literal.slice(1, -1).replaceAll(String.raw`\"`, "\"").replaceAll(String.raw`\\`, "\\");
|
|
91
|
+
}
|
|
92
|
+
/** Append the additional roots to the SBPL form that carries the subpath grants. */
|
|
93
|
+
function widenSeatbelt(profileArgs, additionalRoots) {
|
|
94
|
+
const profile = profileArgs[profileArgs.length - 1];
|
|
95
|
+
if (profile === void 0 || profileArgs[profileArgs.length - 2] !== "-p") throw new DialectUnrecognizedError(`seatbelt profile is not "[<sandbox-exec>, …, -p, <SBPL>]": ${JSON.stringify(profileArgs)}`);
|
|
96
|
+
const form = SBPL_SUBPATH_FORM.exec(profile);
|
|
97
|
+
if (form === null) throw new DialectUnrecognizedError(`seatbelt profile has no "(allow file-write* (subpath …))" form to extend: ${JSON.stringify(profile)}`);
|
|
98
|
+
const matched = form[0];
|
|
99
|
+
const granted = new Set([...matched.matchAll(SBPL_SUBPATH_TOKEN)].map((token) => sbplValue(token[1])));
|
|
100
|
+
const added = additionalRoots.filter((root) => !granted.has(root)).map((root) => `(subpath ${sbplString(root)})`);
|
|
101
|
+
if (added.length === 0) return [...profileArgs];
|
|
102
|
+
const widened = profile.slice(0, form.index) + matched.slice(0, -1) + ` ${added.join(" ")})` + profile.slice(form.index + matched.length);
|
|
103
|
+
return [...profileArgs.slice(0, -1), widened];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The observed bwrap bind flag: the flag of the `[flag, <root>, <root>]` triple
|
|
107
|
+
* upstream built for the policy root.
|
|
108
|
+
*/
|
|
109
|
+
function bwrapBindFlag(profileArgs, workspaceRoot) {
|
|
110
|
+
for (let index = 2; index < profileArgs.length; index += 1) {
|
|
111
|
+
if (profileArgs[index] !== workspaceRoot || profileArgs[index - 1] !== workspaceRoot) continue;
|
|
112
|
+
const flag = profileArgs[index - 2];
|
|
113
|
+
if (flag === void 0 || !flag.startsWith("--") || flag.startsWith("--ro")) continue;
|
|
114
|
+
return flag;
|
|
115
|
+
}
|
|
116
|
+
throw new DialectUnrecognizedError(`bwrap profile has no writable bind of the policy root ${JSON.stringify(workspaceRoot)}: ${JSON.stringify(profileArgs)}`);
|
|
117
|
+
}
|
|
118
|
+
/** Append the additional roots as binds, after upstream's own root bind. */
|
|
119
|
+
function widenBwrap(profileArgs, workspaceRoot, additionalRoots) {
|
|
120
|
+
const flag = bwrapBindFlag(profileArgs, workspaceRoot);
|
|
121
|
+
const granted = /* @__PURE__ */ new Set();
|
|
122
|
+
for (let index = 0; index + 2 < profileArgs.length; index += 1) if (profileArgs[index] === flag && profileArgs[index + 1] === profileArgs[index + 2]) granted.add(profileArgs[index + 2]);
|
|
123
|
+
for (let index = 0; index + 1 < profileArgs.length; index += 1) if (profileArgs[index] === "--tmpfs") granted.add(profileArgs[index + 1]);
|
|
124
|
+
const added = additionalRoots.filter((root) => !granted.has(root)).flatMap((root) => [
|
|
125
|
+
flag,
|
|
126
|
+
root,
|
|
127
|
+
root
|
|
128
|
+
]);
|
|
129
|
+
return [...profileArgs, ...added];
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The observed Landlock read-write flag: the flag preceding the policy root in
|
|
133
|
+
* the launcher's grant arguments.
|
|
134
|
+
*/
|
|
135
|
+
function landlockReadWriteFlag(profileArgs, workspaceRoot) {
|
|
136
|
+
for (let index = 1; index < profileArgs.length; index += 1) {
|
|
137
|
+
if (profileArgs[index] !== workspaceRoot) continue;
|
|
138
|
+
const flag = profileArgs[index - 1];
|
|
139
|
+
if (flag === void 0 || !flag.startsWith("--") || flag.startsWith("--ro")) continue;
|
|
140
|
+
return flag;
|
|
141
|
+
}
|
|
142
|
+
throw new DialectUnrecognizedError(`landlock profile has no read-write grant of the policy root ${JSON.stringify(workspaceRoot)}: ${JSON.stringify(profileArgs)}`);
|
|
143
|
+
}
|
|
144
|
+
/** Append the additional roots as read-write grants, after upstream's own root grant. */
|
|
145
|
+
function widenLandlock(profileArgs, workspaceRoot, additionalRoots) {
|
|
146
|
+
const flag = landlockReadWriteFlag(profileArgs, workspaceRoot);
|
|
147
|
+
const granted = /* @__PURE__ */ new Set();
|
|
148
|
+
for (let index = 0; index + 1 < profileArgs.length; index += 1) if (profileArgs[index] === flag) granted.add(profileArgs[index + 1]);
|
|
149
|
+
const added = additionalRoots.filter((root) => !granted.has(root)).flatMap((root) => [flag, root]);
|
|
150
|
+
return [...profileArgs, ...added];
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/sandbox.ts
|
|
154
|
+
/**
|
|
155
|
+
* `MultiRootSandboxProvider`: the kernel-sandbox dialect provider mounted as
|
|
156
|
+
* `ctx.sandbox`.
|
|
157
|
+
*
|
|
158
|
+
* Every confined execution in this composition funnels through `confine()` —
|
|
159
|
+
* the bash executor wraps `['bash','-c',command]`, and the terminal backend
|
|
160
|
+
* wraps its PTY spawn argv — so widening the grant here is what gives bash, PTY,
|
|
161
|
+
* and the fs fence one and the same root set (requirement §13). The bash
|
|
162
|
+
* executor itself is therefore left upstream: it never computes roots.
|
|
163
|
+
*
|
|
164
|
+
* The widening itself lives in `./dialects.ts`: the upstream profile builders
|
|
165
|
+
* are unreachable in the published package, so the additional roots are grafted
|
|
166
|
+
* onto the profile `super.confine` actually produced (recognize the dialect
|
|
167
|
+
* structurally, clone its grant spelling, fail loudly when the shape is not
|
|
168
|
+
* known). This class owns only the policy that decides WHEN to graft:
|
|
169
|
+
*
|
|
170
|
+
* 1. with no additional roots, or under any mode other than `workspace-write`,
|
|
171
|
+
* the upstream result is returned ELEMENT FOR ELEMENT — same argv, same
|
|
172
|
+
* `enforcement`, same `denialSignatures`, same `runnerFailureRules`;
|
|
173
|
+
* 2. `enforcement`, `denialSignatures`, and `runnerFailureRules` are NEVER
|
|
174
|
+
* recomputed here, even when the argv is widened — the bash executor derives
|
|
175
|
+
* its denial and enforcement reporting from them;
|
|
176
|
+
* 3. only `argv` may differ, and a dialect whose shape cannot be extended fails
|
|
177
|
+
* loudly (`SandboxUnavailableError`) instead of silently confining to the
|
|
178
|
+
* primary root alone.
|
|
179
|
+
*
|
|
180
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/sandbox
|
|
181
|
+
*/
|
|
182
|
+
/**
|
|
183
|
+
* Local process-sandbox provider over the multi-root scope. Registers as
|
|
184
|
+
* `ctx.sandbox`; the runner chain, probing, denial dialects, and Windows ACL
|
|
185
|
+
* grant mechanics stay the upstream implementation's.
|
|
186
|
+
*/
|
|
187
|
+
var MultiRootSandboxProvider = class extends LocalSandboxProvider {
|
|
188
|
+
static inject = ["sandboxPolicy", "multiRootScope"];
|
|
189
|
+
/** Whether the Windows ACL limitation has already been reported (once per provider). */
|
|
190
|
+
warnedAboutWindowsAcl = false;
|
|
191
|
+
/**
|
|
192
|
+
* Wrap `argv` so it executes confined under `policy` on this host, granting
|
|
193
|
+
* the scope's additional roots inside the SAME dialect profile.
|
|
194
|
+
*
|
|
195
|
+
* Under `read-only` nothing is added (the mode denies every write, additional
|
|
196
|
+
* roots included), and `danger-full-access` never reaches here at all: the
|
|
197
|
+
* bash executor and the PTY backend both hand their argv straight to the local
|
|
198
|
+
* runtime in that mode. Widening therefore only ever ADDS the scope's
|
|
199
|
+
* additional roots to an upstream `workspace-write` profile.
|
|
200
|
+
* @param argv - the exact argv the caller is about to spawn.
|
|
201
|
+
* @param policy - the file-effect policy this execution runs under.
|
|
202
|
+
* @returns the argv to spawn instead, plus the selected backend's facts.
|
|
203
|
+
*/
|
|
204
|
+
confine(argv, policy) {
|
|
205
|
+
const confined = super.confine(argv, policy);
|
|
206
|
+
if (policy.mode !== "workspace-write") return confined;
|
|
207
|
+
const scope = this.ctx.multiRootScope.resolve(policy);
|
|
208
|
+
if (scope.additionalRoots.length === 0) return confined;
|
|
209
|
+
try {
|
|
210
|
+
const shape = splitConfined(confined.argv, argv);
|
|
211
|
+
if (shape.dialect === "windows-acl") {
|
|
212
|
+
this.warnAboutWindowsAcl(scope.additionalRoots.length);
|
|
213
|
+
return confined;
|
|
214
|
+
}
|
|
215
|
+
const profileArgs = widenProfileArgs(shape.dialect, shape.profileArgs, policy, scope.additionalRoots);
|
|
216
|
+
return {
|
|
217
|
+
...confined,
|
|
218
|
+
argv: [
|
|
219
|
+
...profileArgs,
|
|
220
|
+
"--",
|
|
221
|
+
...argv
|
|
222
|
+
]
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!(error instanceof DialectUnrecognizedError)) throw error;
|
|
226
|
+
throw new SandboxUnavailableError(policy.mode, `multi-root workspace: cannot grant the additional workspace roots [${scope.additionalRoots.join(", ")}] under the primary root ${scope.primaryRoot} — ${error.message}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Report the documented first-release limitation exactly once: the Windows ACL
|
|
231
|
+
* rung grants through per-workspace write SIDs rather than argv paths, so
|
|
232
|
+
* additional roots stay unenforced for confined commands there while the
|
|
233
|
+
* in-process filesystem fence still allows them.
|
|
234
|
+
* @param rootCount - how many additional roots the scope carries.
|
|
235
|
+
*/
|
|
236
|
+
warnAboutWindowsAcl(rootCount) {
|
|
237
|
+
if (this.warnedAboutWindowsAcl) return;
|
|
238
|
+
this.warnedAboutWindowsAcl = true;
|
|
239
|
+
this.ctx.logger.warn(`multi-root workspace: this scope carries ${rootCount} additional workspace root(s), but the Windows ACL runner grants write access per workspace SID and cannot express them; confined bash/PTY runs will not be able to write them, while ctx.fs still can. Kernel-level multi-root on Windows is out of scope for the first release.`);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
//#endregion
|
|
243
|
+
export { MultiRootSandboxProvider as t };
|
package/lib/sandbox.js
ADDED