@dsh-electron/dsh-plugin-multi-root-workspace 0.1.5 → 0.1.7

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 (101) hide show
  1. package/CHANGELOG.en.md +56 -1
  2. package/CHANGELOG.md +56 -1
  3. package/README.en.md +157 -92
  4. package/README.md +158 -93
  5. package/cordis.patch.yml +61 -0
  6. package/lib/client.js +2819 -115
  7. package/lib/client.js.map +1 -1
  8. package/lib/command-CESuwlVt.js +1306 -0
  9. package/lib/command.js +2 -2
  10. package/lib/{compat-82Y27Iom.js → compat-DF_yL1bB.js} +180 -4
  11. package/lib/compat.js +1 -1
  12. package/lib/contract-DZ4zczDT.js +238 -0
  13. package/lib/{fs-D_Sw3o5z.js → fs-9JIzGaf3.js} +55 -19
  14. package/lib/fs.js +2 -1
  15. package/lib/index.js +10 -8
  16. package/lib/{instructions-Cn1Jj5Xi.js → instructions-D19jRxN0.js} +109 -28
  17. package/lib/instructions.js +1 -1
  18. package/lib/lsp.js +43 -0
  19. package/lib/method-wrapper-CFY3p3j2.js +26 -0
  20. package/lib/process-fence.js +280 -0
  21. package/lib/providers.js +87 -0
  22. package/lib/{registry-DySMbjgW.js → registry-DCxC5Lay.js} +256 -35
  23. package/lib/registry.js +2 -2
  24. package/lib/{roots-DasWEBJk.js → roots-B-0l8mNG.js} +117 -20
  25. package/lib/sandbox-Kup4_ZIb.js +747 -0
  26. package/lib/sandbox.js +2 -1
  27. package/lib/scope.js +240 -25
  28. package/lib/session-directory-BLOstut1.js +9 -0
  29. package/lib/tool-context-CpgIFcCB.js +6 -0
  30. package/lib/types/client/RootSwitcher.d.ts +63 -0
  31. package/lib/types/client/WorkspaceFoldersAction.d.ts +41 -0
  32. package/lib/types/client/files-tree/FilesBody.module.css.d.ts +24 -0
  33. package/lib/types/client/files-tree/FilesTree.d.ts +47 -0
  34. package/lib/types/client/files-tree/adapter.d.ts +94 -0
  35. package/lib/types/client/files-tree/directory-node.d.ts +62 -0
  36. package/lib/types/client/files-tree/face.d.ts +107 -0
  37. package/lib/types/client/files-tree/locales.d.ts +10 -0
  38. package/lib/types/client/files-tree/primitives.d.ts +33 -0
  39. package/lib/types/client/files-tree/store.d.ts +81 -0
  40. package/lib/types/client/index.d.ts +13 -2
  41. package/lib/types/client/locales.d.ts +60 -0
  42. package/lib/types/client/open-in-app/OpenInAppAction.d.ts +27 -0
  43. package/lib/types/client/open-in-app/OpenTargetButton.d.ts +52 -0
  44. package/lib/types/client/open-in-app/OpenTargetButton.module.css.d.ts +29 -0
  45. package/lib/types/client/open-in-app/adapter.d.ts +46 -0
  46. package/lib/types/client/open-in-app/applications.d.ts +4 -0
  47. package/lib/types/client/open-in-app/controller.d.ts +53 -0
  48. package/lib/types/client/open-in-app/locales.d.ts +10 -0
  49. package/lib/types/client/open-in-app/open-failure-toast.d.ts +11 -0
  50. package/lib/types/client/open-in-app/open-path.d.ts +13 -0
  51. package/lib/types/client/panel-client.d.ts +2 -1
  52. package/lib/types/client/styles.d.ts +1 -1
  53. package/lib/types/command.d.ts +16 -3
  54. package/lib/types/compat/client-icons.d.ts +18 -0
  55. package/lib/types/compat/client-session.d.ts +2 -0
  56. package/lib/types/compat/dsh-version.d.ts +5 -3
  57. package/lib/types/compat/instruction-overlap.d.ts +7 -0
  58. package/lib/types/compat/open-in-app-routes.d.ts +38 -0
  59. package/lib/types/compat/process-entrypoints.d.ts +31 -0
  60. package/lib/types/compat/process-result.d.ts +14 -0
  61. package/lib/types/compat/pty-marker.d.ts +83 -0
  62. package/lib/types/compat/sandbox-confine.d.ts +2 -0
  63. package/lib/types/compat/session-directory.d.ts +4 -0
  64. package/lib/types/compat/storage-zod.d.ts +65 -0
  65. package/lib/types/compat/tool-context.d.ts +9 -0
  66. package/lib/types/compat/windows-deny-preflight.d.ts +21 -0
  67. package/lib/types/compat/windows-native.d.ts +68 -0
  68. package/lib/types/compat/windows-path-guard.d.ts +56 -0
  69. package/lib/types/compat/workspace-files.d.ts +7 -0
  70. package/lib/types/contract.d.ts +35 -4
  71. package/lib/types/dialects.d.ts +99 -3
  72. package/lib/types/fs.d.ts +32 -10
  73. package/lib/types/index.d.ts +5 -0
  74. package/lib/types/lsp.d.ts +4 -0
  75. package/lib/types/method-wrapper.d.ts +2 -0
  76. package/lib/types/process-fence.d.ts +54 -0
  77. package/lib/types/profile-isolation.d.ts +178 -0
  78. package/lib/types/providers.d.ts +70 -0
  79. package/lib/types/registry.d.ts +132 -31
  80. package/lib/types/roots.d.ts +100 -13
  81. package/lib/types/sandbox.d.ts +59 -12
  82. package/lib/types/scope.d.ts +203 -14
  83. package/lib/types/windows-broker.d.ts +17 -0
  84. package/lib/types/windows-deny.d.ts +1 -0
  85. package/lib/types/windows-profile.d.ts +10 -0
  86. package/lib/types/windows-runner-contract.d.ts +97 -0
  87. package/lib/types/windows-runner.d.ts +1 -0
  88. package/lib/types/workspace-files.d.ts +4 -0
  89. package/lib/types/workspace-routing.d.ts +11 -0
  90. package/lib/windows-runner-contract-CEfmZzu0.js +141 -0
  91. package/lib/windows-runner.js +1311 -0
  92. package/lib/workspace-files.js +203 -0
  93. package/lib/workspace-routing-YPxaQ4V3.js +26 -0
  94. package/package.json +116 -51
  95. package/scripts/windows-broker/install.ps1 +89 -0
  96. package/scripts/windows-broker/rights.cs +58 -0
  97. package/scripts/windows-broker/service.cs +323 -0
  98. package/scripts/windows-broker/verify-install.ps1 +80 -0
  99. package/lib/command-DyOWujPo.js +0 -690
  100. package/lib/sandbox-D8Fw95FM.js +0 -290
  101. package/lib/{registry-lease-win32-dy8TQMD9.js → registry-lease-win32-DTO6QpT9.js} +2 -2
@@ -22,13 +22,23 @@
22
22
  * 6. `duplicate` — another registered root canonicalizes to the same path.
23
23
  * 7. `nested` — the candidate lies under a registered root, or contains one.
24
24
  * Same reasoning as rule 5, applied to the additional roots.
25
+ * 8. `unsupported-path` — on Windows, a network or device path (`\\server`,
26
+ * `\\?\…`, `\\.\…`): the ACL confinement cannot be shown to cover it, so it
27
+ * is rejected lexically before any filesystem touch.
25
28
  *
26
29
  * The stored side of the same rules lives in {@link classifyStoredRoots}, which
27
30
  * must never throw: a registration that stopped being usable is REPORTED, not
28
- * deleted, so the operator can see it and remove it deliberately.
31
+ * deleted, so the operator can see it and remove it deliberately. It runs in
32
+ * two passes — every record's own structural trust and availability first,
33
+ * then overlap between the RECORDED boundaries of all structurally sound
34
+ * records — so the answer never depends on registry order, and a pair of
35
+ * records whose grants collide (`membership-conflict`) keeps its trustworthy
36
+ * negative boundary even while its membership is withheld.
29
37
  *
30
38
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/roots
31
39
  */
40
+ import { isWindowsNetworkOrDevicePath } from './windows-runner-contract.ts';
41
+ export { isWindowsNetworkOrDevicePath };
32
42
  /**
33
43
  * Stable identity of one registered additional root. A generated uuid, never
34
44
  * the path: a path is re-canonicalized on every read and a display alias may
@@ -44,6 +54,19 @@ export type AdditionalRootId = string & {
44
54
  * @returns the branded id.
45
55
  */
46
56
  export declare function additionalRootId(value: string): AdditionalRootId;
57
+ /**
58
+ * The write authority one registration carries (B9, ADR-0017).
59
+ *
60
+ * - `inherit` — the root follows the session mode: writable under
61
+ * `workspace-write` and unrestricted under `danger-full-access`, read-only
62
+ * under `read-only`. This is what legacy `recordedPath` records mean.
63
+ * - `read-only` — the root stays a workspace member (instructions, LSP,
64
+ * workspace-file routing, and reads all still see it) but a write is refused
65
+ * under EVERY mode, including `danger-full-access`.
66
+ * - `read-write` — the root is writable under EVERY mode, including a global
67
+ * `read-only` session; it does not widen anything beyond its own directory.
68
+ */
69
+ export type RootAccess = 'inherit' | 'read-write' | 'read-only';
47
70
  /** One registered additional root exactly as it is persisted. */
48
71
  export interface RegisteredRoot {
49
72
  /** Stable registry identity. */
@@ -51,13 +74,35 @@ export interface RegisteredRoot {
51
74
  /** Canonical absolute directory (the `realpath` captured at registration). */
52
75
  readonly path: string;
53
76
  /**
54
- * The canonical directory this registration was GRANTED for: the `realpath`
55
- * observed when the operator registered it (or the last time the operator
56
- * restored it). Authorization is `canonicalPath(path) === recordedPath` — a
57
- * root whose current resolution moved somewhere else is reported as
58
- * `redirected` and withheld, never silently granted elsewhere.
77
+ * The write authority this registration carries. In memory every record has
78
+ * exactly one value; on disk it is expressed by WHICH grant field is
79
+ * present — `recordedPath` means `inherit`, `readOnlyRecordedPath` means
80
+ * `read-only`, `readWriteRecordedPath` means `read-write` — never by a
81
+ * separate `access` key (see `registry.ts` for the downgrade safety that
82
+ * split buys).
83
+ */
84
+ readonly access: RootAccess;
85
+ /**
86
+ * The canonical directory this INHERIT registration was GRANTED for: the
87
+ * `realpath` observed when the operator registered it (or the last time the
88
+ * operator restored it). Authorization is `canonicalPath(path) ===
89
+ * recordedPath` — a root whose current resolution moved somewhere else is
90
+ * reported as `redirected` and withheld, never silently granted elsewhere.
59
91
  */
60
92
  readonly recordedPath?: string;
93
+ /**
94
+ * The canonical directory this READ-ONLY registration was granted for. It
95
+ * plays exactly the role `recordedPath` plays for an inherit record, kept
96
+ * under a separate key so the single persisted layout stays downgrade-safe.
97
+ */
98
+ readonly readOnlyRecordedPath?: string;
99
+ /**
100
+ * The canonical directory this fixed READ-WRITE registration was granted
101
+ * for. Same role as the other two grant fields, under its own key: an older
102
+ * plugin strips the unknown field and classifies the record `invalid`
103
+ * rather than granting it as a legacy `recordedPath` write.
104
+ */
105
+ readonly readWriteRecordedPath?: string;
61
106
  /** Optional display alias; absent when the operator cleared it. */
62
107
  readonly alias?: string;
63
108
  /** ISO-8601 instant of registration. */
@@ -73,7 +118,9 @@ export interface RegisteredRoot {
73
118
  * registered for (it was replaced by a symlink, or a symlink in the chain
74
119
  * changed); kept, withheld, and `recheck` restores it once the original
75
120
  * directory is back.
76
- * - `invalid` — the record violates a rule and can only be removed.
121
+ * - `invalid` — the record violates a rule and can only be removed. Whether the
122
+ * violation also voids the record's negative boundary is told apart by
123
+ * {@link RootStatus.invalidReason}.
77
124
  */
78
125
  export type RootState = 'available' | 'missing' | 'redirected' | 'invalid';
79
126
  /** One registered root as the surfaces report it. */
@@ -82,6 +129,20 @@ export interface RootStatus extends RegisteredRoot {
82
129
  readonly state: RootState;
83
130
  /** Why the root is not `available`; absent otherwise. */
84
131
  readonly detail?: string;
132
+ /**
133
+ * Distinguishes WHY an `invalid` record is unusable. Internal only: it is
134
+ * neither persisted nor sent over the panel wire — `state` and `detail`
135
+ * carry everything a surface needs.
136
+ *
137
+ * `'membership-conflict'` means the record passed every structural rule —
138
+ * including an intact, absolute recorded grant — but its recorded boundary
139
+ * overlaps another record's. Neither membership may be granted while the
140
+ * conflict stands, yet the boundary remains a trustworthy negative
141
+ * restriction, so {@link reservedRootBoundary} keeps it reserved and the
142
+ * registry publishes the root as a withheld, deny-only entry. Every other
143
+ * `invalid` verdict left the grant unverifiable, so it contributes nothing.
144
+ */
145
+ readonly invalidReason?: 'membership-conflict';
85
146
  }
86
147
  /** One status together with the 1-based ordinal the surfaces accept for it. */
87
148
  export interface IndexedRootStatus {
@@ -108,7 +169,7 @@ export type RootRef = {
108
169
  readonly addedAt: string;
109
170
  };
110
171
  /** Every way root handling can fail, as a stable code the surfaces localize. */
111
- export type RootValidationCode = 'not-absolute' | 'missing' | 'not-a-directory' | 'equals-primary' | 'primary-overlap' | 'duplicate' | 'nested' | 'invalid-alias' | 'not-found' | 'invalid-ref' | 'storage-unavailable' | 'registry-contended' | 'reveal-unavailable' | 'session-not-found';
172
+ export type RootValidationCode = 'not-absolute' | 'missing' | 'not-a-directory' | 'equals-primary' | 'primary-overlap' | 'duplicate' | 'nested' | 'invalid-alias' | 'not-found' | 'invalid-ref' | 'storage-unavailable' | 'registry-contended' | 'root-limit-reached' | 'config-invalid' | 'reveal-unavailable' | 'session-not-found' | 'unsupported-path' | 'root-unavailable';
112
173
  /** A rejected root operation; `code` is the stable contract, `message` the fallback prose. */
113
174
  export declare class RootValidationError extends Error {
114
175
  readonly code: RootValidationCode;
@@ -163,10 +224,19 @@ export interface RootCandidateContext {
163
224
  */
164
225
  readonly home?: string;
165
226
  /**
166
- * Canonical paths already granted. Missing and invalid registrations are
167
- * deliberately excluded: a withheld root grants nothing, so a candidate may
168
- * legitimately sit under it, and an unusable record must not block a store
169
- * the operator is trying to make work again.
227
+ * The platform the rules run for; defaults to `process.platform`. A caller
228
+ * (a test, a probe) may exercise another platform's rules — notably the
229
+ * Windows network/device-path refusal — on any host.
230
+ */
231
+ readonly platform?: NodeJS.Platform;
232
+ /**
233
+ * The recorded boundaries already reserved: {@link reservedRootBoundary}
234
+ * applied to every other status — available, missing, redirected, and
235
+ * membership-conflict records alike. A withheld root grants nothing, but
236
+ * its recorded boundary is the negative restriction a new writable root
237
+ * must not swallow while the operator has not removed it. Only a
238
+ * structurally invalid record contributes nothing, and the row being
239
+ * revived exempts only itself.
170
240
  */
171
241
  readonly existing: readonly string[];
172
242
  }
@@ -188,15 +258,32 @@ export declare function validateRootCandidate(raw: string, context: RootCandidat
188
258
  * `invalid` (kept, unusable).
189
259
  * @param primaryRoot - the session workspace root the records belong to.
190
260
  * @param records - the stored records, in registry order.
261
+ * @param platform - the platform the rules run for; defaults to `process.platform`.
191
262
  * @returns one status per record, in the same order.
192
263
  */
193
- export declare function classifyStoredRoots(primaryRoot: string, records: readonly RegisteredRoot[]): RootStatus[];
264
+ export declare function classifyStoredRoots(primaryRoot: string, records: readonly RegisteredRoot[], platform?: NodeJS.Platform): RootStatus[];
194
265
  /**
195
266
  * The canonical roots a status list grants, in registry order.
196
267
  * @param statuses - the statuses to project.
197
268
  * @returns the canonical paths of every `available` root.
198
269
  */
199
270
  export declare function availableRoots(statuses: readonly RootStatus[]): string[];
271
+ /**
272
+ * The recorded boundary one status still RESERVES: the grant it was
273
+ * registered for, which a new or revived registration may not overlap merely
274
+ * because the root is currently unusable.
275
+ *
276
+ * `available`, `missing`, `redirected`, and membership-conflict statuses all
277
+ * carry an intact, trustworthy recorded grant; every other `invalid` verdict
278
+ * means the grant could not be parsed at all, so it reserves — and denies —
279
+ * nothing. This is not an independent authorization table: it derives the
280
+ * boundary from the status list {@link classifyStoredRoots} produced, with
281
+ * the same grant-extraction semantics, so its answer can never disagree with
282
+ * classification.
283
+ * @param status - one classified registration.
284
+ * @returns the absolute recorded boundary, or `undefined` when there is none.
285
+ */
286
+ export declare function reservedRootBoundary(status: RootStatus): string | undefined;
200
287
  /**
201
288
  * Pair every status with the 1-based ordinal the surfaces accept for it. The
202
289
  * ordinal belongs to the POSITION, so it stays correct even for records whose
@@ -8,7 +8,8 @@
8
8
  * and the fs fence one and the same root set (requirement §13). The bash
9
9
  * executor itself is therefore left upstream: it never computes roots.
10
10
  *
11
- * The widening itself lives in `./dialects.ts`: the upstream profile builders
11
+ * POSIX widening lives in `./dialects.ts`, Windows capabilities in
12
+ * `./windows-profile.ts` and `./windows-runner.ts`: the upstream profile builders
12
13
  * are unreachable in the published package, so the additional roots are grafted
13
14
  * onto the profile `super.confine` actually produced (recognize the dialect
14
15
  * structurally, clone its grant spelling, fail loudly when the shape is not
@@ -26,7 +27,8 @@
26
27
  *
27
28
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/sandbox
28
29
  */
29
- import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox';
30
+ import type { Context } from '@deepseek-ai/cordis';
31
+ import type { SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox';
30
32
  import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local';
31
33
  import type { Config } from '@deepseek-ai/dsh-sandbox-local';
32
34
  import type { UpstreamConfined } from './compat/sandbox-confine.ts';
@@ -38,8 +40,7 @@ export type { Config };
38
40
  */
39
41
  export declare class MultiRootSandboxProvider extends LocalSandboxProvider {
40
42
  static inject: string[];
41
- /** Whether the Windows ACL limitation has already been reported (once per provider). */
42
- private warnedAboutWindowsAcl;
43
+ constructor(ctx: Context, config: Config);
43
44
  /**
44
45
  * Wrap `argv` so it executes confined under `policy` on this host, granting
45
46
  * the scope's additional roots inside the SAME dialect profile.
@@ -61,25 +62,71 @@ export declare class MultiRootSandboxProvider extends LocalSandboxProvider {
61
62
  */
62
63
  confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): UpstreamConfined;
63
64
  /**
64
- * Graft the scope's additional roots onto one upstream `confine` result.
65
+ * Graft the scope's effective grants and denies onto one upstream `confine`
66
+ * result.
65
67
  *
66
68
  * Pure with respect to the upstream call: it only ever reads the scope and
67
69
  * rewrites `argv`. `enforcement`, `denialSignatures` and `runnerFailureRules`
68
70
  * are passed through untouched, because the bash executor derives its denial
69
71
  * and enforcement reporting from them.
72
+ *
73
+ * Mode behavior (B9, R2): `workspace-write` widens the grant with the
74
+ * effective writable roots AND denies any restricted root the upstream
75
+ * grants could reach (a `read-only` root registered under the platform
76
+ * temp). `read-only` grafts the fixed `read-write` roots back onto the
77
+ * deny-all baseline — the three-state matrix's standout cell — and then
78
+ * applies the SAME deny pass: a grafted writable parent must not silently
79
+ * swallow a denied child (Seatbelt appends the deny after the allow, bwrap
80
+ * adds a mandatory `--ro-bind`, Landlock / windows-acl refuse the
81
+ * unrepresentable subtraction). `danger-full-access` never reaches
82
+ * here (consumers bypass `confine`); its deny semantics live in
83
+ * {@link restrictedArgvPrefix}.
70
84
  * @param confined - the upstream result for this call.
71
85
  * @param argv - the caller's argv, needed to re-split the wrapped form.
72
86
  * @param policy - the per-call policy.
73
- * @returns the upstream result element for element, or one with a widened argv.
87
+ * @param scope - the authorization snapshot taken at dispatch.
88
+ * @returns the upstream result element for element, or one with a rewritten argv.
74
89
  */
75
90
  private widenForScope;
76
91
  /**
77
- * Report the documented first-release limitation exactly once: the Windows ACL
78
- * rung grants through per-workspace write SIDs rather than argv paths, so
79
- * additional roots stay unenforced for confined commands there while the
80
- * in-process filesystem fence still allows them.
81
- * @param rootCount - how many additional roots the scope carries.
92
+ * Build the runner-prefix that confines an ambient-writable execution to
93
+ * everything EXCEPT `deniedRoots` — the `danger-full-access` + explicit
94
+ * `read-only` cell, which the shell, PTY and PTC consumers never route
95
+ * through `confine()` at all.
96
+ *
97
+ * The restricted profile does not depend on the callee's argv, so the prefix
98
+ * is computed once per call here and the synchronous `ctx.subprocess.spawn`
99
+ * seam only has to prepend it. The upstream profile builders stay private:
100
+ * this probes `confine` under `read-only` for the runner's argument skeleton
101
+ * and rewrites the file-effect part per dialect. Windows selects the
102
+ * standalone ambient deny-token runner. Landlock cannot express
103
+ * "everything except", so this combination selects bubblewrap; unusable
104
+ * runners fail without spawning an unconfined command.
105
+ *
106
+ * The return preserves the installed `confine` shape: a synchronous result
107
+ * on a synchronous release, a promise on an asynchronous one.
108
+ * @param policy - the caller's policy (mode must be `danger-full-access`).
109
+ * @param deniedRoots - canonical roots this execution must not write.
110
+ * @param signal - cancellation, forwarded to releases that accept it.
111
+ * @param protectedHome - diagnostics label: which deny root is the
112
+ * protected `$DSH_HOME`. Forwarded to the Windows runner only when it is
113
+ * a member of `deniedRoots`; it never changes authorization.
114
+ * @returns the argv prefix, ending at the `--` separator.
115
+ */
116
+ restrictedArgvPrefix(policy: SandboxExecutionPolicy, deniedRoots: readonly string[], signal?: AbortSignal, protectedHome?: string): readonly string[] | Promise<readonly string[]>;
117
+ /** Full-access restriction and the actual selected backend's diagnostics. */
118
+ restrictedExecution(policy: SandboxExecutionPolicy, deniedRoots: readonly string[], signal?: AbortSignal, protectedHome?: string): UpstreamConfined;
119
+ /**
120
+ * Limit an execution with unknown registry authority to the primary root.
121
+ * Shared temp grants are unsafe: another process may have registered a
122
+ * read-only root beneath them. Harvest the upstream read-only skeleton and
123
+ * graft only the primary root, retaining diagnostics and the sync/async shape.
124
+ * Explicit denies still win; an unrepresentable overlap refuses launch.
125
+ * @param policy - the caller's execution policy.
126
+ * @param deniedRoots - known negative boundaries, including protected home.
127
+ * @param signal - cancellation forwarded to upstream.
128
+ * @returns the confined prefix ending at `--`, without the caller's command.
82
129
  */
83
- private warnAboutWindowsAcl;
130
+ clampedExecution(policy: SandboxExecutionPolicy, deniedRoots: readonly string[], signal?: AbortSignal): UpstreamConfined;
84
131
  }
85
132
  export default MultiRootSandboxProvider;
@@ -16,7 +16,8 @@
16
16
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/scope
17
17
  */
18
18
  import { Context, Service } from '@deepseek-ai/cordis';
19
- import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox';
19
+ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox';
20
+ import type { RootAccess } from './roots.ts';
20
21
  declare module '@deepseek-ai/cordis' {
21
22
  interface Context {
22
23
  multiRootScope: MultiRootScopeService;
@@ -37,19 +38,82 @@ export interface AdditionalWorkspaceRoot {
37
38
  * ever registered.
38
39
  */
39
40
  recordedPath: string;
41
+ /**
42
+ * The write authority the registration carries (B9 three-state).
43
+ * `read-only` roots remain workspace members — instructions, LSP,
44
+ * workspace-file routing, and reads all still see them — but they are never
45
+ * part of `writableRoots` and never reach a write grant, under ANY mode.
46
+ * `inherit` follows the session mode; fixed `read-write` stays writable even
47
+ * under a read-only session.
48
+ */
49
+ access: RootAccess;
50
+ /** Registry withheld the positive grant; retain its recorded deny boundary. */
51
+ withheld?: boolean;
40
52
  /** Optional display alias. */
41
53
  alias?: string;
42
54
  }
55
+ /** One granted workspace member, with the authority fact the enforcement reads. */
56
+ export interface ScopeRoot {
57
+ /** Canonical absolute directory. */
58
+ readonly path: string;
59
+ /** The write authority the registration carries. */
60
+ readonly access: RootAccess;
61
+ /** Optional display alias. */
62
+ readonly alias?: string;
63
+ /** Stable registry identity. */
64
+ readonly id: string;
65
+ }
43
66
  /**
44
67
  * The resolved scope of one confined call. `primaryRoot` equals the policy's
45
- * workspace root; `additionalRoots` are canonical, deduplicated, never equal to
46
- * the primary root, and empty in M1.
68
+ * workspace root; `additionalRoots` are canonical, deduplicated, never equal
69
+ * to the primary root.
47
70
  */
48
71
  export interface FilesystemScope {
49
72
  /** The session's workspace root (`session.header.cwd`, canonical). */
50
73
  primaryRoot: string;
51
- /** Extra writable roots granted inside the same sandbox mechanism. */
74
+ /** Every granted member — ANY access level — canonical, in registry order. */
52
75
  additionalRoots: readonly string[];
76
+ /** The granted members with their access, in registry order. */
77
+ roots: readonly ScopeRoot[];
78
+ /**
79
+ * The members a write may reach under THIS call — the effective access
80
+ * (registration policy × session mode) narrowed by the tool cap.
81
+ */
82
+ writableRoots: readonly string[];
83
+ /**
84
+ * Registered boundaries THIS call must not write, including missing and
85
+ * redirected registrations. Negative restrictions never create a grant.
86
+ * It is the deny set enforcement reads: explicit
87
+ * `read-only` roots under `danger-full-access` (and wherever another grant,
88
+ * such as the platform temp area, would otherwise cover the same path), and
89
+ * every member when the call is tool-capped. Read access is unaffected —
90
+ * these are all still workspace members.
91
+ */
92
+ deniedRoots: readonly string[];
93
+ /**
94
+ * Whether this process holds the registry authority for the store. While it
95
+ * is `unavailable` — contended, storage-failed, config-invalid, or torn
96
+ * down — the empty root table is NOT evidence that nothing is restricted:
97
+ * the registrations this process cannot see may carry explicit `read-only`
98
+ * boundaries, so both writable modes limit writes to the primary root
99
+ * instead of trusting the empty deny set.
100
+ * A composition with no registry at all never reports `unavailable` — it can
101
+ * never have registered roots.
102
+ */
103
+ authority: 'available' | 'unavailable';
104
+ /**
105
+ * The canonical DSH home directory, present exactly when this scope appended
106
+ * it to `deniedRoots` — i.e. under `danger-full-access` while restrictions
107
+ * apply (a non-empty deny set or an unavailable authority). `$DSH_HOME`
108
+ * holds the store, the lease, the profiles and the user's own config: an
109
+ * ambient Full-access process must not edit them, so once the workspace
110
+ * carries ANY deny boundary the home joins it. Zero additional roots with a
111
+ * healthy authority never set this — the empty scope stays upstream-exact.
112
+ * Never set under `workspace-write` or `read-only`: those modes already
113
+ * confine writes by containment, so denying home adds nothing but the cost
114
+ * of a stale `FsTarget` re-resolution on every write.
115
+ */
116
+ protectedHome?: string;
53
117
  }
54
118
  /**
55
119
  * Canonicalize and sanitize one root list against a primary root: drop the
@@ -76,15 +140,65 @@ export declare function sanitizeAdditionalRoots(primaryRoot: string, roots: read
76
140
  */
77
141
  export declare const MULTI_ROOT_CONTEXT_NAME = "multi-root:scope";
78
142
  /**
79
- * Render the workspace topology the model is told about. Only roots are listed
80
- * (never files), and the sentence states the two facts a denied write would
81
- * otherwise have to teach: that the additional roots are part of the SAME
82
- * workspace, and that the session cwd is unchanged (requirement §5, §8).
83
- * @param primaryRoot - the canonical session workspace root.
84
- * @param additionalRoots - canonical additional roots, in scope order.
85
- * @returns the stable topology sentence.
143
+ * The deterministic sentence the model hears while the registry authority is
144
+ * unavailable under either writable mode — enforcement is then the
145
+ * primary-root-only one, which the prompt must not leave implicit.
146
+ */
147
+ export declare const REGISTRY_UNAVAILABLE_NOTE: string;
148
+ /**
149
+ * Render the workspace topology the model is told about. Pure: the text
150
+ * depends only on its inputs, so two consecutive assemblies of the same
151
+ * session state produce the same contribution byte for byte. Only granted
152
+ * members are ever listed — withheld, missing, or invalid roots never reach
153
+ * `roots` in the first place — and no host internals (ids, SIDs, store or
154
+ * lease paths) are ever named.
155
+ * @param options.platform - the host platform (`process.platform` of the contribution).
156
+ * @param options.mode - the session's sandbox mode.
157
+ * @param options.primaryRoot - the canonical session workspace root.
158
+ * @param options.roots - the granted additional members, in registry order.
159
+ * @param options.cappedTools - sorted tool names that can never write an additional root.
160
+ * @param options.toolAccessInvalid - invalid host config caps every additional-root write.
161
+ * @param options.authority - the registry-authority fact; `unavailable` under
162
+ * `danger-full-access` and `workspace-write` limit writes to the primary root, which the
163
+ * model must hear even when there is no topology to describe.
164
+ * @param options.protectedHome - the canonical DSH home when the scope appended
165
+ * it to the deny set (`danger-full-access` with restrictions); stated by path
166
+ * so the model can place the refusal.
167
+ * @returns the stable topology text; empty when there are no additional roots
168
+ * and no downgrade to report.
86
169
  */
87
- export declare function renderWorkspaceRootsContext(primaryRoot: string, additionalRoots: readonly string[]): string;
170
+ export declare function renderWorkspaceRootsContext(options: {
171
+ readonly platform: string;
172
+ readonly mode: SandboxMode;
173
+ readonly primaryRoot: string;
174
+ readonly roots: readonly ScopeRoot[];
175
+ readonly cappedTools: readonly string[];
176
+ readonly toolAccessInvalid?: boolean;
177
+ readonly authority?: 'available' | 'unavailable';
178
+ readonly protectedHome?: string;
179
+ }): string;
180
+ /**
181
+ * Plugin configuration for the `multi-root-scope` row.
182
+ */
183
+ export interface Config {
184
+ /**
185
+ * Per-tool write caps: a listed tool may never write ANY additional root
186
+ * (the primary root and upstream behaviour are unchanged). `read-only` is
187
+ * the only legal value — a cap restricts, it can never widen past the
188
+ * root's own access.
189
+ */
190
+ readonly toolAccess?: Record<string, 'read-only'>;
191
+ /**
192
+ * The resolved DSH home directory (`$DSH_HOME` / `~/.dsh`), which the patch
193
+ * row fills from `dshHomePath()`. Under `danger-full-access` it joins the
194
+ * deny set exactly while restrictions apply — a non-empty derived deny set
195
+ * or an unavailable registry authority — so an ambient agent process cannot
196
+ * overwrite the store, the lease file, profiles, or the user's own config.
197
+ * Absent or invalid (non-string, empty, relative) means the boundary simply
198
+ * does not exist: bare test compositions carry no home.
199
+ */
200
+ readonly dshHome?: string;
201
+ }
88
202
  /**
89
203
  * The `ctx.multiRootScope` service. Providers ask it — never the filesystem —
90
204
  * what the current scope is, which is what keeps the fs fence and every kernel
@@ -93,7 +207,38 @@ export declare function renderWorkspaceRootsContext(primaryRoot: string, additio
93
207
  export declare class MultiRootScopeService extends Service {
94
208
  /** Additional roots keyed by canonical primary root; empty until roots are registered. */
95
209
  private readonly rootsByPrimary;
96
- constructor(ctx: Context);
210
+ private readonly listeners;
211
+ /**
212
+ * The registry-authority fact the registry row publishes. Defaults to
213
+ * `available`: a composition without a registry row can never have
214
+ * registered roots, so nothing is unknown there. The registry sets it false
215
+ * the moment it cannot prove the store is its own — before the lease is
216
+ * acquired, while contended or storage-failed, and at teardown — and true
217
+ * only after the accepted records have been published.
218
+ */
219
+ private registryAuthority;
220
+ /** Validated `toolAccess` row config; empty when no tool is capped. */
221
+ private readonly caps;
222
+ /** A malformed `toolAccess` refuses every additional-root write (fail closed). */
223
+ private readonly capsInvalid;
224
+ /**
225
+ * The configured DSH home boundary; `undefined` when the row carries no
226
+ * `dshHome` (bare test compositions) or one that is not an absolute string —
227
+ * an invalid value is logged at construction and ignored rather than
228
+ * treated as evidence.
229
+ */
230
+ private readonly dshHome;
231
+ /** Observe registry publications; consumers still resolve authority from scopeOf(). */
232
+ subscribe(listener: () => void): () => void;
233
+ /**
234
+ * The sorted names of the tools `toolAccess` caps — read-only for the
235
+ * prompt contribution. An invalid config is not enumerated (it caps every
236
+ * call but is not a listable tool set).
237
+ */
238
+ toolCaps(): readonly string[];
239
+ /** Whether malformed toolAccess caps every call, including unknown tools. */
240
+ get toolAccessInvalid(): boolean;
241
+ constructor(ctx: Context, config?: Config);
97
242
  /**
98
243
  * Resolve the scope of one confined call.
99
244
  * @param policy - the upstream policy whose workspace root is the primary root.
@@ -101,11 +246,45 @@ export declare class MultiRootScopeService extends Service {
101
246
  */
102
247
  resolve(policy: SandboxExecutionPolicy): FilesystemScope;
103
248
  /**
104
- * The additional roots registered for one canonical primary root.
249
+ * Whether THIS call's additional-root writes are capped. Inside a tool
250
+ * dispatch the AsyncLocalStorage store answers; outside any dispatch (user
251
+ * PTY, panel, an unknown async boundary) the answer is fail-closed: capped
252
+ * whenever `toolAccess` names any tool, because an operation whose tool
253
+ * identity is unknown gets the most restrictive configured cap. Membership
254
+ * is never affected — only writability.
255
+ */
256
+ private capped;
257
+ /**
258
+ * The additional roots registered for one canonical primary root — every
259
+ * granted member, ANY access level (a read-only root is still a member of
260
+ * the workspace for instructions, LSP, routing, and reads).
105
261
  * @param primaryRoot - canonical primary root (or any spelling thereof).
106
262
  * @returns the canonical additional roots; empty when none are registered.
107
263
  */
108
264
  scopeOf(primaryRoot: string): readonly string[];
265
+ /**
266
+ * The additional roots a WRITE may reach for one primary root under
267
+ * `workspace-write` — the members whose effective access is writable there
268
+ * (`inherit` and fixed `read-write`). Tool caps are per-call and do not
269
+ * apply to this diagnostic projection.
270
+ * @param primaryRoot - canonical primary root (or any spelling thereof).
271
+ * @returns the canonical writable roots; empty when none qualify.
272
+ */
273
+ writableOf(primaryRoot: string): readonly string[];
274
+ /**
275
+ * Whether ANY registered primary currently publishes an explicit `read-only`
276
+ * member. The PTY seam resolves its caller through the agent-PTY
277
+ * environment markers; when that lookup fails this flag is the difference
278
+ * between pass-through (nothing to deny anywhere on the store) and fail
279
+ * closed. An unavailable registry authority answers `true` by itself: a
280
+ * terminal whose session cannot be resolved must not inherit a deny set
281
+ * this process cannot see.
282
+ * @returns whether at least one primary holds a restricted root, or the
283
+ * registry cannot prove none does.
284
+ */
285
+ hasActiveDenyRoots(): boolean;
286
+ /** The sanitized members of one primary root, in registry order. */
287
+ private membersOf;
109
288
  /**
110
289
  * Replace the roots registered for one primary root.
111
290
  *
@@ -116,5 +295,15 @@ export declare class MultiRootScopeService extends Service {
116
295
  * @param roots - the additional roots, in display order.
117
296
  */
118
297
  setAdditionalRoots(primaryRoot: string, roots: readonly AdditionalWorkspaceRoot[]): void;
298
+ /**
299
+ * Publish the registry-authority fact. This is the REGISTRY's write port —
300
+ * the counterpart of {@link setAdditionalRoots}: the registry marks the
301
+ * scope `unavailable` the moment it stops being the store's authority
302
+ * (before acquisition, on contention or storage failure, at teardown) and
303
+ * `available` only once its stored records have been published. Nothing
304
+ * else may call it, and it never touches the root table.
305
+ * @param available - whether this process holds the registry authority.
306
+ */
307
+ setRegistryAuthority(available: boolean): void;
119
308
  }
120
309
  export default MultiRootScopeService;
@@ -0,0 +1,17 @@
1
+ export declare function brokerPipeName(userSid: string): string;
2
+ /** The broker must acknowledge before any ACL edits or process creation. The
3
+ * caller independently reads the deny-only groups from the token;
4
+ * even an impersonated pipe server cannot fabricate that kernel state.
5
+ */
6
+ export declare function requestDenyToken(userSid: string, tokenHandle: bigint, deniedIds: readonly string[]): Promise<bigint>;
7
+ /** Synchronous, bounded RPC inside the public suspended-create binding. The
8
+ * caller verifies the child's kernel token before the helper can resume it.
9
+ */
10
+ export declare function assignDenyToken(userSid: string, processHandle: bigint, threadHandle: bigint, deniedIds: readonly string[], io: {
11
+ open(pipe: string): unknown;
12
+ write(pipe: unknown, bytes: Buffer): void;
13
+ available(pipe: unknown): number;
14
+ read(pipe: unknown, count: number): Buffer;
15
+ pause(): void;
16
+ close(pipe: unknown): void;
17
+ }): void;
@@ -0,0 +1 @@
1
+ export declare function runWindowsDenied(upstreamRunner: string, roots: readonly string[], argv: readonly string[], protectedHome?: string): Promise<number>;
@@ -0,0 +1,10 @@
1
+ export { parseWindowsProfile, workspaceScopeSid, type WindowsProfile } from './windows-runner-contract.ts';
2
+ /** Recognize the upstream runner, then select our ambient deny-token path.
3
+ *
4
+ * `protectedHome` is a diagnostics label only: when it names one of the deny
5
+ * roots it is forwarded as `--protected-home` so the runner can word its
6
+ * hardlink/scan remediation for the protected `$DSH_HOME`. It never changes
7
+ * which roots are denied or how.
8
+ */
9
+ export declare function restrictWindowsProfile(profile: readonly string[], deniedRoots: readonly string[], protectedHome?: string): string[];
10
+ export declare function widenWindowsProfile(profile: readonly string[], roots: readonly string[], deniedRoots: readonly string[], primaryRoot?: string): string[];