@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.
- package/CHANGELOG.en.md +25 -1
- package/CHANGELOG.md +25 -1
- package/README.en.md +10 -8
- package/README.md +10 -8
- package/cordis.patch.yml +7 -2
- package/lib/auto-review.js +1 -1
- package/lib/client.js +197 -74
- package/lib/client.js.map +1 -1
- package/lib/{command-BGdz7VBH.js → command-B9855H7y.js} +3 -2
- package/lib/command.js +1 -1
- package/lib/{compat-BiW-m_q0.js → compat-Bg2HFVMX.js} +3 -0
- package/lib/compat.js +1 -1
- package/lib/{sandbox-CXk5h-ct.js → dialects-Dr4rDR-H.js} +6 -264
- package/lib/{fs-BTXySwSG.js → fs-CfzJq2wd.js} +23 -8
- package/lib/fs.js +1 -1
- package/lib/index.js +7 -6
- package/lib/{instructions-MePoRigy.js → instructions-KsUwUFE0.js} +53 -37
- package/lib/instructions.js +1 -1
- package/lib/lsp.js +19 -16
- package/lib/native-win32-2waCRkPI.js +312 -0
- package/lib/process-fence.js +62 -12
- package/lib/protocol-CJOvAvOS.js +652 -0
- package/lib/{registry-KSsmU393.js → registry-Bv431TfO.js} +717 -74
- package/lib/registry-operation-C2ZV3Jhw.js +116 -0
- package/lib/registry.js +3 -2
- package/lib/{review-context-AH-SXHmj.js → review-context-NDpKeMCc.js} +1 -1
- package/lib/review-context.js +1 -1
- package/lib/sandbox-C-vCJCp3.js +278 -0
- package/lib/sandbox.js +1 -1
- package/lib/scope.js +101 -11
- package/lib/skills.js +48 -29
- package/lib/types/authority/native-win32.d.ts +7 -0
- package/lib/types/authority/native.d.ts +23 -0
- package/lib/types/authority/protocol.d.ts +70 -0
- package/lib/types/authority/recovery.d.ts +58 -0
- package/lib/types/client/RootSwitcher.d.ts +6 -3
- package/lib/types/client/files-tree/adapter.d.ts +63 -1
- package/lib/types/compat/authority-storage.d.ts +6 -0
- package/lib/types/compat/dsh-version.d.ts +4 -4
- package/lib/types/compat/open-in-app-device.d.ts +1 -1
- package/lib/types/compat/process-entrypoints.d.ts +2 -0
- package/lib/types/compat/process-profile.d.ts +3 -0
- package/lib/types/compat/sandbox-confine.d.ts +2 -0
- package/lib/types/fs.d.ts +4 -1
- package/lib/types/instructions.d.ts +0 -47
- package/lib/types/registry-operation.d.ts +31 -0
- package/lib/types/registry.d.ts +145 -6
- package/lib/types/scope.d.ts +28 -0
- package/lib/types/skills/catalog.d.ts +3 -0
- package/lib/types/skills/pool.d.ts +1 -1
- package/lib/workspace-files.js +63 -7
- 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-
|
|
2
|
-
|
|
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-
|
|
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";
|
package/lib/review-context.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as MultiRootReviewContext } from "./review-context-
|
|
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
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.
|
|
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
|
-
|
|
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
|
|
258
|
-
const
|
|
259
|
-
const
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
457
|
+
this.notifySubscribers();
|
|
368
458
|
}
|
|
369
459
|
};
|
|
370
460
|
//#endregion
|