@dsh-electron/dsh-plugin-multi-root-workspace 0.1.11 → 0.1.13

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 (52) hide show
  1. package/CHANGELOG.en.md +25 -1
  2. package/CHANGELOG.md +25 -1
  3. package/README.en.md +10 -8
  4. package/README.md +10 -8
  5. package/cordis.patch.yml +7 -2
  6. package/lib/auto-review.js +1 -1
  7. package/lib/client.js +197 -74
  8. package/lib/client.js.map +1 -1
  9. package/lib/{command-BGdz7VBH.js → command-B9855H7y.js} +3 -2
  10. package/lib/command.js +1 -1
  11. package/lib/{compat-BiW-m_q0.js → compat-Bg2HFVMX.js} +3 -0
  12. package/lib/compat.js +1 -1
  13. package/lib/{sandbox-CXk5h-ct.js → dialects-Dr4rDR-H.js} +6 -264
  14. package/lib/{fs-BTXySwSG.js → fs-CfzJq2wd.js} +23 -8
  15. package/lib/fs.js +1 -1
  16. package/lib/index.js +7 -6
  17. package/lib/{instructions-MePoRigy.js → instructions-KsUwUFE0.js} +53 -37
  18. package/lib/instructions.js +1 -1
  19. package/lib/lsp.js +19 -16
  20. package/lib/native-win32-2waCRkPI.js +312 -0
  21. package/lib/process-fence.js +62 -12
  22. package/lib/protocol-CJOvAvOS.js +652 -0
  23. package/lib/{registry-KSsmU393.js → registry-Bv431TfO.js} +717 -74
  24. package/lib/registry-operation-C2ZV3Jhw.js +116 -0
  25. package/lib/registry.js +3 -2
  26. package/lib/{review-context-AH-SXHmj.js → review-context-NDpKeMCc.js} +1 -1
  27. package/lib/review-context.js +1 -1
  28. package/lib/sandbox-C-vCJCp3.js +278 -0
  29. package/lib/sandbox.js +1 -1
  30. package/lib/scope.js +101 -11
  31. package/lib/skills.js +48 -29
  32. package/lib/types/authority/native-win32.d.ts +7 -0
  33. package/lib/types/authority/native.d.ts +23 -0
  34. package/lib/types/authority/protocol.d.ts +70 -0
  35. package/lib/types/authority/recovery.d.ts +58 -0
  36. package/lib/types/client/RootSwitcher.d.ts +6 -3
  37. package/lib/types/client/files-tree/adapter.d.ts +63 -1
  38. package/lib/types/compat/authority-storage.d.ts +6 -0
  39. package/lib/types/compat/dsh-version.d.ts +4 -4
  40. package/lib/types/compat/open-in-app-device.d.ts +1 -1
  41. package/lib/types/compat/process-entrypoints.d.ts +2 -0
  42. package/lib/types/compat/process-profile.d.ts +3 -0
  43. package/lib/types/compat/sandbox-confine.d.ts +2 -0
  44. package/lib/types/fs.d.ts +4 -1
  45. package/lib/types/instructions.d.ts +0 -47
  46. package/lib/types/registry-operation.d.ts +31 -0
  47. package/lib/types/registry.d.ts +145 -6
  48. package/lib/types/scope.d.ts +28 -0
  49. package/lib/types/skills/catalog.d.ts +3 -0
  50. package/lib/types/skills/pool.d.ts +1 -1
  51. package/lib/workspace-files.js +63 -7
  52. package/package.json +38 -31
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ //#region src/registry-operation.ts
4
+ /**
5
+ * Host-local operation context after ordered Registry authority admission.
6
+ * This is Host-only state, never a wire permit or a reusable cached grant.
7
+ */
8
+ const operations = new AsyncLocalStorage();
9
+ function currentRegistryOperation(proof) {
10
+ const frame = operations.getStore();
11
+ return frame?.active === true && frame.proof === proof ? frame.operation : void 0;
12
+ }
13
+ /** Capture by value: neither later publication nor a caller can mutate admission. */
14
+ function freezeOperationScope(scope) {
15
+ return Object.freeze({
16
+ ...scope,
17
+ additionalRoots: Object.freeze([...scope.additionalRoots]),
18
+ roots: Object.freeze(scope.roots.map((root) => Object.freeze({ ...root }))),
19
+ writableRoots: Object.freeze([...scope.writableRoots]),
20
+ deniedRoots: Object.freeze([...scope.deniedRoots])
21
+ });
22
+ }
23
+ /** Execute outside the authority queue; completion also expires async descendants. */
24
+ async function runRegistryOperation(scope, kind, authorityEpoch, revision, body, proof, mode) {
25
+ let active = true;
26
+ const operation = Object.freeze({
27
+ identity: Object.freeze({
28
+ operationId: randomUUID(),
29
+ kind,
30
+ authorityEpoch,
31
+ revision,
32
+ mode,
33
+ primaryRoot: scope.primaryRoot
34
+ }),
35
+ get scope() {
36
+ if (!active) throw new Error("multi-root registry: operation admission has expired");
37
+ return scope;
38
+ }
39
+ });
40
+ const frame = {
41
+ proof,
42
+ operation,
43
+ active: true,
44
+ profiles: []
45
+ };
46
+ try {
47
+ return await operations.run(frame, () => body(operation));
48
+ } finally {
49
+ active = false;
50
+ frame.active = false;
51
+ }
52
+ }
53
+ /** Optional Registry seam for public consumers; the legacy composition stays exact. */
54
+ async function withRegistryRead(ctx, primary, kind, body) {
55
+ const registry = ctx.get("multiRootRegistry");
56
+ return registry?.shared ? registry.withOperation({
57
+ workspaceRoot: primary,
58
+ mode: "read-only"
59
+ }, kind, () => body()) : body();
60
+ }
61
+ async function withRegistryAdmission(ctx, policy, kind, body) {
62
+ const registry = ctx.get("multiRootRegistry");
63
+ return registry?.shared ? registry.withOperation(policy, kind, () => body()) : body();
64
+ }
65
+ /** Preserve a legacy synchronous entry while granting only the safe primary-root fallback. */
66
+ function runPrimaryOperation(scope, mode, proof, body) {
67
+ let active = true;
68
+ const frame = {
69
+ proof,
70
+ operation: Object.freeze({
71
+ identity: Object.freeze({
72
+ operationId: randomUUID(),
73
+ kind: "process",
74
+ authorityEpoch: void 0,
75
+ revision: 0,
76
+ mode,
77
+ primaryRoot: scope.primaryRoot
78
+ }),
79
+ get scope() {
80
+ if (!active) throw new Error("operation admission has expired");
81
+ return scope;
82
+ }
83
+ }),
84
+ active: true,
85
+ profiles: []
86
+ };
87
+ const finish = () => {
88
+ active = false;
89
+ frame.active = false;
90
+ };
91
+ try {
92
+ const result = operations.run(frame, body);
93
+ if (typeof result?.then === "function") return Promise.resolve(result).finally(finish);
94
+ finish();
95
+ return result;
96
+ } catch (error) {
97
+ finish();
98
+ throw error;
99
+ }
100
+ }
101
+ /** Only profiles produced within this exact admission can reach a sync spawn. */
102
+ function rememberOperationProfile(proof, argv, capped) {
103
+ const frame = operations.getStore();
104
+ if (frame?.active !== true || frame.proof !== proof) return;
105
+ if (frame.profiles.length >= 128) throw new Error("too many confined profiles in one operation");
106
+ frame.profiles.push({
107
+ argv: [...argv],
108
+ capped
109
+ });
110
+ }
111
+ function operationPermitsProfile(proof, argv, capped) {
112
+ const frame = operations.getStore();
113
+ return frame?.active === true && frame.proof === proof && frame.profiles.some((profile) => profile.capped === capped && profile.argv.length <= argv.length && profile.argv.every((arg, i) => argv[i] === arg));
114
+ }
115
+ //#endregion
116
+ export { runPrimaryOperation as a, withRegistryRead as c, rememberOperationProfile as i, freezeOperationScope as n, runRegistryOperation as o, operationPermitsProfile as r, withRegistryAdmission as s, currentRegistryOperation as t };
package/lib/registry.js CHANGED
@@ -1,2 +1,3 @@
1
- import { a as MultiRootRegistry, c as multiRootDomainSpec, i as MAX_ALIAS_LENGTH, n as DOMAIN_NAME, o as REGISTRY_CONTENDED_MESSAGE, r as MAX_ADDITIONAL_ROOTS_CEILING, s as TABLE_NAME, t as DEFAULT_MAX_ADDITIONAL_ROOTS } from "./registry-KSsmU393.js";
2
- export { DEFAULT_MAX_ADDITIONAL_ROOTS, DOMAIN_NAME, MAX_ADDITIONAL_ROOTS_CEILING, MAX_ALIAS_LENGTH, MultiRootRegistry, MultiRootRegistry as default, REGISTRY_CONTENDED_MESSAGE, TABLE_NAME, multiRootDomainSpec };
1
+ import { a as MultiRootRegistry, c as multiRootDomainSpec, i as MAX_ALIAS_LENGTH, n as DOMAIN_NAME, o as REGISTRY_CONTENDED_MESSAGE, r as MAX_ADDITIONAL_ROOTS_CEILING, s as TABLE_NAME, t as DEFAULT_MAX_ADDITIONAL_ROOTS } from "./registry-Bv431TfO.js";
2
+ import { o as nativeHostAvailability } from "./protocol-CJOvAvOS.js";
3
+ export { DEFAULT_MAX_ADDITIONAL_ROOTS, DOMAIN_NAME, MAX_ADDITIONAL_ROOTS_CEILING, MAX_ALIAS_LENGTH, MultiRootRegistry, MultiRootRegistry as default, REGISTRY_CONTENDED_MESSAGE, TABLE_NAME, multiRootDomainSpec, nativeHostAvailability };
@@ -1,5 +1,5 @@
1
1
  import { t as effectiveDirectory } from "./session-directory-BLOstut1.js";
2
- import { s as touchedPathOfToolCall } from "./instructions-MePoRigy.js";
2
+ import { s as touchedPathOfToolCall } from "./instructions-KsUwUFE0.js";
3
3
  import { l as isCanonicallyUnder } from "./roots-Ca_W1S9n.js";
4
4
  import { createHash } from "node:crypto";
5
5
  import { resolve } from "node:path";
@@ -1,2 +1,2 @@
1
- import { t as MultiRootReviewContext } from "./review-context-AH-SXHmj.js";
1
+ import { t as MultiRootReviewContext } from "./review-context-NDpKeMCc.js";
2
2
  export { MultiRootReviewContext, MultiRootReviewContext as default };
@@ -0,0 +1,278 @@
1
+ import { a as parseWindowsProfile, i as isWindowsNetworkOrDevicePath, n as assertWindowsDenyCapacity, t as DialectUnrecognizedError } from "./windows-runner-contract-B5R2K3mM.js";
2
+ import { a as splitConfined, c as mapConfined, i as restrictProfileArgs, l as widenConfined, o as widenProfileArgs, r as graftProfileArgs, t as denyProfileArgs } from "./dialects-Dr4rDR-H.js";
3
+ import { brandMultiRootProvider } from "./providers.js";
4
+ import { SandboxUnavailableError, canonicalPath } from "@deepseek-ai/dsh-sandbox";
5
+ import { fileURLToPath } from "node:url";
6
+ import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
7
+ //#region src/windows-profile.ts
8
+ /** Windows runner argv and capability identity. No Win32 calls at module load. */
9
+ /** Recognize the upstream runner, then select our ambient deny-token path.
10
+ *
11
+ * `protectedHome` is a diagnostics label only: when it names one of the deny
12
+ * roots it is forwarded as `--protected-home` so the runner can word its
13
+ * hardlink/scan remediation for the protected `$DSH_HOME`. It never changes
14
+ * which roots are denied or how.
15
+ */
16
+ function restrictWindowsProfile(profile, deniedRoots, protectedHome) {
17
+ assertWindowsDenyCapacity(deniedRoots.length);
18
+ if (profile.length < 2 || profile[0] === void 0 || profile[1]?.startsWith("--")) throw new DialectUnrecognizedError("Windows ACL runner invocation is not [node, runner, ...flags]");
19
+ const parsed = parseWindowsProfile(profile.slice(2));
20
+ if (deniedRoots.length === 0) throw new DialectUnrecognizedError("Windows ambient restriction requires at least one deny root");
21
+ for (const root of [parsed.workspace, ...deniedRoots]) if (isWindowsNetworkOrDevicePath(root)) throw new DialectUnrecognizedError(`Windows ACL confinement cannot cover network or device paths: ${root}`);
22
+ const label = protectedHome !== void 0 && deniedRoots.some((root) => root.toLowerCase() === protectedHome.toLowerCase()) ? ["--protected-home", protectedHome] : [];
23
+ return [
24
+ profile[0],
25
+ fileURLToPath(new URL("./windows-runner.js", import.meta.url)),
26
+ "--upstream-runner",
27
+ profile[1],
28
+ "--workspace",
29
+ parsed.workspace,
30
+ "--temp",
31
+ parsed.temp,
32
+ "--mode",
33
+ "danger-full-access",
34
+ ...deniedRoots.flatMap((root) => ["--deny-root", root]),
35
+ ...label
36
+ ];
37
+ }
38
+ function widenWindowsProfile(profile, roots, deniedRoots, primaryRoot) {
39
+ if (profile.length < 2 || profile[0] === void 0 || profile[1]?.startsWith("--")) throw new DialectUnrecognizedError("Windows ACL runner invocation is not [node, runner, ...flags]");
40
+ const parsed = parseWindowsProfile(profile.slice(2));
41
+ for (const root of [parsed.workspace, ...roots]) if (isWindowsNetworkOrDevicePath(root)) throw new DialectUnrecognizedError(`Windows ACL confinement cannot cover network or device paths: ${root}`);
42
+ if (primaryRoot !== void 0 && canonicalPath(parsed.workspace) !== primaryRoot) throw new DialectUnrecognizedError("Windows ACL profile workspace does not match the policy root");
43
+ {
44
+ const granted = parsed.mode === "workspace-write" ? [
45
+ parsed.workspace,
46
+ parsed.temp,
47
+ ...roots
48
+ ] : roots;
49
+ const covered = deniedRoots.filter((root) => granted.some((target) => windowsContained(root, target) || windowsContained(target, root)));
50
+ if (covered.length > 0) throw new DialectUnrecognizedError(`Windows ACL cannot deny [${covered.join(", ")}] inside a writable grant — the restricted-token model only grants`);
51
+ }
52
+ if (roots.length === 0) return [...profile];
53
+ return [
54
+ profile[0],
55
+ fileURLToPath(new URL("./windows-runner.js", import.meta.url)),
56
+ "--upstream-runner",
57
+ profile[1],
58
+ ...profile.slice(2),
59
+ ...roots.flatMap((root) => ["--additional-root", root])
60
+ ];
61
+ }
62
+ /**
63
+ * Containment over Windows SPELLINGS, not the host OS: the profile this
64
+ * function rewrites may be produced for a Windows host while the check runs
65
+ * in tests on POSIX, so the comparison cannot borrow the host's separator or
66
+ * case rules.
67
+ */
68
+ function windowsContained(candidate, root) {
69
+ const normalize = (path) => path.replaceAll("/", "\\").replace(/\\+$/, "").toLowerCase();
70
+ const target = normalize(candidate);
71
+ const base = normalize(root);
72
+ return target === base || target.startsWith(`${base}\\`);
73
+ }
74
+ //#endregion
75
+ //#region src/sandbox.ts
76
+ /**
77
+ * Local process-sandbox provider over the multi-root scope. Registers as
78
+ * `ctx.sandbox`; the runner chain, probing, denial dialects, and Windows ACL
79
+ * grant mechanics stay the upstream implementation's.
80
+ */
81
+ var MultiRootSandboxProvider = class extends LocalSandboxProvider {
82
+ static inject = ["sandboxPolicy", "multiRootScope"];
83
+ constructor(ctx, config) {
84
+ super(ctx, config);
85
+ brandMultiRootProvider(this, "sandbox");
86
+ }
87
+ /**
88
+ * Wrap `argv` so it executes confined under `policy` on this host, granting
89
+ * the scope's additional roots inside the SAME dialect profile.
90
+ *
91
+ * Under `read-only` nothing is added (the mode denies every write, additional
92
+ * roots included), and `danger-full-access` never reaches here at all: the
93
+ * bash executor and the PTY backend both hand their argv straight to the local
94
+ * runtime in that mode. Widening therefore only ever ADDS the scope's
95
+ * additional roots to an upstream `workspace-write` profile.
96
+ *
97
+ * The return shape is the INSTALLED upstream provider's: synchronous on a
98
+ * release with a synchronous `confine`, a promise on a release with an
99
+ * asynchronous one. `widenConfined` preserves whichever it is — see
100
+ * `./compat/sandbox-confine.ts`.
101
+ * @param argv - the exact argv the caller is about to spawn.
102
+ * @param policy - the file-effect policy this execution runs under.
103
+ * @param signal - cancellation, forwarded to releases that accept it.
104
+ * @returns the argv to spawn instead, plus the selected backend's facts.
105
+ */
106
+ confine(argv, policy, signal) {
107
+ const scope = this.ctx.multiRootScope.resolve(policy);
108
+ const remember = (facts) => {
109
+ this.ctx.multiRootScope.rememberProfile(facts.argv);
110
+ return facts;
111
+ };
112
+ if (policy.mode === "workspace-write" && scope.authority === "unavailable") {
113
+ const prefix = this.clampedExecution(policy, scope.deniedRoots, signal);
114
+ return widenConfined(() => prefix, (facts) => remember({
115
+ ...facts,
116
+ argv: [...facts.argv, ...argv]
117
+ }), argv, policy, signal);
118
+ }
119
+ const base = super.confine;
120
+ return widenConfined(base.bind(this), (confined) => remember(this.widenForScope(confined, argv, policy, scope)), argv, policy, signal);
121
+ }
122
+ /**
123
+ * Graft the scope's effective grants and denies onto one upstream `confine`
124
+ * result.
125
+ *
126
+ * Pure with respect to the upstream call: it only ever reads the scope and
127
+ * rewrites `argv`. `enforcement`, `denialSignatures` and `runnerFailureRules`
128
+ * are passed through untouched, because the bash executor derives its denial
129
+ * and enforcement reporting from them.
130
+ *
131
+ * Mode behavior (B9, R2): `workspace-write` widens the grant with the
132
+ * effective writable roots AND denies any restricted root the upstream
133
+ * grants could reach (a `read-only` root registered under the platform
134
+ * temp). `read-only` grafts the fixed `read-write` roots back onto the
135
+ * deny-all baseline — the three-state matrix's standout cell — and then
136
+ * applies the SAME deny pass: a grafted writable parent must not silently
137
+ * swallow a denied child (Seatbelt appends the deny after the allow, bwrap
138
+ * adds a mandatory `--ro-bind`, Landlock / windows-acl refuse the
139
+ * unrepresentable subtraction). `danger-full-access` never reaches
140
+ * here (consumers bypass `confine`); its deny semantics live in
141
+ * {@link restrictedArgvPrefix}.
142
+ * @param confined - the upstream result for this call.
143
+ * @param argv - the caller's argv, needed to re-split the wrapped form.
144
+ * @param policy - the per-call policy.
145
+ * @param scope - the authorization snapshot taken at dispatch.
146
+ * @returns the upstream result element for element, or one with a rewritten argv.
147
+ */
148
+ widenForScope(confined, argv, policy, scope) {
149
+ if (scope.authority === "unavailable" || policy.mode !== "workspace-write" && policy.mode !== "read-only") return confined;
150
+ const writable = scope.writableRoots;
151
+ const denied = scope.deniedRoots;
152
+ if (writable.length === 0 && (denied.length === 0 || policy.mode === "read-only")) return confined;
153
+ try {
154
+ const shape = splitConfined(confined.argv, argv);
155
+ if (shape.dialect === "windows-acl") return {
156
+ ...confined,
157
+ argv: [
158
+ ...widenWindowsProfile(shape.profileArgs, writable, denied, scope.primaryRoot),
159
+ "--",
160
+ ...argv
161
+ ]
162
+ };
163
+ const widened = policy.mode === "workspace-write" ? widenProfileArgs(shape.dialect, shape.profileArgs, policy, writable) : graftProfileArgs(shape.dialect, shape.profileArgs, writable);
164
+ const profileArgs = denyProfileArgs(shape.dialect, widened, denied);
165
+ return {
166
+ ...confined,
167
+ argv: [
168
+ ...profileArgs,
169
+ "--",
170
+ ...argv
171
+ ]
172
+ };
173
+ } catch (error) {
174
+ if (!(error instanceof DialectUnrecognizedError)) throw error;
175
+ throw new SandboxUnavailableError(policy.mode, `multi-root workspace: cannot apply the additional workspace roots (writable: [${writable.join(", ")}], denied: [${denied.join(", ")}]) under the primary root ${scope.primaryRoot} — ${error.message}`);
176
+ }
177
+ }
178
+ /**
179
+ * Build the runner-prefix that confines an ambient-writable execution to
180
+ * everything EXCEPT `deniedRoots` — the `danger-full-access` + explicit
181
+ * `read-only` cell, which the shell, PTY and PTC consumers never route
182
+ * through `confine()` at all.
183
+ *
184
+ * The restricted profile does not depend on the callee's argv, so the prefix
185
+ * is computed once per call here and the synchronous `ctx.subprocess.spawn`
186
+ * seam only has to prepend it. The upstream profile builders stay private:
187
+ * this probes `confine` under `read-only` for the runner's argument skeleton
188
+ * and rewrites the file-effect part per dialect. Windows selects the
189
+ * standalone ambient deny-token runner. Landlock cannot express
190
+ * "everything except", so this combination selects bubblewrap; unusable
191
+ * runners fail without spawning an unconfined command.
192
+ *
193
+ * The return preserves the installed `confine` shape: a synchronous result
194
+ * on a synchronous release, a promise on an asynchronous one.
195
+ * @param policy - the caller's policy (mode must be `danger-full-access`).
196
+ * @param deniedRoots - canonical roots this execution must not write.
197
+ * @param signal - cancellation, forwarded to releases that accept it.
198
+ * @param protectedHome - diagnostics label: which deny root is the
199
+ * protected `$DSH_HOME`. Forwarded to the Windows runner only when it is
200
+ * a member of `deniedRoots`; it never changes authorization.
201
+ * @returns the argv prefix, ending at the `--` separator.
202
+ */
203
+ restrictedArgvPrefix(policy, deniedRoots, signal, protectedHome) {
204
+ const result = this.restrictedExecution(policy, deniedRoots, signal, protectedHome);
205
+ return mapConfined(result, (confined) => confined.argv);
206
+ }
207
+ /** Full-access restriction and the actual selected backend's diagnostics. */
208
+ restrictedExecution(policy, deniedRoots, signal, protectedHome) {
209
+ const probePolicy = {
210
+ mode: "read-only",
211
+ workspaceRoot: policy.workspaceRoot,
212
+ ...policy.sessionId === void 0 ? {} : { sessionId: policy.sessionId }
213
+ };
214
+ const probe = super.confine.call(this, ["true"], probePolicy, signal);
215
+ const restrict = (confined) => {
216
+ try {
217
+ const shape = splitConfined(confined.argv, ["true"]);
218
+ const argv = [...shape.dialect === "windows-acl" ? restrictWindowsProfile(shape.profileArgs, deniedRoots, protectedHome) : restrictProfileArgs(shape.dialect, shape.profileArgs, deniedRoots), "--"];
219
+ return shape.dialect === "landlock" ? {
220
+ argv,
221
+ enforcement: "full",
222
+ denialSignatures: ["read-only file system"],
223
+ runnerFailureRules: [{ fatalSignatures: ["bwrap: "] }]
224
+ } : {
225
+ ...confined,
226
+ argv
227
+ };
228
+ } catch (error) {
229
+ if (!(error instanceof DialectUnrecognizedError)) throw error;
230
+ throw new SandboxUnavailableError(policy.mode, `multi-root workspace: cannot confine an ambient execution away from the denied roots [${deniedRoots.join(", ")}] — ${error.message}`);
231
+ }
232
+ };
233
+ return widenConfined(() => probe, (facts) => {
234
+ const result = restrict(facts);
235
+ this.ctx.multiRootScope.rememberProfile(result.argv);
236
+ return result;
237
+ }, ["true"], probePolicy, signal);
238
+ }
239
+ /**
240
+ * Limit an execution with unknown registry authority to the primary root.
241
+ * Shared temp grants are unsafe: another process may have registered a
242
+ * read-only root beneath them. Harvest the upstream read-only skeleton and
243
+ * graft only the primary root, retaining diagnostics and the sync/async shape.
244
+ * Explicit denies still win; an unrepresentable overlap refuses launch.
245
+ * @param policy - the caller's execution policy.
246
+ * @param deniedRoots - known negative boundaries, including protected home.
247
+ * @param signal - cancellation forwarded to upstream.
248
+ * @returns the confined prefix ending at `--`, without the caller's command.
249
+ */
250
+ clampedExecution(policy, deniedRoots, signal) {
251
+ const probePolicy = {
252
+ mode: "read-only",
253
+ workspaceRoot: policy.workspaceRoot,
254
+ ...policy.sessionId === void 0 ? {} : { sessionId: policy.sessionId }
255
+ };
256
+ const probe = super.confine.call(this, ["true"], probePolicy, signal);
257
+ const clamp = (confined) => {
258
+ try {
259
+ const shape = splitConfined(confined.argv, ["true"]);
260
+ const profileArgs = shape.dialect === "windows-acl" ? widenWindowsProfile(shape.profileArgs, [canonicalPath(policy.workspaceRoot)], deniedRoots, canonicalPath(policy.workspaceRoot)) : denyProfileArgs(shape.dialect, graftProfileArgs(shape.dialect, shape.profileArgs, [canonicalPath(policy.workspaceRoot)]), deniedRoots);
261
+ return {
262
+ ...confined,
263
+ argv: [...profileArgs, "--"]
264
+ };
265
+ } catch (error) {
266
+ if (!(error instanceof DialectUnrecognizedError)) throw error;
267
+ throw new SandboxUnavailableError(policy.mode, `multi-root workspace: cannot limit an execution to the primary root while the registry authority is unavailable — ${error.message}`);
268
+ }
269
+ };
270
+ return widenConfined(() => probe, (facts) => {
271
+ const result = clamp(facts);
272
+ this.ctx.multiRootScope.rememberProfile(result.argv);
273
+ return result;
274
+ }, ["true"], probePolicy, signal);
275
+ }
276
+ };
277
+ //#endregion
278
+ export { MultiRootSandboxProvider as t };
package/lib/sandbox.js CHANGED
@@ -1,3 +1,3 @@
1
- import { t as MultiRootSandboxProvider } from "./sandbox-CXk5h-ct.js";
2
1
  import "./providers.js";
2
+ import { t as MultiRootSandboxProvider } from "./sandbox-C-vCJCp3.js";
3
3
  export { MultiRootSandboxProvider, MultiRootSandboxProvider as default };
package/lib/scope.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { i as rememberOperationProfile, r as operationPermitsProfile, t as currentRegistryOperation } from "./registry-operation-C2ZV3Jhw.js";
1
2
  import { t as aroundTool } from "./tool-context-CpgIFcCB.js";
2
3
  import { r as effectiveRootAccess } from "./contract-CStSEOcK.js";
3
4
  import { statSync } from "node:fs";
@@ -153,6 +154,11 @@ var MultiRootScopeService = class extends Service {
153
154
  /** Additional roots keyed by canonical primary root; empty until roots are registered. */
154
155
  rootsByPrimary = /* @__PURE__ */ new Map();
155
156
  listeners = /* @__PURE__ */ new Set();
157
+ /** Enclosing {@link MultiRootScopeService.batch} depth; inner calls nest. */
158
+ batchDepth = 0;
159
+ /** True when a batched write coalesced its notification into the flush. */
160
+ batchDirty = false;
161
+ admissionProof;
156
162
  /**
157
163
  * The registry-authority fact the registry row publishes. Defaults to
158
164
  * `available`: a composition without a registry row can never have
@@ -181,6 +187,36 @@ var MultiRootScopeService = class extends Service {
181
187
  };
182
188
  }
183
189
  /**
190
+ * Run `body` as one notification round: `setAdditionalRoots()` and
191
+ * `setRegistryAuthority()` apply their state immediately (a same-value
192
+ * authority write still stays silent), but subscriber invocations coalesce
193
+ * into a single flush when the OUTERMOST batch ends — also when `body`
194
+ * throws, because the state already landed. A write a subscriber makes
195
+ * during the flush is not part of the batch and notifies synchronously
196
+ * under the normal rules.
197
+ * @param body - the writes to run as one round.
198
+ * @returns the body's return value.
199
+ */
200
+ batch(body) {
201
+ this.batchDepth++;
202
+ try {
203
+ return body();
204
+ } finally {
205
+ if (--this.batchDepth === 0 && this.batchDirty) {
206
+ this.batchDirty = false;
207
+ for (const listener of this.listeners) listener();
208
+ }
209
+ }
210
+ }
211
+ /** Notify subscribers now, or coalesce into the enclosing batch's flush. */
212
+ notifySubscribers() {
213
+ if (this.batchDepth > 0) {
214
+ this.batchDirty = true;
215
+ return;
216
+ }
217
+ for (const listener of this.listeners) listener();
218
+ }
219
+ /**
184
220
  * The sorted names of the tools `toolAccess` caps — read-only for the
185
221
  * prompt contribution. An invalid config is not enumerated (it caps every
186
222
  * call but is not a listable tool set).
@@ -220,7 +256,7 @@ var MultiRootScopeService = class extends Service {
220
256
  const session = assembly.agent?.session;
221
257
  if (session === void 0) return "";
222
258
  const policy = scope.sandboxPolicy.resolve({ session });
223
- const resolved = this.resolve(policy);
259
+ const resolved = this.snapshot(policy);
224
260
  if (resolved.roots.length === 0 && resolved.authority !== "unavailable" && resolved.protectedHome === void 0) return "";
225
261
  return renderWorkspaceRootsContext({
226
262
  platform: process.platform,
@@ -241,9 +277,52 @@ var MultiRootScopeService = class extends Service {
241
277
  * @param policy - the upstream policy whose workspace root is the primary root.
242
278
  * @returns the canonical scope; `additionalRoots` is empty when none are registered.
243
279
  */
244
- resolve(policy) {
280
+ requireAdmission(proof) {
281
+ this.admissionProof = proof;
282
+ }
283
+ rememberProfile(argv) {
284
+ if (this.admissionProof !== void 0) rememberOperationProfile(this.admissionProof, argv, this.capped());
285
+ }
286
+ permitsProfile(argv) {
287
+ return this.admissionProof === void 0 || operationPermitsProfile(this.admissionProof, argv, this.capped());
288
+ }
289
+ get admissionRequired() {
290
+ return this.admissionProof !== void 0;
291
+ }
292
+ admissionPathsValid() {
293
+ if (this.admissionProof === void 0) return true;
294
+ const operation = currentRegistryOperation(this.admissionProof);
295
+ return operation !== void 0 && operation.scope.additionalRoots.every((root) => {
296
+ try {
297
+ return canonicalPath(root) === root && statSync(root).isDirectory();
298
+ } catch {
299
+ return false;
300
+ }
301
+ });
302
+ }
303
+ hasAdmission() {
304
+ return this.admissionProof === void 0 || currentRegistryOperation(this.admissionProof) !== void 0;
305
+ }
306
+ /** Trusted display/review projection, never an execution permit. */
307
+ snapshot(policy) {
245
308
  return this.project(policy, this.capped());
246
309
  }
310
+ resolveSnapshot(policy, roots, available) {
311
+ return this.project(policy, this.capped(), roots, available ? "available" : "unavailable");
312
+ }
313
+ resolve(policy) {
314
+ if (this.admissionProof === void 0) return this.project(policy, this.capped());
315
+ const operation = currentRegistryOperation(this.admissionProof);
316
+ if (operation !== void 0 && operation.identity.mode === policy.mode && operation.scope.primaryRoot === canonicalPath(policy.workspaceRoot)) {
317
+ const snapshot = operation.scope;
318
+ return this.capped() ? {
319
+ ...snapshot,
320
+ writableRoots: [],
321
+ deniedRoots: [.../* @__PURE__ */ new Set([...snapshot.deniedRoots, ...snapshot.additionalRoots])]
322
+ } : snapshot;
323
+ }
324
+ return this.project(policy, true, void 0, "unavailable");
325
+ }
247
326
  /** Read-only pending-dispatch projection. Does not mint an ALS execution grant. */
248
327
  previewForTool(policy, toolName) {
249
328
  return this.project(policy, this.capForTool(toolName));
@@ -252,14 +331,15 @@ var MultiRootScopeService = class extends Service {
252
331
  capForTool(toolName) {
253
332
  return this.capsInvalid || toolCapStore.getStore()?.capped === true || this.caps[toolName] === "read-only";
254
333
  }
255
- project(policy, capped) {
334
+ project(policy, capped, recorded, authority = this.registryAuthority) {
256
335
  const primaryRoot = canonicalPath(policy.workspaceRoot);
257
- const roots = this.membersOf(primaryRoot);
258
- const writable = capped || this.registryAuthority === "unavailable" ? [] : roots.filter((root) => effectivelyWritable(policy.mode, root.access)).map((root) => root.path);
259
- const denied = (this.rootsByPrimary.get(primaryRoot) ?? []).filter((root) => typeof root.recordedPath === "string" && root.recordedPath !== "" && root.recordedPath !== primaryRoot && (capped || !effectivelyWritable(policy.mode, root.access))).map((root) => root.recordedPath);
336
+ const registered = recorded ?? this.rootsByPrimary.get(primaryRoot) ?? [];
337
+ const roots = authority === "unavailable" ? [] : sanitizeMembers(primaryRoot, registered);
338
+ const writable = capped || authority === "unavailable" ? [] : roots.filter((root) => effectivelyWritable(policy.mode, root.access)).map((root) => root.path);
339
+ const denied = registered.filter((root) => typeof root.recordedPath === "string" && root.recordedPath !== "" && root.recordedPath !== primaryRoot && (capped || !effectivelyWritable(policy.mode, root.access))).map((root) => root.recordedPath);
260
340
  const deniedRoots = [...new Set(denied)];
261
341
  let protectedHome;
262
- if (this.dshHome !== void 0 && policy.mode === "danger-full-access" && (deniedRoots.length > 0 || this.registryAuthority === "unavailable")) {
342
+ if (this.dshHome !== void 0 && policy.mode === "danger-full-access" && (deniedRoots.length > 0 || authority === "unavailable")) {
263
343
  const home = canonicalPath(this.dshHome);
264
344
  if (!deniedRoots.includes(home)) {
265
345
  deniedRoots.push(home);
@@ -272,7 +352,7 @@ var MultiRootScopeService = class extends Service {
272
352
  roots,
273
353
  writableRoots: writable,
274
354
  deniedRoots,
275
- authority: this.registryAuthority,
355
+ authority,
276
356
  ...protectedHome === void 0 ? {} : { protectedHome }
277
357
  };
278
358
  }
@@ -298,6 +378,16 @@ var MultiRootScopeService = class extends Service {
298
378
  * @returns the canonical additional roots; empty when none are registered.
299
379
  */
300
380
  scopeOf(primaryRoot) {
381
+ if (this.admissionProof !== void 0) {
382
+ const operation = currentRegistryOperation(this.admissionProof);
383
+ return operation?.scope.primaryRoot === canonicalPath(primaryRoot) ? operation.scope.additionalRoots.filter((root) => {
384
+ try {
385
+ return canonicalPath(root) === root && statSync(root).isDirectory();
386
+ } catch {
387
+ return false;
388
+ }
389
+ }) : [];
390
+ }
301
391
  return this.membersOf(primaryRoot).map((root) => root.path);
302
392
  }
303
393
  /**
@@ -323,7 +413,7 @@ var MultiRootScopeService = class extends Service {
323
413
  * registry cannot prove none does.
324
414
  */
325
415
  hasActiveDenyRoots() {
326
- if (this.registryAuthority === "unavailable") return true;
416
+ if (this.registryAuthority === "unavailable" || !this.hasAdmission()) return true;
327
417
  for (const members of this.rootsByPrimary.values()) if (members.some((root) => root.access === "read-only") || members.length > 0 && this.capped()) return true;
328
418
  return false;
329
419
  }
@@ -347,7 +437,7 @@ var MultiRootScopeService = class extends Service {
347
437
  const key = canonicalPath(primaryRoot);
348
438
  if (roots.length === 0) this.rootsByPrimary.delete(key);
349
439
  else this.rootsByPrimary.set(key, [...roots]);
350
- for (const listener of this.listeners) listener();
440
+ this.notifySubscribers();
351
441
  }
352
442
  /**
353
443
  * Publish the registry-authority fact. This is the REGISTRY's write port —
@@ -364,7 +454,7 @@ var MultiRootScopeService = class extends Service {
364
454
  const next = available ? "available" : "unavailable";
365
455
  if (this.registryAuthority === next) return;
366
456
  this.registryAuthority = next;
367
- for (const listener of this.listeners) listener();
457
+ this.notifySubscribers();
368
458
  }
369
459
  };
370
460
  //#endregion