dsh-gitbash-shell 0.39.4 → 0.40.1

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.
@@ -0,0 +1,297 @@
1
+ import { registerExperimentalTool } from './experimental-tools.js'
2
+
3
+ /**
4
+ * dsh-gitbash-shell/path-tool — the EXPLICIT full-value MSYS→native converter
5
+ * (`gitbash_to_native_path`, 2026-10-13, compat-implementation task
6
+ * IMPL-PATH-HELPER).
7
+ *
8
+ * WHY. run_code hands a program's path STRINGS to native Node, and the legacy
9
+ * codePaths literal rewrite can only ever guess which literals are paths
10
+ * (research REPORT.md: route/search data still gets corrupted, dynamic values
11
+ * are out of reach by design). The explicit alternative: the CALLER decides a
12
+ * complete value is a path and converts it AT RUNTIME through one tool:
13
+ *
14
+ * const r = await tools.gitbash_to_native_path({ msys_path: p })
15
+ * await fs.readFile(r.native_path, 'utf8')
16
+ *
17
+ * Contract (mirrors the approved research fixture `fixture_to_native`):
18
+ * - the parameter is named `msys_path` ON PURPOSE: the automatic
19
+ * tools/execute path-field rewrite only touches the whitelisted
20
+ * `file_path`/`path`/`workdir` fields (src/index.js
21
+ * TRANSLATABLE_PATH_FIELDS), so the raw MSYS value reaches this tool
22
+ * intact and the conversion is the tool's own explicit job;
23
+ * - the value goes through the SHARED `translateMsysPath` + a FRESH
24
+ * `buildTranslateEnv(...)` per call (injected, never copied here) — drive
25
+ * roots, ~, $HOME-style variables, /tmp, /dev/null and the Git install
26
+ * mounts resolve exactly like every other translated face of the plugin;
27
+ * - NO filesystem I/O, NO existence check, NO permission grant, NO global
28
+ * env seeding: the caller's own runtime policy still governs what the
29
+ * converted path may touch;
30
+ * - already Windows-native (drive-letter/UNC) or cwd-relative values are
31
+ * returned unchanged — declared here, in the description;
32
+ * - a POSIX-looking value that NO mount maps, or whose needed mount fact is
33
+ * unavailable, is REJECTED with an explicit error: returning the POSIX
34
+ * spelling as `native_path` would be a silent lie;
35
+ * - empty / NUL-byte / URL-shaped values are rejected outright; Unicode,
36
+ * `#` and spaces inside a path are preserved byte for byte;
37
+ * - the tool is INDEPENDENT of the automatic dialect switches
38
+ * (posixPaths/codePaths/virtualMounts): whether it is registered at all
39
+ * (and the Windows-only gate) is the main wiring's decision — this module
40
+ * carries no Config and reads no dialect state.
41
+ *
42
+ * Mounting follows the bash-tool pattern: `createPathToolModule(deps)` returns
43
+ * a cordis module ({ inject, apply }) that the main wiring mounts inside an
44
+ * agent-owned scope; registration reverts through the scope's effect on
45
+ * disposal. There are NO top-level static `@deepseek-ai/*` imports — the host
46
+ * `defineTool` and the shared translator ride the deps bag (v0.24.3 lesson: a
47
+ * failed static peer import silently drops the whole plugin row).
48
+ *
49
+ * The SUCCESS result is `{ native_path }`, a shape the result-echo layer
50
+ * never rewrites (rewriteResultPaths is keyed on the official tools' names
51
+ * and path metadata fields, not on this tool), so the native value cannot be
52
+ * translated back behind the caller's back.
53
+ *
54
+ * @module dsh-gitbash-shell/path-tool
55
+ */
56
+
57
+ /** The registered tool name. */
58
+ export const PATH_TOOL_NAME = 'gitbash_to_native_path'
59
+
60
+ /** Windows drive-letter absolute: `C:/x`, `C:\x` (drive-relative `C:` is NOT). */
61
+ const WINDOWS_ABSOLUTE = /^[A-Za-z]:[\\/]/
62
+
63
+ /** A `scheme:` prefix — `https:`, `file:`, `app:`, … (single-letter `C:` excluded). */
64
+ const URL_SCHEME = /^([A-Za-z][A-Za-z0-9+.\-]*):/
65
+
66
+ const NUL_BYTE = '\0'
67
+
68
+ /**
69
+ * Whether the value is ALREADY in a native Windows spelling (drive-letter
70
+ * absolute or UNC, both slash styles). Pure.
71
+ * @param {unknown} value
72
+ * @returns {boolean}
73
+ */
74
+ export function isWindowsNativeValue(value) {
75
+ return typeof value === 'string'
76
+ && (WINDOWS_ABSOLUTE.test(value) || value.startsWith('\\\\') || value.startsWith('//'))
77
+ }
78
+
79
+ /**
80
+ * Whether the value LOOKS like it needs MSYS mount resolution (POSIX root,
81
+ * home shorthand or a leading variable). A match does NOT promise a mapping
82
+ * exists — only that a passthrough would be a guess. Pure.
83
+ * @param {unknown} value
84
+ * @returns {boolean}
85
+ */
86
+ export function looksPosixValue(value) {
87
+ return typeof value === 'string'
88
+ && (value.startsWith('/') || value.startsWith('~') || value.startsWith('$'))
89
+ }
90
+
91
+ /**
92
+ * One honest line about which mount facts the conversion could see. `null`
93
+ * means the wiring turned mounts off; `undefined` means the wiring did not
94
+ * expose facts at all.
95
+ */
96
+ function factSummary(env) {
97
+ if (env === null) return 'mount facts unavailable (virtual mounts are off)'
98
+ if (!env || typeof env !== 'object') return 'mount facts not exposed by this wiring'
99
+ return `mount facts: home=${env.home ? 'yes' : 'no'} tmpDir=${env.tmpDir ? 'yes' : 'no'} gitRoot=${env.gitRoot ? 'yes' : 'no'}`
100
+ }
101
+
102
+ /**
103
+ * Convert ONE caller-declared full path value. Pure: string surgery only,
104
+ * the translator (the shared translateMsysPath composed with a fresh mount
105
+ * fact bag) is injected, nothing is probed here.
106
+ *
107
+ * Rejections are explicit and non-guessing:
108
+ * - EMPTY — not a non-empty string;
109
+ * - NUL_BYTE — a NUL byte anywhere in the value;
110
+ * - URL — a multi-letter scheme prefix (`https:…`, `file:…`);
111
+ * - NO_MOUNT_MAPPING — POSIX-looking but the shared mount table returned it
112
+ * unchanged: either no mount maps it or a needed fact is missing. The
113
+ * POSIX spelling is NEVER returned as `native_path`.
114
+ *
115
+ * @param {unknown} value - the complete value the caller has decided is a path.
116
+ * @param {{ translate?: (value: string, env?: object|null) => unknown,
117
+ * env?: object|null }} [options] - the injected shared translator
118
+ * and this call's fresh mount-fact bag (for the rejection diagnostics).
119
+ * @returns {{ ok: true, native_path: string,
120
+ * status: 'converted'|'passthrough', kind: string }
121
+ * | { ok: false, code: string, message: string }}
122
+ */
123
+ export function convertMsysToNative(value, { translate, env } = {}) {
124
+ // The code rides the message: the registry carries only error.message to
125
+ // the model/bridge, so the rejection class must survive that one face.
126
+ const reject = (code, body) => ({ ok: false, code, message: `${PATH_TOOL_NAME} [${code}]: ${body}` })
127
+ if (typeof value !== 'string' || value.trim().length === 0) {
128
+ return reject('EMPTY', 'msys_path must be a non-empty string')
129
+ }
130
+ if (value.includes(NUL_BYTE)) {
131
+ return reject('NUL_BYTE', 'msys_path contains a NUL byte; a single filesystem path is required')
132
+ }
133
+ const scheme = URL_SCHEME.exec(value)
134
+ if (scheme && scheme[1].length >= 2) {
135
+ return reject('URL', `msys_path looks like a URL (scheme '${scheme[1]}'); explicit conversion accepts a single filesystem path only`)
136
+ }
137
+ // Already native or cwd-relative: an honest, DECLARED passthrough — the
138
+ // description promises this, so no mount facts are consulted at all.
139
+ if (isWindowsNativeValue(value)) {
140
+ return { ok: true, native_path: value, status: 'passthrough', kind: 'windows-native' }
141
+ }
142
+ if (!looksPosixValue(value)) {
143
+ return { ok: true, native_path: value, status: 'passthrough', kind: 'cwd-relative' }
144
+ }
145
+ if (typeof translate !== 'function') {
146
+ return reject('NO_TRANSLATOR', `no shared translator is wired; refusing to guess a native form for ${JSON.stringify(value)}`)
147
+ }
148
+ let translated
149
+ try {
150
+ translated = translate(value, env ?? null)
151
+ } catch (error) {
152
+ return reject('TRANSLATE_FAILED', `the shared translator failed for ${JSON.stringify(value)}: ${error?.message ?? error}`)
153
+ }
154
+ if (typeof translated !== 'string' || translated.length === 0) {
155
+ return reject('TRANSLATE_FAILED', `the shared translator returned a non-string for ${JSON.stringify(value)}`)
156
+ }
157
+ if (translated !== value) {
158
+ return { ok: true, native_path: translated, status: 'converted', kind: 'msys' }
159
+ }
160
+ return reject('NO_MOUNT_MAPPING', `no MSYS mount mapping for ${JSON.stringify(value)} (${factSummary(env)}). Refusing to return the POSIX spelling as a native path — resolve the mount fact or convert the value at its source.`)
161
+ }
162
+
163
+ /** The model-facing contract (kept in one place for both definition shapes). */
164
+ export function pathToolDescription() {
165
+ return 'Explicitly convert one complete caller-declared path value from the MSYS/Git Bash dialect '
166
+ + '(/c/-style drive roots, ~, $HOME-style variables, /tmp, /dev/null, Git installation mounts) '
167
+ + 'to its native Windows form and return { native_path }. Call this ONLY when the full value is '
168
+ + 'already known to be a single filesystem path — the tool never inspects surrounding data and never guesses. '
169
+ + 'Pure string conversion through the plugin\'s shared mount table: no filesystem I/O, no existence check, '
170
+ + 'no permission grant; your runtime policy still governs the converted path. Values that are already '
171
+ + 'Windows-native (drive-letter or UNC) or cwd-relative are returned unchanged, by design. A POSIX-looking '
172
+ + 'value that no mount maps — or whose needed mount fact is unavailable — is REJECTED with an explicit error '
173
+ + 'instead of a misleading passthrough. Independent of the automatic dialect switches; the parameter is named '
174
+ + 'msys_path so the automatic path-field rewrite leaves it untouched. '
175
+ + 'Use it for runtime-built MSYS paths before direct native Node fs/path I/O: '
176
+ + 'await the result and pass result.native_path, never the original MSYS string. '
177
+ + 'Already-native/cwd-relative Node values or ordinary translated tool path fields need no conversion. '
178
+ + 'Never pass routes, search strings, shell commands or program source. '
179
+ + 'In PTC invoke this offered SDK binding from inside run_code. A file policy/access denial is a stop condition, '
180
+ + 'not a reason to convert the spelling and retry through Node or another tool.'
181
+ }
182
+
183
+ const MSYS_PATH_PARAM_DESCRIPTION = 'The complete value to convert, as one single MSYS/Git Bash path '
184
+ + '(drive root /c/, ~, $HOME-style variable, /tmp, /dev/null or a Git install mount). '
185
+ + 'Pass the FULL value only; the tool performs no I/O and no permission grant.'
186
+
187
+ /**
188
+ * Build the tool definition in whichever dialect the host supports: the real
189
+ * `defineTool` author schema when the host module is available (parameters is
190
+ * a property map with `required: true` on the property; the output object
191
+ * declares `additionalProperties: false` explicitly and marks every property
192
+ * required), or an equivalent plain JSON-schema object otherwise. Both shapes
193
+ * share ONE execute body.
194
+ * @param {object} deps
195
+ * @param {object|undefined} [deps.toolsModule] - `@deepseek-ai/dsh-tools` (defineTool).
196
+ * @param {(value: string, env?: object|null) => unknown} [deps.translate] -
197
+ * the shared translator, already composed fresh by the wiring.
198
+ * @param {(value: string, env: object|null) => unknown} [deps.translateMsysPath] -
199
+ * alternative to `translate`: the raw shared helper.
200
+ * @param {() => object|null|undefined} [deps.buildEnv] - fresh mount-facts
201
+ * reader (buildTranslateEnv composition); called once per tool call.
202
+ * @param {(message: string) => void} [deps.log]
203
+ * @returns {object} a registry-ready definition.
204
+ */
205
+ export function buildPathToolDefinition({ toolsModule, translate, translateMsysPath, buildEnv, experimentalEnabled, log = () => {} } = {}) {
206
+ const convertValue = typeof translate === 'function'
207
+ ? (value, env) => translate(value, env)
208
+ : typeof translateMsysPath === 'function'
209
+ ? (value, env) => translateMsysPath(value, env)
210
+ : undefined
211
+ if (!convertValue) {
212
+ throw new Error('path-tool: a shared translator is required — pass translate (fresh composed) or translateMsysPath; the tool never copies the mount table')
213
+ }
214
+ const execute = (args) => {
215
+ if (experimentalEnabled?.() === false) throw new Error('gitbash_to_native_path is disabled by experimentalNative. Enable Experimental native execution / fail-closed in Settings > Plugins > dsh-gitbash-shell.')
216
+ // Fresh facts per call: the same lazy discipline as every other
217
+ // translation consumer (a settings bashPath change must be visible on
218
+ // the next call without a re-mount).
219
+ const env = typeof buildEnv === 'function' ? buildEnv() : undefined
220
+ const outcome = convertMsysToNative(args?.msys_path, { translate: convertValue, env })
221
+ if (!outcome.ok) throw new Error(outcome.message)
222
+ return { native_path: outcome.native_path }
223
+ }
224
+ if (typeof toolsModule?.defineTool === 'function') {
225
+ return toolsModule.defineTool({
226
+ name: PATH_TOOL_NAME,
227
+ description: pathToolDescription(),
228
+ parameters: {
229
+ msys_path: { type: 'string', required: true, description: MSYS_PATH_PARAM_DESCRIPTION },
230
+ },
231
+ output: {
232
+ schema: {
233
+ type: 'object',
234
+ properties: { native_path: { type: 'string', required: true } },
235
+ additionalProperties: false,
236
+ },
237
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
238
+ },
239
+ isConcurrencySafe: true,
240
+ execute,
241
+ })
242
+ }
243
+ log('path-tool: @deepseek-ai/dsh-tools not resolvable; building a plain JSON-schema definition')
244
+ return {
245
+ name: PATH_TOOL_NAME,
246
+ description: pathToolDescription(),
247
+ parameters: {
248
+ type: 'object',
249
+ properties: { msys_path: { type: 'string', description: MSYS_PATH_PARAM_DESCRIPTION } },
250
+ required: ['msys_path'],
251
+ additionalProperties: false,
252
+ },
253
+ output: {
254
+ schema: {
255
+ type: 'object',
256
+ properties: { native_path: { type: 'string' } },
257
+ required: ['native_path'],
258
+ additionalProperties: false,
259
+ },
260
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
261
+ },
262
+ execute,
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Build the cordis module the main wiring mounts inside an agent-owned scope.
268
+ * The tool registration is owned by the mounting scope: it reverts through
269
+ * the scope's effect at disposal, exactly like the bash-tool module's.
270
+ * Windows-only availability, agent gating and any dialect/config gate are the
271
+ * MAIN WIRING's decisions — this module carries none.
272
+ *
273
+ * Deps (final interface):
274
+ * - toolsModule: `@deepseek-ai/dsh-tools` when resolvable (defineTool);
275
+ * absent ⇒ plain JSON-schema definition, same surface.
276
+ * - translate: (value) => native, the shared translateMsysPath
277
+ * composed fresh per call by the wiring, OR
278
+ * - translateMsysPath + buildEnv: the raw shared helper plus a fresh
279
+ * mount-facts reader `() => buildTranslateEnv(...)`;
280
+ * buildEnv is called once per tool call.
281
+ * - log: optional (message) => void.
282
+ *
283
+ * @param {object} [deps]
284
+ * @returns {{ inject: string[], apply: (ctx: object) => void }}
285
+ */
286
+ export function createPathToolModule(deps = {}) {
287
+ const definition = buildPathToolDefinition(deps)
288
+ return {
289
+ inject: ['tools'],
290
+ apply(ctx) {
291
+ if (typeof ctx?.tools?.register !== 'function') {
292
+ throw new Error('path-tool: ctx.tools.register is unavailable; cannot own the tool registration')
293
+ }
294
+ registerExperimentalTool(ctx, () => definition, deps.experimentalEnabled, { scope: deps.scope, log: deps.log })
295
+ },
296
+ }
297
+ }
@@ -0,0 +1,12 @@
1
+ /** Use the host's current directory; retain the old policy/header seam when absent. */
2
+ export async function resolveSessionCwd(ctx, exec, policy) {
3
+ if (exec.agent === undefined) return undefined
4
+ exec.signal?.throwIfAborted()
5
+ const service = typeof ctx.get === 'function' ? ctx.get('workingDirectory') : undefined
6
+ if (service !== undefined) {
7
+ if (typeof service?.ensure !== 'function') throw new TypeError('workingDirectory service does not expose ensure()')
8
+ // Existing service errors must not become a silent downgrade to the original cwd.
9
+ return service.ensure(exec.agent, exec.signal)
10
+ }
11
+ return policy?.workspaceRoot ?? exec.agent.session?.header?.cwd
12
+ }
package/src/shell.js CHANGED
@@ -7,31 +7,28 @@
7
7
  * replacing the shell executor IS overriding one). Instead the plugin
8
8
  * constructs ONE private instance of this class in a PRIVATE cordis scope
9
9
  * (`executorScope.ctx.plugin(GitBashSandboxExecutor)`, src/inline-tools.js)
10
- * and hands it to the plugin's OWN bash tool (src/bash-tool.js). The class
11
- * body is unchanged from its executor-row era:
10
+ * and hands it to the plugin's OWN bash tool (src/bash-tool.js).
12
11
  *
13
12
  * - extends @deepseek-ai/dsh-bash-sandbox to inherit policy, denial and
14
- * lifecycle semantics EXCEPT the explicit Windows unconfined route below;
13
+ * lifecycle semantics; unsupported Windows policy is refused, not weakened;
15
14
  * - overrides `confine` so the inner argv is [git-bash, -c, command];
16
15
  * - overrides run/start ONLY for danger-full-access (the parent's
17
16
  * full-access branch calls LocalBashExecutor.run, which hardcodes the
18
17
  * bare `bash` name), routing through runArgv/startArgv with the same
19
18
  * argv as the confined branch;
20
- * - on Windows ALSO overrides the confined branch itself (issue #1):
21
- * historical MSYS2/restricted-token tests failed during msys-2.0.dll
22
- * cygheap/signal-pipe initialization (Win32 error 5 / 0xC0000142;
23
- * restricting-SID object permissions, CHANGELOG v0.13.2). No working
24
- * confined Git Bash implementation is provided here; this is not a claim
25
- * that every future runtime/isolation design is impossible. Windows calls
26
- * retain the explicit UNCONFINED route, labelled on the handle/result and
27
- * in model-facing foreground/background text, plus a one-time notice.
28
- * Official file-tool write/edit policy remains separate; it does not
29
- * constrain shell code and its denials must never be bypassed through shell.
19
+ * - on Windows refuses read-only/workspace-write or unresolved policy before
20
+ * argv resolution or process start. Historical restricted-token failures
21
+ * (Win32 error 5 / 0xC0000142, CHANGELOG v0.13.2) are not proof that every
22
+ * future isolation design is impossible. The matched modern MSYS signal
23
+ * pipe has an explicit SD; cygheap uses VirtualAlloc, and Core already
24
+ * supplements TokenDefaultDacl. No verified confined Git Bash is provided
25
+ * here. experimentalNative defaults ON (refusal); an explicit user opt-out
26
+ * restores the labelled legacy unconfined route, with truthful facts.
27
+ * Official file-tool policy is separate and its denials must not be bypassed.
30
28
  *
31
- * Environment inheritance: bash.exe is spawned as a direct child of the host
32
- * process (never through the git-bash.exe login launcher), so it inherits the
33
- * full system environment plus the DSH_* snapshot exactly like the pwsh
34
- * executor did.
29
+ * Environment inheritance: bash.exe is a direct child (not a login launcher)
30
+ * of the host's managed, credential-scrubbing subprocess provider. The trusted
31
+ * DSH_* snapshot does not exempt it from protected environment rules.
35
32
  *
36
33
  * Constructed as a cordis class plugin (static inject/Config honoured) —
37
34
  * inside an explicitly `ctx.isolate('shell')`-isolated scope, so the base
@@ -48,9 +45,6 @@ export { DEFAULT_GIT_BASH }
48
45
  import { loadSchemastery } from './schemastery.js'
49
46
 
50
47
 
51
- /** Log prefix, matching src/index.js. */
52
- const TAG = '[gitbash-shell]'
53
-
54
48
  /**
55
49
  * The plugin row's id, which doubles as the settings namespace on <= 0.1.6 and
56
50
  * as the row-config namespace on 0.1.7+ (same string by design).
@@ -212,7 +206,8 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
212
206
  * @returns true when the base class start is an async function.
213
207
  */
214
208
  static baseStartIsAsync() {
215
- return SandboxBashExecutor.prototype.start.constructor.name === 'AsyncFunction'
209
+ return typeof SandboxBashExecutor.prototype.start === 'function'
210
+ && SandboxBashExecutor.prototype.start.constructor.name === 'AsyncFunction'
216
211
  }
217
212
 
218
213
  /**
@@ -232,25 +227,54 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
232
227
  return { result: raw, spawnRequested: true }
233
228
  }
234
229
 
230
+ /** Construct the host's structured refusal when its module was resolved. */
231
+ policyUnavailableError(mode) {
232
+ const knownMode = mode === 'read-only' || mode === 'workspace-write' ? mode : 'unresolved'
233
+ const detail = 'dsh-gitbash-shell does not provide verified Windows Git for Windows confinement. '
234
+ + 'The command was not started; no unconfined fallback is permitted. '
235
+ + 'Do not bypass this refusal with another tool or change permissions automatically.'
236
+ + ' The user can open Settings > Plugins > dsh-gitbash-shell and turn off '
237
+ + '"Experimental native execution / fail-closed" (experimentalNative) to restore the legacy '
238
+ + 'explicitly UNCONFINED Bash behavior, or select danger-full-access in the session access-mode control.'
239
+ if (typeof this.unavailableErrorType === 'function') return new this.unavailableErrorType(knownMode, detail)
240
+ // Direct/older callers without the optional host error export still refuse.
241
+ const error = new Error('sandbox mode "' + knownMode + '" cannot execute Git Bash on this Windows host. ' + detail)
242
+ error.name = 'SandboxUnavailableError'
243
+ error.code = 'SANDBOX_UNAVAILABLE'
244
+ return error
245
+ }
246
+
235
247
  /**
236
- * Decide whether one spec takes the Git Bash argv path, and how to label it
237
- * (pure). Full access always does — the shipped executor hardcodes the bare
238
- * `bash` name there. A Windows confined call does too (issue #1: MSYS2 cannot
239
- * start under the restricted-token runner). Everything else inherits the
240
- * parent's sandbox path, which already runs Git Bash because `confine` below
241
- * substitutes the argv.
242
- * @param spec the shell request.
243
- * @returns `{ mode, unconfined }`, or undefined to inherit the parent path.
248
+ * Full access uses explicit Git Bash argv. Windows restricted or unresolved
249
+ * policy fails BEFORE argv resolution, job admission or spawning; disclosure
250
+ * of an unconfined route is not permission to weaken the requested policy.
251
+ * Non-Windows restricted calls retain the parent's actual sandbox path.
252
+ * @returns `{ mode, unconfined: false }`, or undefined to inherit.
244
253
  */
245
254
  gitBashRoute(spec) {
246
- const policy = spec.sandboxPolicy
247
- const mode = policy === undefined ? undefined : policy.mode
248
- if (mode === undefined) return undefined
255
+ const mode = spec.sandboxPolicy?.mode
249
256
  if (mode === 'danger-full-access') return { mode, unconfined: false }
250
- if (process.platform === 'win32') return { mode, unconfined: true }
257
+ if (process.platform === 'win32') {
258
+ if (this.experimentalEnabled?.() !== false) throw this.policyUnavailableError(mode)
259
+ // Exact legacy opt-out: unresolved policy still delegates to the host.
260
+ if (mode !== undefined) return { mode, unconfined: true }
261
+ }
251
262
  return undefined
252
263
  }
253
264
 
265
+ warnConfinedUnconfined(mode) {
266
+ if (this._warnedUnconfined) return
267
+ this._warnedUnconfined = true
268
+ console.log('dsh-gitbash-shell: experimentalNative is OFF. Requested mode ' + mode
269
+ + ' uses an UNCONFINED Git Bash route: shell file-write restrictions are NOT enforced. '
270
+ + 'Official file-tool policy is separate; never bypass its denial through shell.')
271
+ }
272
+
273
+ routeSandbox(route) {
274
+ if (route.unconfined) this.warnConfinedUnconfined(route.mode)
275
+ return { mode: route.mode, denied: false, ...(route.unconfined ? { enforcement: 'unconfined' } : {}) }
276
+ }
277
+
254
278
  /**
255
279
  * Label one execution handle's foreground result without changing its
256
280
  * identity (the parent's `decorateResult` is private to the shipped class, so
@@ -285,16 +309,13 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
285
309
  * @param spec resolved execution settings and caller-owned command metadata.
286
310
  * @returns the live execution handle.
287
311
  */
288
- execute(spec) {
312
+ async execute(spec) {
289
313
  if (typeof super.execute !== 'function') return this.run(spec)
314
+ const route = this.gitBashRoute(spec)
290
315
  const routed = this.withParityEnv(spec)
291
- const route = this.gitBashRoute(routed)
292
316
  if (route === undefined) return super.execute(routed)
293
317
  const argv = [this.requireBashPath(), '-c', routed.command]
294
- if (route.unconfined) this.warnConfinedUnconfined(route.mode)
295
- const sandbox = route.unconfined
296
- ? { mode: route.mode, denied: false, enforcement: 'unconfined' }
297
- : { mode: route.mode, denied: false }
318
+ const sandbox = this.routeSandbox(route)
298
319
  // `executeArgv` is the shipped helper for exactly this: run an explicit
299
320
  // argv with the executor's own lifecycle, output and deadline semantics.
300
321
  return Promise.resolve(this.executeArgv(routed, argv))
@@ -366,48 +387,29 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
366
387
  * old-era entry point.
367
388
  */
368
389
  async run(spec) {
390
+ const route = this.gitBashRoute(spec)
369
391
  spec = this.withParityEnv(spec)
370
- const policy = spec.sandboxPolicy
371
- if (policy === undefined) return super.run(spec)
372
- const { mode } = policy
373
- if (mode === 'danger-full-access') {
374
- const { result } = GitBashSandboxExecutor.unwrapRunArgv(
375
- await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command]))
376
- return { ...result, sandbox: { mode, denied: false } }
377
- }
378
- if (process.platform === 'win32') {
379
- this.warnConfinedUnconfined(mode)
380
- const { result, spawnRequested } = GitBashSandboxExecutor.unwrapRunArgv(
381
- await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command]))
382
- // No spawn means no argv ran, so there is nothing to label as
383
- // unconfined; the parent reports the same bare sandbox facts there.
384
- return { ...result, sandbox: spawnRequested ? { mode, denied: false, enforcement: 'unconfined' } : { mode, denied: false } }
385
- }
386
- return super.run(spec)
392
+ if (route === undefined) return super.run(spec)
393
+ if (route.unconfined) this.warnConfinedUnconfined(route.mode)
394
+ const { result, spawnRequested } = GitBashSandboxExecutor.unwrapRunArgv(
395
+ await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command]))
396
+ return { ...result, sandbox: this.routeSandbox({ ...route, unconfined: route.unconfined && spawnRequested }) }
387
397
  }
388
398
 
389
399
  start(spec) {
390
- spec = this.withParityEnv(spec)
391
- const policy = spec.sandboxPolicy
392
- if (policy === undefined) return super.start(spec)
393
- const { mode } = policy
394
- const fullAccess = mode === 'danger-full-access'
395
- if (fullAccess || process.platform === 'win32') {
396
- if (!fullAccess) this.warnConfinedUnconfined(mode)
397
- const proc = this.startArgv(spec, [this.requireBashPath(), '-c', spec.command])
398
- proc.sandbox = fullAccess ? { mode, denied: false } : { mode, denied: false, enforcement: 'unconfined' }
399
- // Match the loaded base contract: the async one (dsh >= 0.1.6) is
400
- // awaited by its callers, the synchronous one is consumed directly.
401
- return GitBashSandboxExecutor.baseStartIsAsync() ? Promise.resolve(proc) : proc
400
+ let route
401
+ try { route = this.gitBashRoute(spec) }
402
+ catch (error) {
403
+ if (GitBashSandboxExecutor.baseStartIsAsync()) return Promise.reject(error)
404
+ throw error
402
405
  }
403
- return super.start(spec)
404
- }
405
-
406
- /** One-per-instance notice that confined Windows calls run unconfined. */
407
- warnConfinedUnconfined(mode) {
408
- if (this._warnedUnconfined) return
409
- this._warnedUnconfined = true
410
- console.log(TAG + ' Windows: confined Git Bash is not provided by this restricted-token backend (issue #1). Requested mode ' + mode + ' will use an UNCONFINED Git Bash route: shell file-write restrictions are NOT enforced. Official file-tool write/edit policy is separate; never bypass its denial through shell.')
406
+ spec = this.withParityEnv(spec)
407
+ if (route === undefined) return super.start(spec)
408
+ const proc = this.startArgv(spec, [this.requireBashPath(), '-c', spec.command])
409
+ const label = (handle) => { handle.sandbox = this.routeSandbox(route); return handle }
410
+ // Preserve the old host's synchronous/async start contract, including its
411
+ // refusal shape; modern execute() callers never enter this legacy method.
412
+ return GitBashSandboxExecutor.baseStartIsAsync() ? Promise.resolve(proc).then(label) : label(proc)
411
413
  }
412
414
  }
413
415