@lone-wolf-akela/dsh-sandboxie-redirect 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/naming.mjs ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Pure naming/geometry helpers. Deliberately free of koffi and of any DSH core
3
+ * import, so the harness host process can load this module safely (the host
4
+ * must not pull the FFI binding in just to render a system-prompt line).
5
+ *
6
+ * Box names are Docker-style word pairs derived deterministically from the
7
+ * workspace, so every consumer — the launcher, `--manage`, and the UI — derives
8
+ * the same name with no shared state file:
9
+ *
10
+ * workspace-write / copy-on-write -> `dsh_<adjective>_<noun>`
11
+ * read-only -> `dshr_<adjective>_<noun>`
12
+ *
13
+ * `boxNameCandidates()` walks a deterministic sequence so a collision between
14
+ * two workspaces resolves to the next pair instead of either sharing a box or
15
+ * falling back to hex.
16
+ */
17
+ import path from "node:path";
18
+ import { ADJECTIVES, NOUNS } from "./words.mjs";
19
+
20
+ /** Canonical form of a workspace path used for hashing (no trailing separator). */
21
+ export function canonicalWorkspace(workspace) {
22
+ let resolved = path.resolve(workspace);
23
+ if (resolved.length > 3 && resolved.endsWith(path.sep)) resolved = resolved.slice(0, -1);
24
+ return resolved;
25
+ }
26
+
27
+ /** 64-bit FNV-1a over the UTF-8 bytes of `text`. Same algorithm in host and browser. */
28
+ export function fnv1a64(text) {
29
+ let hash = 0xcbf29ce484222325n;
30
+ const prime = 0x100000001b3n;
31
+ const mask = 0xffffffffffffffffn;
32
+ for (let index = 0; index < text.length; index += 1) {
33
+ hash ^= BigInt(text.charCodeAt(index));
34
+ hash = (hash * prime) & mask;
35
+ }
36
+ return hash;
37
+ }
38
+
39
+ /** The box-name prefix that encodes the workspace-access mode. */
40
+ export function boxPrefix(mode) {
41
+ return mode === "read-only" ? "dshr" : "dsh";
42
+ }
43
+
44
+ function wordsFor(key, nonce) {
45
+ const hash = fnv1a64(nonce === 0 ? key : `${key}#${nonce}`);
46
+ const adjective = ADJECTIVES[Number(hash % BigInt(ADJECTIVES.length))];
47
+ const noun = NOUNS[Number((hash / BigInt(ADJECTIVES.length)) % BigInt(NOUNS.length))];
48
+ return `${adjective}_${noun}`;
49
+ }
50
+
51
+ /** The first-choice box name (no collision handling): `dsh_brisk_otter`. */
52
+ export function boxNameFor(workspace, mode) {
53
+ return `${boxPrefix(mode)}_${wordsFor(`${mode === "read-only" ? "ro" : "rw"}:${canonicalWorkspace(workspace).toLowerCase()}`, 0)}`;
54
+ }
55
+
56
+ /** The deterministic fallback sequence for one workspace, first choice first. */
57
+ export function boxNameCandidates(workspace, mode, count = 8) {
58
+ const key = `${mode === "read-only" ? "ro" : "rw"}:${canonicalWorkspace(workspace).toLowerCase()}`;
59
+ const prefix = boxPrefix(mode);
60
+ const names = [];
61
+ for (let nonce = 0; nonce < count; nonce += 1) {
62
+ const name = `${prefix}_${wordsFor(key, nonce)}`;
63
+ if (!names.includes(name)) names.push(name);
64
+ }
65
+ return names;
66
+ }
67
+
68
+ /**
69
+ * Whether a box name is one this plugin manages — including the legacy
70
+ * `Dsh_<8 hex>` scheme, so boxes created before the word naming stay visible
71
+ * and cleanable in `--manage`.
72
+ */
73
+ export function isManagedBoxName(name) {
74
+ return /^dshr?_[a-z]+_[a-z]+$/.test(name) || /^Dshr?_[0-9a-f]{8}$/.test(name);
75
+ }
76
+
77
+ /** Sandboxie's default sandbox root — the PARENT of every box directory. */
78
+ export function defaultSandboxRoot(env = process.env) {
79
+ return path.join(env.SystemDrive ?? "C:", "Sandbox", env.USERNAME ?? "user");
80
+ }
81
+
82
+ /**
83
+ * Expand a Sandboxie `FileRootPath` into one box's own directory, for example
84
+ * `C:\Sandbox\%USER%\%SANDBOX%` → `C:\Sandbox\<user>\dsh_able_willow`.
85
+ *
86
+ * The setting names the BOX's folder, `%SANDBOX%` included, and that is why a
87
+ * template WITHOUT that placeholder is refused instead of followed: every box
88
+ * would resolve to one shared directory, and that directory is what `--manage`
89
+ * deletes. Returning `null` keeps the caller on the default layout — a wrong
90
+ * path costs a missed cleanup, never a deletion of the wrong tree.
91
+ *
92
+ * Also `null` for an empty template, an unknown placeholder (`%SID%`, …), a
93
+ * placeholder whose environment value is missing, or a relative result.
94
+ */
95
+ export function expandFileRootPath(template, options = {}) {
96
+ const env = options.env ?? process.env;
97
+ const box = options.box;
98
+ if (typeof template !== "string" || template.trim().length === 0) return null;
99
+ if (typeof box !== "string" || box.length === 0) return null;
100
+ if (!template.toLowerCase().includes("%sandbox%")) return null;
101
+
102
+ const user = env.USERNAME ?? "user";
103
+ const values = {
104
+ "%USER%": user,
105
+ "%SANDBOX%": box,
106
+ "%SYSTEMDRIVE%": env.SystemDrive ?? "C:",
107
+ "%USERPROFILE%": env.USERPROFILE ?? path.join(env.SystemDrive ?? "C:", "Users", user),
108
+ "%WINDIR%": env.WINDIR ?? path.join(env.SystemDrive ?? "C:", "Windows")
109
+ };
110
+
111
+ let expanded = template.trim();
112
+ for (const [token, value] of Object.entries(values)) {
113
+ if (!expanded.toLowerCase().includes(token.toLowerCase())) continue;
114
+ if (value.length === 0) return null;
115
+ expanded = expanded.replace(new RegExp(token, "gi"), value);
116
+ }
117
+
118
+ // Any surviving placeholder belongs to Sandboxie's wider set (%SID%, %APPDATA%, …).
119
+ if (/%[A-Za-z_]+%/.test(expanded)) return null;
120
+ const resolved = path.normalize(expanded);
121
+ return path.isAbsolute(resolved) ? resolved : null;
122
+ }
123
+
124
+ /**
125
+ * The directory one box's copy lives in, in order: `DSH_SBIE_ROOT\<box>`, the
126
+ * expanded `FileRootPath`, the default layout's `<sandbox root>\<box>`.
127
+ *
128
+ * @param box - the box name.
129
+ * @param options.template - Sandboxie's `FileRootPath` for that box, when known.
130
+ */
131
+ export function resolveBoxRoot(box, options = {}) {
132
+ const env = options.env ?? process.env;
133
+ const override = env.DSH_SBIE_ROOT;
134
+ if (typeof override === "string" && override.trim().length > 0) return path.join(path.resolve(override.trim()), box);
135
+ return expandFileRootPath(options.template, { box, env }) ?? path.join(defaultSandboxRoot(env), box);
136
+ }
137
+
138
+ /**
139
+ * The sandbox root for this machine as far as a caller with no access to
140
+ * Sandboxie's configuration can tell: the override, else the default layout.
141
+ */
142
+ export function sbieSandboxRoot(env = process.env) {
143
+ const override = env.DSH_SBIE_ROOT;
144
+ if (typeof override === "string" && override.trim().length > 0) return path.resolve(override.trim());
145
+ return defaultSandboxRoot(env);
146
+ }
147
+
148
+ /**
149
+ * Where Sandboxie keeps a box's redirected (copy-on-write) tree.
150
+ *
151
+ * @param box - the box name.
152
+ * @param root - an already-resolved sandbox ROOT (the parent), for callers that
153
+ * read Sandboxie's configuration themselves; defaults to
154
+ * {@link sbieSandboxRoot}. Callers holding a `FileRootPath` want
155
+ * {@link resolveBoxRoot} instead — that setting is the box directory itself.
156
+ */
157
+ export function boxRoot(box, root) {
158
+ return path.join(root ?? sbieSandboxRoot(), box);
159
+ }
package/lib/note.mjs ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Model-facing note for copy-on-write sessions.
3
+ *
4
+ * The stock policy line (`sandbox:policy`, rendered by `dsh-sandbox-policy`)
5
+ * describes the resolved MODE, which for this preset is truthfully
6
+ * `workspace-write`. What it cannot say is that shell commands additionally run
7
+ * inside a Sandboxie box whose out-of-workspace writes are redirected. This
8
+ * plugin contributes exactly that sentence, and nothing when the preset is not
9
+ * selected.
10
+ *
11
+ * No harness import is needed: the two services it uses arrive through cordis.
12
+ */
13
+ import { REDIRECT_PRESET, redirectPolicyNote } from "./redirect.mjs";
14
+ import { rootForBox } from "./host.mjs";
15
+ import { boxNameFor } from "./naming.mjs";
16
+
17
+ export const name = "dsh-sandboxie-redirect-note";
18
+ export const inject = ["systemPrompt"];
19
+
20
+ export function apply(ctx) {
21
+ ctx.inject(["systemPrompt", "sessionProjections"], (scope) => {
22
+ const noteFor = (session) => {
23
+ if (session === undefined) return "";
24
+ try {
25
+ const state = scope.get("sessionProjections").stateOf(session, "permissions");
26
+ if (state?.preset !== REDIRECT_PRESET) return "";
27
+ const cwd = session.header?.cwd;
28
+ if (typeof cwd !== "string" || cwd.length === 0) return "";
29
+ // Name the root Sandboxie is actually configured with, not the default
30
+ // layout: the model is told to trust the env vars over this prose, but
31
+ // the prose should not contradict them.
32
+ return redirectPolicyNote(cwd, rootForBox(boxNameFor(cwd, "workspace-write")));
33
+ } catch {
34
+ return "";
35
+ }
36
+ };
37
+ scope.systemPrompt.context({
38
+ name: "sandbox:copy-on-write",
39
+ order: scope.systemPrompt.getContextOrder("SANDBOX_POLICY") + 1,
40
+ text: (context) => noteFor(context?.agent?.session)
41
+ });
42
+ });
43
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Frame codec for the launcher <-> boxed-relay TCP channel.
3
+ *
4
+ * The relay runs *inside* the Sandboxie box, so it cannot be handed the
5
+ * launcher's stdio handles (SbieSvc creates it, not us — see M0 findings).
6
+ * A length-prefixed frame stream over a loopback socket is the transport that
7
+ * survives the box boundary (verified: named pipes do not, TCP loopback does).
8
+ *
9
+ * frame := u8 type | u32be length | length bytes
10
+ */
11
+
12
+ export const Frame = {
13
+ HELLO: 1,
14
+ SPEC: 2,
15
+ STDIN: 3,
16
+ STDIN_EOF: 4,
17
+ STDOUT: 16,
18
+ STDERR: 17,
19
+ EXIT: 18,
20
+ FAIL: 19,
21
+ READY: 20
22
+ };
23
+
24
+ export const MAX_FRAME = 8 * 1024 * 1024;
25
+
26
+ /** Encode one frame. `payload` may be a string (utf8) or a Buffer. */
27
+ export function encodeFrame(type, payload = Buffer.alloc(0)) {
28
+ const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), "utf8");
29
+ const head = Buffer.allocUnsafe(5);
30
+ head.writeUInt8(type, 0);
31
+ head.writeUInt32BE(body.length, 1);
32
+ return Buffer.concat([head, body]);
33
+ }
34
+
35
+ /** Encode one frame carrying JSON. */
36
+ export function encodeJsonFrame(type, value) {
37
+ return encodeFrame(type, JSON.stringify(value));
38
+ }
39
+
40
+ /**
41
+ * Incremental decoder: feed it socket chunks, receive complete frames.
42
+ * @param {(type: number, body: Buffer) => void} onFrame
43
+ * @param {(error: Error) => void} [onError]
44
+ */
45
+ export function createFrameDecoder(onFrame, onError) {
46
+ let pending = Buffer.alloc(0);
47
+ return (chunk) => {
48
+ pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]);
49
+ for (;;) {
50
+ if (pending.length < 5) return;
51
+ const type = pending.readUInt8(0);
52
+ const length = pending.readUInt32BE(1);
53
+ if (length > MAX_FRAME) {
54
+ onError?.(new Error(`frame too large: ${length}`));
55
+ return;
56
+ }
57
+ if (pending.length < 5 + length) return;
58
+ const body = pending.subarray(5, 5 + length);
59
+ pending = pending.subarray(5 + length);
60
+ onFrame(type, body);
61
+ }
62
+ };
63
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Shell sandbox provider for DSH: stock behaviour everywhere, Sandboxie
3
+ * copy-on-write for sessions whose permission preset is `copy-on-write`.
4
+ *
5
+ * Why a subclass rather than more `runnerCommand` config: `runnerCommand`
6
+ * applies to EVERY mode (the seam early-returns as soon as it is set), so a
7
+ * configured runner cannot leave `read-only` / `workspace-write` on their
8
+ * original ACL restricted-token path. Overriding `confine` per call does.
9
+ *
10
+ * The preset — not the resolved mode — is what distinguishes copy-on-write.
11
+ * The preset deliberately bundles `sandbox: workspace-write`, so the mode stays
12
+ * a legal literal: the session projection schema, `SANDBOX_MODES`, the file
13
+ * fence (`writableRoots`) and the escalation tables are all untouched, and the
14
+ * model-visible mode string stays truthful for the file tools.
15
+ *
16
+ * Failure posture: everything about the preset lookup is best-effort, and any
17
+ * surprise falls back to `super.confine` (the stock behaviour). Only a failure
18
+ * to import the harness packages themselves is fatal, and that case still
19
+ * registers a provider that fails CLOSED instead of running commands
20
+ * unconfined.
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { importCore } from "./core.mjs";
26
+ import { invalidateBoxCache } from "./host.mjs";
27
+ import { logFile } from "./log.mjs";
28
+ import { canonicalWorkspace } from "./naming.mjs";
29
+ import { REDIRECT_PRESET, isRedirectPresetState, redirectWrap, resolveNodePath } from "./redirect.mjs";
30
+ // Imported, not mounted as their own loader rows: see wireCompanions().
31
+ import { apply as applyHostHalf } from "./host.mjs";
32
+ import { apply as applyNote } from "./note.mjs";
33
+ import { apply as applyTool } from "./tool.mjs";
34
+
35
+ const here = path.dirname(fileURLToPath(import.meta.url));
36
+ const launcherPath = path.resolve(here, "..", "bin", "dsh-sbie-run.mjs");
37
+ // Diagnostics go to the plugin's state directory: an installed bundle lives in
38
+ // the profile's node_modules (pnpm may serve it from its content-addressed
39
+ // store), which is no place to append log lines.
40
+ const LOG = logFile("provider.log");
41
+
42
+ function log(line) {
43
+ try {
44
+ fs.appendFileSync(LOG, `${new Date().toISOString()} ${line}\n`);
45
+ } catch {}
46
+ }
47
+
48
+ let Core = null;
49
+ let Base = null;
50
+ let loadError;
51
+ try {
52
+ Core = await importCore("@deepseek-ai/cordis");
53
+ Base = (await importCore("@deepseek-ai/dsh-sandbox-local")).default;
54
+ log(`loaded: cordis=${typeof Core.Service} base=${Base?.name} config=${Base?.Config !== undefined} launcher=${launcherPath}`);
55
+ } catch (error) {
56
+ loadError = error;
57
+ log(`LOAD FAILED: ${error?.message ?? error}`);
58
+ }
59
+
60
+ /**
61
+ * Mount the rest of the plugin: the header chip's projection, the model-facing
62
+ * note, and the `sandbox_clear` tool.
63
+ *
64
+ * They used to be their own profile rows. They cannot be: the client-modules
65
+ * registry composes a `dsh.client` package only when exactly ONE of its loader
66
+ * entries is active, and it fails the whole boot otherwise —
67
+ *
68
+ * client-modules: package dsh-sandboxie-redirect resolves from multiple
69
+ * active Loader sources: "…/lib/host.mjs", "…/lib/tool.mjs"; remove one entry
70
+ *
71
+ * — with the app offering "disable third-party plugins" as the only way out.
72
+ * Which entries count depends on activation timing, so several rows into one
73
+ * package made every start a race. One entry, three imports: no race to lose.
74
+ *
75
+ * Each companion is isolated so a failure costs that feature and nothing else.
76
+ * `note.mjs` and `tool.mjs` inject their own dependencies inside their `apply`,
77
+ * so handing them this ctx is enough.
78
+ * @param ctx - the provider's context.
79
+ */
80
+ function wireCompanions(ctx) {
81
+ const mount = (label, run) => {
82
+ try {
83
+ const result = run(ctx);
84
+ // tool.mjs's apply is async; a rejection must not become an unhandled one.
85
+ if (result !== undefined && typeof result.then === "function") {
86
+ result.then(undefined, (error) => log(`${label} failed: ${error?.stack ?? error}`));
87
+ }
88
+ log(`wired ${label}`);
89
+ } catch (error) {
90
+ log(`wiring ${label} FAILED: ${error?.stack ?? error}`);
91
+ }
92
+ };
93
+ mount("host half (header chip projection)", applyHostHalf);
94
+ mount("note (model-facing policy text)", applyNote);
95
+ mount("tool (sandbox_clear)", applyTool);
96
+ }
97
+
98
+ /** Last-resort provider: present (so the rest of the profile still loads) but refusing to run anything. */function makeFailClosedProvider(Service) {
99
+ return class FailClosedSandboxProvider extends Service {
100
+ constructor(ctx) {
101
+ super(ctx, "sandbox");
102
+ log("fail-closed provider constructed");
103
+ // The companions are pure-JS and do not depend on the harness packages
104
+ // that just failed to load, so they are still wired: a fail-closed sandbox
105
+ // must not also take the header chip or `sandbox_clear` down with it.
106
+ wireCompanions(ctx);
107
+ }
108
+ async confine() {
109
+ const error = new Error(
110
+ `sandbox: the copy-on-write provider could not load the harness packages (${loadError?.message ?? "unknown error"}); refusing to run the command unconfined. Switch the session to danger-full-access to run without confinement.`
111
+ );
112
+ error.code = "SANDBOX_UNAVAILABLE";
113
+ throw error;
114
+ }
115
+ };
116
+ }
117
+
118
+ function makeRedirectProvider(BaseClass) {
119
+ return class CopyOnWriteSandboxProvider extends BaseClass {
120
+ constructor(ctx, config) {
121
+ super(ctx, config);
122
+ log(`provider constructed (runnerCommand=${this.runnerCommand === undefined ? "unset" : "SET"})`);
123
+ // This class is the package's ONLY loader entry, and that is load-bearing.
124
+ // The client-modules registry refuses to compose a `dsh.client` package
125
+ // that resolves from more than one ACTIVE loader entry, and which entries
126
+ // are active depends on activation timing — so several rows pointing into
127
+ // this package made startup a race that eventually failed outright
128
+ // ("resolves from multiple active Loader sources; remove one entry").
129
+ // The other halves are imported modules now, wired here instead of by
130
+ // their own rows. Each registration is isolated: a broken one costs that
131
+ // feature, never the profile.
132
+ wireCompanions(ctx);
133
+ }
134
+
135
+ /**
136
+ * Whether this call belongs to a copy-on-write session. Any failure to read
137
+ * the imperative state answers `false`, which routes the call to the stock
138
+ * path — the safe direction, since the stock path is the original behaviour.
139
+ *
140
+ * Two independent readings, because only one of them may exist depending on
141
+ * load order: the permission service's own derivation (`current(session)`,
142
+ * which folds the `permission/preset` event and the knob state), and the
143
+ * raw `permissions` projection.
144
+ */
145
+ isCopyOnWrite(policy) {
146
+ const sessionId = policy?.sessionId;
147
+ if (sessionId === undefined) return false;
148
+ try {
149
+ const sessions = this.ctx.get("sessions");
150
+ if (sessions === undefined) return false;
151
+ const session = sessions.list().find((candidate) => String(candidate.id) === String(sessionId));
152
+ if (session === undefined) return false;
153
+
154
+ const presets = this.ctx.get("permissionPresets");
155
+ if (presets !== undefined && typeof presets.current === "function") {
156
+ if (presets.current(session) === REDIRECT_PRESET) return true;
157
+ }
158
+ const projections = this.ctx.get("sessionProjections");
159
+ if (projections !== undefined) return isRedirectPresetState(projections.stateOf(session, "permissions"));
160
+ return false;
161
+ } catch (error) {
162
+ log(`preset lookup failed, falling back to stock: ${error?.message ?? error}`);
163
+ return false;
164
+ }
165
+ }
166
+
167
+ async confine(argv, policy, signal) {
168
+ signal?.throwIfAborted();
169
+ if (!this.isCopyOnWrite(policy)) {
170
+ if (!this.loggedStock) {
171
+ this.loggedStock = true;
172
+ log(`stock path in use (mode=${policy?.mode}, sessionId=${policy?.sessionId ?? "none"})`);
173
+ }
174
+ return super.confine(argv, policy, signal);
175
+ }
176
+ const workspace = canonicalWorkspace(policy.workspaceRoot ?? process.cwd());
177
+ const nodePath = resolveNodePath();
178
+ if (nodePath === null) {
179
+ // No safe fallback exists: `process.execPath` in this host is the
180
+ // Electron binary, and handing it to the launcher would fail later,
181
+ // inside the box, where nothing can attribute the failure. Fail here,
182
+ // loudly, with the one thing the user can act on.
183
+ const error = new Error(
184
+ "sandbox: no genuine Node runtime found to run the Sandboxie launcher, so the copy-on-write preset cannot confine this command. Set DSH_SBIE_NODE to a node.exe path, or switch this session to another permission preset."
185
+ );
186
+ error.code = "SANDBOX_UNAVAILABLE";
187
+ log(`redirect refused: ${error.message}`);
188
+ throw error;
189
+ }
190
+ // The launcher may be about to CREATE this workspace's box. The host
191
+ // half's box lookup memoizes the Sandboxie configuration for a moment,
192
+ // so a "no box yet" answer cached before this command must not be the one
193
+ // the post-command event folds — otherwise the header chip would lag
194
+ // behind the box by up to the memo window. Same module instance, so this
195
+ // clears the cache the projection reads.
196
+ try {
197
+ invalidateBoxCache();
198
+ } catch {
199
+ /* a stale cache costs a delayed chip, never a broken command */
200
+ }
201
+ log(`redirect: node=${nodePath} workspace=${workspace} argv0=${argv[0]}`);
202
+ return redirectWrap({ nodePath, launcherPath, workspace, argv });
203
+ }
204
+ };
205
+ }
206
+
207
+ const Exported = Base === null ? makeFailClosedProvider(Core.Service) : makeRedirectProvider(Base);
208
+ log(`exporting ${Exported.name}`);
209
+ export default Exported;
210
+ export { REDIRECT_PRESET, launcherPath };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The copy-on-write preset, as pure values and functions.
3
+ *
4
+ * No core import, no koffi: this module is unit-testable with plain Node and is
5
+ * the single source of truth for the preset id, the redirect argv shape, the
6
+ * node-path search, and the model-facing explanation.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { boxNameFor, boxRoot, canonicalWorkspace } from "./naming.mjs";
12
+
13
+ /**
14
+ * The preset id that selects copy-on-write. It is written into the session log
15
+ * (`permission/preset` events and the `permissions` projection), so it is a
16
+ * compatibility surface: renaming it later makes existing sessions fall back to
17
+ * `custom` instead of matching.
18
+ */
19
+ export const REDIRECT_PRESET = "copy-on-write";
20
+
21
+ /** Signatures this runner's own failures speak (must match the profile's runnerFailureSignatures). */
22
+ export const RUNNER_FATAL_SIGNATURE = "dsh-sbie-run: ";
23
+
24
+ /**
25
+ * Whether a session's folded `permissions` state selects copy-on-write.
26
+ * Anything other than an exact preset match is NOT copy-on-write, so every
27
+ * unexpected shape fails back to the stock workspace-write behaviour.
28
+ */
29
+ export function isRedirectPresetState(state) {
30
+ return state?.preset === REDIRECT_PRESET;
31
+ }
32
+
33
+ /**
34
+ * Every place a genuine `node.exe` may live, most specific first.
35
+ *
36
+ * `process.execPath` is deliberately NOT a candidate while the host is
37
+ * Electron: there it is the Electron binary, and starting the launcher with it
38
+ * would put the native Sandboxie work back into an Electron process — the very
39
+ * thing this design avoids. Under a plain-Node host (the source-execution path)
40
+ * it IS a genuine node and is used.
41
+ *
42
+ * Order matters. The desktop app ships its own Node under its `resources`
43
+ * directory, which is user-relocatable and therefore only reachable through
44
+ * `process.resourcesPath`. The managed-runtime pool under the DSH home comes
45
+ * second, because this build provisions it lazily, only when something asks for
46
+ * it — a fresh machine may not have it yet.
47
+ */
48
+ export function nodeCandidates(options = {}) {
49
+ const env = options.env ?? process.env;
50
+ const exists = options.exists ?? fs.existsSync;
51
+ const found = [];
52
+ const push = (candidate) => {
53
+ if (typeof candidate !== "string" || candidate.length === 0) return;
54
+ if (found.includes(candidate)) return;
55
+ try {
56
+ if (exists(candidate)) found.push(candidate);
57
+ } catch {
58
+ /* an unreadable candidate is simply not a candidate */
59
+ }
60
+ };
61
+
62
+ push(env.DSH_SBIE_NODE);
63
+
64
+ const resources = typeof options.resourcesPath === "string"
65
+ ? options.resourcesPath
66
+ : (typeof process.resourcesPath === "string" ? process.resourcesPath : undefined);
67
+ if (resources !== undefined) {
68
+ push(path.join(resources, "runtime", "primary-runtime", "dependencies", "node", "bin", "node.exe"));
69
+ }
70
+
71
+ const home = options.homeDir ?? os.homedir();
72
+ const dshHome = typeof env.DSH_HOME === "string" && env.DSH_HOME.length > 0 ? env.DSH_HOME : path.join(home, ".dsh");
73
+ const runtimes = path.join(dshHome, "dsh-runtimes");
74
+ let entries = [];
75
+ try {
76
+ entries = fs.readdirSync(runtimes);
77
+ } catch {
78
+ /* no runtime pool on this machine (yet) */
79
+ }
80
+ for (const entry of entries) {
81
+ push(path.join(runtimes, entry, "dependencies", "node", "bin", "node.exe"));
82
+ }
83
+
84
+ const electron = options.isElectron ?? (typeof process.versions?.electron === "string");
85
+ if (electron === false) push(options.execPath ?? process.execPath);
86
+
87
+ return found;
88
+ }
89
+
90
+ /**
91
+ * The one node to run the launcher with, or `null` when this machine offers
92
+ * none.
93
+ *
94
+ * `null` is a hard failure and the caller has to say so: there is NO safe
95
+ * fallback. An Electron binary is not a Node, and using it silently would fail
96
+ * later, inside the box, in a way nobody can attribute.
97
+ */
98
+ export function resolveNodePath(options = {}) {
99
+ return nodeCandidates(options)[0] ?? null;
100
+ }
101
+
102
+ /**
103
+ * The redirect invocation DSH hands to the sandbox provider.
104
+ *
105
+ * Explicit flags rather than the bubblewrap dialect: the launcher then reads
106
+ * the workspace and the mode directly instead of inferring them from
107
+ * `--bind <ws> <ws>`.
108
+ */
109
+ export function redirectArgv(request) {
110
+ const workspace = canonicalWorkspace(request.workspace);
111
+ return [
112
+ request.nodePath,
113
+ request.launcherPath,
114
+ "--workspace",
115
+ workspace,
116
+ "--mode",
117
+ "workspace-write",
118
+ "--",
119
+ ...request.argv
120
+ ];
121
+ }
122
+
123
+ /** The copy-on-write redirect facts reported to the sandbox seam. */
124
+ export function redirectWrap(request) {
125
+ return {
126
+ argv: redirectArgv(request),
127
+ enforcement: "full",
128
+ denialSignatures: ["read-only file system", "permission denied"],
129
+ runnerFailureRules: [{ fatalSignatures: [RUNNER_FATAL_SIGNATURE] }]
130
+ };
131
+ }
132
+
133
+ /**
134
+ * The model-facing explanation for a copy-on-write session.
135
+ *
136
+ * This is the ONLY channel that reaches a future agent automatically: it is
137
+ * contributed as a per-session system-prompt context, so it is present on every
138
+ * request while the preset is selected and absent otherwise. It carries the
139
+ * operating rules an agent cannot infer from the sandbox's behaviour — the
140
+ * ones that otherwise cost a hung command or a wrong conclusion about whether
141
+ * the real disk changed. Full detail (and the reasoning) lives in README.zh.md.
142
+ *
143
+ * @param cwd - the session workspace; the box root shown to the model is derived from it.
144
+ * @param root - the copy root, when the caller could read Sandboxie's configured
145
+ * sandbox folder; omitted, the default layout is named instead.
146
+ */
147
+ export function redirectPolicyNote(cwd, root) {
148
+ const workspace = canonicalWorkspace(cwd ?? process.cwd());
149
+ const copyRoot = typeof root === "string" && root.length > 0 ? root : boxRoot(boxNameFor(workspace, "workspace-write"));
150
+ return [
151
+ "This session's file policy is copy-on-write (copy-on-write preset) for shell commands:",
152
+ `- Writes under the workspace (${JSON.stringify(workspace)}) go to the real disk as usual.`,
153
+ `- Writes, edits and deletes ANYWHERE ELSE still succeed, but only inside this workspace's Sandboxie copy at ${JSON.stringify(copyRoot)}; the real files outside the workspace are not touched.`,
154
+ "- Deletions outside the workspace leave the real file in place and only record a tombstone in that copy.",
155
+ "",
156
+ "Operating rules for this mode:",
157
+ "- Reading back an out-of-workspace write: read it WITH A SHELL COMMAND (the sandbox merges the copy for you). The harness read/write/edit file tools run OUTSIDE the sandbox, so they see the real disk and still refuse writes outside the workspace. Never conclude \"the write did not happen\" from a host-side read tool — and never conclude \"it leaked to the real disk\" from a shell `Test-Path`, which sees the merged view. Host tools see reality; shell commands see the copy.",
158
+ "- The box name and root are resolved against Sandboxie's own state (a name already owned by another workspace steps to the next candidate), so do NOT derive them from the workspace path. Inside a command, `$env:DSH_SBIE_BOX` and `$env:DSH_SANDBOX_ROOT` are authoritative.",
159
+ "- Escalation (`sandbox_permissions` together with `justification`) runs ONE call entirely OUTSIDE the box, against the real disk; the next call is back in the box.",
160
+ `- That parameter's own description — \"the narrowest wider sandbox mode for a one-shot retry of the exact command the sandbox just denied\" — is written for the ACL presets (read-only / workspace-write), where a denial comes first. Under copy-on-write nothing outside the workspace is ever denied: the write succeeds into the copy, no \`[sandbox: file access denied under … mode]\` marker appears, and there is no denied command to retry. So do not wait for such a marker before escalating, and do not read the absence of one as \"the real disk was written\". Choose escalation deliberately: it is the lever for a call that must genuinely act on the real disk (installing or configuring something outside the workspace, or replacing a stale copy with reality), it is never a way to make a single file real, and it is not needed to READ the real disk — the host-side file tools already read it.`,
161
+ "- The same redirection silently captures anything you INSTALL or CONFIGURE outside the workspace from a command — a plugin under `~/.dsh/plugins`, a profile file, any config edit. The write reports success, lands in the copy, and an in-box read-back confirms it, so the real file keeps its old contents while every check you can run from inside the box says otherwise. Judge such a change with a HOST-SIDE read, or make it through escalation; `tools/install.mjs` refuses to run inside a box for exactly this reason.",
162
+ "- Inside a command, never enumerate or walk `C:\\Sandbox\\`: that is the box's own storage, and walking it recurses into the redirection layer and hangs. Inspect the copy with host-side tools instead.",
163
+ "- `--manage` and any nested launch of `bin/dsh-sbie-run.mjs` need Sandboxie's API, and Sandboxie hides the box list from a process that is itself inside a box. Run those from a `danger-full-access` session (or a plain console), not from a shell command here.",
164
+ "",
165
+ "- The copy is not auto-recovered. It can be inspected, recovered or discarded from the Sandboxie SandMan window, or removed with `--manage clean`."
166
+ ].join("\n");
167
+ }