dsh-gitbash-shell 0.39.4 → 0.40.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/CHANGELOG.md +154 -1
- package/README.md +69 -33
- package/README_EN.md +38 -44
- package/docs/experimental-native-execution.md +224 -0
- package/docs/experimental-native-verification.md +184 -0
- package/dsh.plugin.json +1 -1
- package/package.json +4 -3
- package/src/bash-tool.js +5 -7
- package/src/client.js +73 -21
- package/src/experimental-tools.js +47 -0
- package/src/index.js +26 -20
- package/src/inline-tools.js +95 -8
- package/src/native-executor.js +283 -0
- package/src/native-output.js +347 -0
- package/src/native-tool.js +195 -0
- package/src/path-tool.js +297 -0
- package/src/session-workdir.js +12 -0
- package/src/shell.js +78 -76
package/src/path-tool.js
ADDED
|
@@ -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).
|
|
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
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* Official file-tool
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
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
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
if (
|
|
372
|
-
const {
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|