dsh-gitbash-shell 0.4.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.
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "dsh-external/dsh-gitbash-shell",
3
+ "version": "0.4.0",
4
+ "main": "./src/index.js",
5
+ "description": "Windows 全模式 Git Bash:用 Git for Windows bash 替换 PowerShell 执行器,并物化 standard/minimal/code/cordis 的 Git Bash 变体 preset。",
6
+ "engines": {
7
+ "dsh": ">=0.0.1"
8
+ },
9
+ "contributes": {
10
+ "tools": [],
11
+ "skills": []
12
+ }
13
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "dsh-gitbash-shell",
3
+ "version": "0.4.0",
4
+ "description": "DSH plugin: run every agent shell command through Git for Windows bash on Windows instead of PowerShell. Replaces the pwsh executor with a Git Bash ctx.shell provider and materializes Git Bash variants of the standard/minimal/code/cordis agent presets into your user preset root at startup.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/KannaKuron/dsh-gitbash-shell.git"
9
+ },
10
+ "main": "src/index.js",
11
+ "exports": {
12
+ ".": "./src/index.js",
13
+ "./shell": "./src/shell.js"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ }
22
+ },
23
+ "files": [
24
+ "src",
25
+ "assets",
26
+ "cordis.patch.yml",
27
+ "dsh.plugin.json",
28
+ "README.md",
29
+ "README_EN.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "test": "node --test tests/smoke.mjs"
34
+ },
35
+ "keywords": [
36
+ "dsh",
37
+ "deepseek-harness",
38
+ "git-bash",
39
+ "windows",
40
+ "shell",
41
+ "dsh-plugin"
42
+ ],
43
+ "license": "MIT",
44
+ "peerDependencies": {
45
+ "@deepseek-ai/cordis": "^4.0.1"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "homepage": "https://github.com/KannaKuron/dsh-gitbash-shell",
51
+ "bugs": {
52
+ "url": "https://github.com/KannaKuron/dsh-gitbash-shell/issues"
53
+ }
54
+ }
package/src/index.js ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * dsh-gitbash-shell — host half (preset materialization).
3
+ *
4
+ * Installed as a profile bundle, this plugin swaps Windows' PowerShell shell
5
+ * for Git for Windows bash (the executor lives in ./shell.js). On startup it
6
+ * also materializes four Git Bash agent presets into the FIRST user-trust
7
+ * preset root, mirroring how dsh-ptc-cordis-preset ships its 'ptc-cordis'
8
+ * preset:
9
+ *
10
+ * standard-gitbash — 标准模式 · Git Bash (tool-bash on, tool-pwsh off)
11
+ * minimal-gitbash — 极简模式 · Git Bash (persistent Git Bash terminal)
12
+ * code-gitbash — PTC 模式 · Git Bash (Code Mode presentation)
13
+ * cordis-gitbash — 创造模式 · Git Bash (self-modifying toolset; skills
14
+ * copied from the INSTALLED shipped 'cordis' preset so
15
+ * the guidance tracks the deployment)
16
+ *
17
+ * Each preset directory carries a .plugin-managed.json marker recording the
18
+ * hash of every file this plugin wrote. Ownership rules (per preset):
19
+ * - absent → materialize;
20
+ * - foreign → written by someone else: never touch;
21
+ * - user-modified → ours but edited: never touch again (delete the
22
+ * directory to re-materialize);
23
+ * - unmodified → refreshed only when the plugin version changed (or,
24
+ * for cordis-gitbash, when the live skills source
25
+ * drifted); otherwise idle.
26
+ *
27
+ * Uninstall hygiene mirrors dsh-ptc-cordis-preset: on disposal, a package
28
+ * directory that still exists means reload/update/restart — keep the
29
+ * presets; a vanished package.json means uninstall — remove each preset the
30
+ * user never modified.
31
+ */
32
+
33
+ import { createHash } from 'node:crypto'
34
+ import {
35
+ cpSync,
36
+ existsSync,
37
+ mkdirSync,
38
+ readFileSync,
39
+ readdirSync,
40
+ rmSync,
41
+ writeFileSync,
42
+ } from 'node:fs'
43
+ import { dirname, join } from 'node:path'
44
+ import { fileURLToPath } from 'node:url'
45
+
46
+ /** Plugin identity for cordis.yml rows. */
47
+ export const name = 'dsh-gitbash-shell'
48
+
49
+ /** The preset roster is a hard dependency: without it there is nothing to do. */
50
+ export const inject = ['agentPresets']
51
+
52
+ const TAG = '[gitbash-shell]'
53
+ const MANAGED_BY = 'dsh-gitbash-shell'
54
+ const MARKER_FILE = '.plugin-managed.json'
55
+
56
+ /** Default preset ids this plugin materializes, in roster order (configurable). */
57
+ export const PRESET_IDS = ['standard-gitbash', 'minimal-gitbash', 'code-gitbash', 'cordis-gitbash']
58
+
59
+ /** Git Bash binary default — must match src/shell.js. */
60
+ const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
61
+
62
+ /** The shipped preset whose skills/ dir seeds cordis-gitbash. */
63
+ const SKILLS_SOURCE_PRESET = 'cordis'
64
+
65
+ const here = dirname(fileURLToPath(import.meta.url))
66
+ const pkgDir = join(here, '..')
67
+
68
+ // ── tree hashing ────────────────────────────────────────────────────────────
69
+
70
+ /** Every file under `root`, as sorted relative POSIX-style paths. */
71
+ function walkFiles(root, rel = '') {
72
+ const out = []
73
+ let entries
74
+ try {
75
+ entries = readdirSync(join(root, rel), { withFileTypes: true })
76
+ } catch {
77
+ return out
78
+ }
79
+ for (const e of entries) {
80
+ const r = rel ? `${rel}/${e.name}` : e.name
81
+ if (e.isDirectory()) out.push(...walkFiles(root, r))
82
+ else if (e.isFile()) out.push(r)
83
+ }
84
+ return out.sort()
85
+ }
86
+
87
+ /** Map of relative path → sha256 for every file under `root`. */
88
+ function hashTree(root) {
89
+ const files = {}
90
+ for (const rel of walkFiles(root)) {
91
+ if (rel === MARKER_FILE) continue
92
+ files[rel] = createHash('sha256').update(readFileSync(join(root, rel))).digest('hex')
93
+ }
94
+ return files
95
+ }
96
+
97
+ /** The parsed marker, or null when the tree is not ours. */
98
+ function readMarker(target) {
99
+ try {
100
+ const m = JSON.parse(readFileSync(join(target, MARKER_FILE), 'utf8'))
101
+ if (m && m.managedBy === MANAGED_BY && m.files && typeof m.files === 'object') return m
102
+ } catch {
103
+ /* absent or unreadable → not ours */
104
+ }
105
+ return null
106
+ }
107
+
108
+ /** Classify a preset directory: absent | foreign | user-modified | unmodified. */
109
+ function classify(target) {
110
+ if (!existsSync(target)) return 'absent'
111
+ const marker = readMarker(target)
112
+ if (!marker) return 'foreign'
113
+ const current = hashTree(target)
114
+ const recorded = marker.files
115
+ const keys = Object.keys(recorded)
116
+ if (keys.length !== Object.keys(current).length) return 'user-modified'
117
+ for (const k of keys) if (current[k] !== recorded[k]) return 'user-modified'
118
+ return 'unmodified'
119
+ }
120
+
121
+ /** 'skills/<rel>' → sha256 map of a skills source tree, or null when absent. */
122
+ function skillsHashes(source) {
123
+ if (!source || !existsSync(source)) return null
124
+ const out = {}
125
+ for (const rel of walkFiles(source)) {
126
+ out[`skills/${rel}`] = createHash('sha256').update(readFileSync(join(source, rel))).digest('hex')
127
+ }
128
+ return out
129
+ }
130
+
131
+ /** Startup decision for an existing unmodified tree: 'refresh' or 'idle'. */
132
+ function syncDecision({ state, marker, version, sourceHashes }) {
133
+ if (state !== 'unmodified' || !marker) return 'refresh'
134
+ if (marker.version !== version) return 'refresh'
135
+ const recorded = {}
136
+ for (const k of Object.keys(marker.files)) if (k.startsWith('skills/')) recorded[k] = marker.files[k]
137
+ if (sourceHashes === null) return Object.keys(recorded).length === 0 ? 'idle' : 'refresh'
138
+ const live = Object.keys(sourceHashes)
139
+ const seen = Object.keys(recorded)
140
+ if (live.length !== seen.length) return 'refresh'
141
+ for (const k of live) if (recorded[k] !== sourceHashes[k]) return 'refresh'
142
+ return 'idle'
143
+ }
144
+
145
+ // ── materialization ─────────────────────────────────────────────────────────
146
+
147
+ /** Write one preset directory from scratch. Returns 'ok' or 'no-skills-source'. */
148
+ function materialize({ target, presetId, skillsSource, version }) {
149
+ rmSync(target, { recursive: true, force: true })
150
+ mkdirSync(target, { recursive: true })
151
+
152
+ writeFileSync(join(target, 'agent.cordis.yml'), readFileSync(join(pkgDir, 'assets', presetId, 'agent.cordis.yml')))
153
+ writeFileSync(join(target, 'preset.yml'), readFileSync(join(pkgDir, 'assets', presetId, 'preset.yml')))
154
+
155
+ let skills = 'none'
156
+ if (presetId === 'cordis-gitbash') {
157
+ if (skillsSource && existsSync(skillsSource)) {
158
+ cpSync(skillsSource, join(target, 'skills'), { recursive: true, force: true, dereference: true })
159
+ skills = 'copied'
160
+ } else {
161
+ mkdirSync(join(target, 'skills'), { recursive: true })
162
+ skills = 'missing-source'
163
+ }
164
+ }
165
+
166
+ const marker = { managedBy: MANAGED_BY, version, presetId, files: hashTree(target) }
167
+ writeFileSync(join(target, MARKER_FILE), JSON.stringify(marker, null, 2) + '\n')
168
+ return skills
169
+ }
170
+
171
+ /** Disposal-time cleanup for one preset. */
172
+ function cleanupOnDispose({ target, packageJsonExists }) {
173
+ if (packageJsonExists) return 'kept-package-intact'
174
+ const state = classify(target)
175
+ if (state === 'unmodified') {
176
+ rmSync(target, { recursive: true, force: true })
177
+ return 'removed'
178
+ }
179
+ return `kept-${state}`
180
+ }
181
+
182
+ // ── inspect-registry compatibility shim ─────────────────────────────────────
183
+ //
184
+ // The host-plane runner's inspect registry is a process-global singleton and
185
+ // its `register` THROWS on a duplicate provider id. `tool-cordis` registers
186
+ // the same provider ids from every preset that mounts it, so a process
187
+ // hosting both the built-in Creation mode and any cordis-derived preset
188
+ // (cordis-gitbash here, ptc-cordis from dsh-ptc-cordis-preset) fails the
189
+ // SECOND mount. Two registrants of the SAME package produce identical
190
+ // manifests, so replacing the stored entry instead of throwing is a no-op for
191
+ // consumers, and the identity-guarded disposer keeps teardown consistent.
192
+ // Installed once at boot by this always-mounted host row, so every later
193
+ // cordis toolset mount coexists.
194
+
195
+ const SHIM_FLAG = '__gitbashShellRegisterShim'
196
+
197
+ /** Wrap one inspect registry's `register` to tolerate duplicate registrations. */
198
+ export function installRegisterShim(reg) {
199
+ const restore = () => {}
200
+ if (!reg || typeof reg.register !== 'function' || !(reg.providers instanceof Map)) {
201
+ return { installed: false, restore }
202
+ }
203
+ if (reg.register[SHIM_FLAG] === true) return { installed: false, restore }
204
+ const original = reg.register
205
+ try {
206
+ const wrapped = function register(registration) {
207
+ try {
208
+ return original.call(this, registration)
209
+ } catch (error) {
210
+ const message = error && error.message ? error.message : String(error)
211
+ if (!message.includes('is already registered')) throw error
212
+ const manifest = registration && registration.manifest
213
+ if (!manifest || typeof manifest.id !== 'string') throw error
214
+ const stored = { ...registration, manifest }
215
+ this.providers.set(manifest.id, stored)
216
+ const self = this
217
+ return () => {
218
+ if (self.providers.get(manifest.id) === stored) self.providers.delete(manifest.id)
219
+ }
220
+ }
221
+ }
222
+ try { Object.defineProperty(wrapped, SHIM_FLAG, { value: true }) } catch { /* cosmetic */ }
223
+ reg.register = wrapped
224
+ return {
225
+ installed: true,
226
+ restore: () => {
227
+ try { if (reg.register === wrapped) reg.register = original } catch { /* never block */ }
228
+ },
229
+ }
230
+ } catch {
231
+ return { installed: false, restore }
232
+ }
233
+ }
234
+
235
+ // ── plugin ──────────────────────────────────────────────────────────────────
236
+
237
+ /** First user-trust root: the roster's authoring target. */
238
+ function firstUserRoot(roots) {
239
+ for (const r of roots) if (r && r.trust === 'user' && typeof r.path === 'string') return r
240
+ return undefined
241
+ }
242
+
243
+ /** Directory of skills inside the installed shipped `cordis` preset, if any. */
244
+ async function findSkillsSource(agentPresets) {
245
+ try {
246
+ const list = await agentPresets.list()
247
+ const cordis = Array.isArray(list) ? list.find((p) => p && p.id === SKILLS_SOURCE_PRESET && typeof p.path === 'string') : undefined
248
+ if (!cordis) return undefined
249
+ return join(dirname(cordis.path), 'skills')
250
+ } catch {
251
+ return undefined
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Purge preset directories this plugin materialized in earlier versions but
257
+ * that are no longer in the configured materialization set. Only an
258
+ * UNMODIFIED tree is removed; a user-edited one stays (and is left alone).
259
+ * @param userRoot - the user-trust preset root path.
260
+ * @param keep - preset ids to keep.
261
+ */
262
+ function purgeOrphans(userRoot, keep) {
263
+ try {
264
+ for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
265
+ if (!entry.isDirectory()) continue
266
+ const id = entry.name
267
+ if (keep.includes(id)) continue
268
+ const target = join(userRoot, id)
269
+ const marker = readMarker(target)
270
+ if (!marker || marker.managedBy !== MANAGED_BY) continue
271
+ if (classify(target) === 'unmodified') {
272
+ rmSync(target, { recursive: true, force: true })
273
+ console.log(`${TAG} removed orphan preset '${id}' (no longer materialized)`)
274
+ } else {
275
+ console.log(`${TAG} orphan preset '${id}' was modified after materialization — leaving it alone`)
276
+ }
277
+ }
278
+ } catch (error) {
279
+ console.log(`${TAG} orphan cleanup skipped: ${error?.message ?? error}`)
280
+ }
281
+ }
282
+
283
+ export async function apply(ctx, config = {}) {
284
+ const presetIds = Array.isArray(config.presets) && config.presets.length > 0 ? config.presets : PRESET_IDS
285
+
286
+ // The shim rides along every mount of this plugin and degrades silently if
287
+ // the upstream shape differs from what we verified.
288
+ let shim = { installed: false, restore: () => {} }
289
+ try {
290
+ shim = installRegisterShim(ctx.get('cordisInspect'))
291
+ } catch {
292
+ /* never block startup */
293
+ }
294
+ if (shim.installed) {
295
+ ctx.effect(() => () => shim.restore(), 'dsh-gitbash-shell: inspect-registry shim')
296
+ console.log(`${TAG} inspect-registry compatibility shim active (multiple cordis-mode sessions supported)`)
297
+ }
298
+
299
+ // ── cooperation capability ────────────────────────────────────────────────
300
+ // Publish whether the Git Bash shell stack is active on this host so peer
301
+ // plugins (e.g. dsh-ptc-cordis-preset) can adopt it in the presets they
302
+ // materialize: service present, 'active: true' on Windows, 'active: false'
303
+ // where the bundle is installed but the platform stack stayed native.
304
+ const gitBashCapability = { active: process.platform === 'win32', bashPath: DEFAULT_GIT_BASH }
305
+ const disposeGitBash = ctx.provide('gitBash', gitBashCapability)
306
+ ctx.effect(() => disposeGitBash, 'dsh-gitbash-shell: gitBash capability')
307
+
308
+ const roots = ctx.agentPresets?.roots ?? []
309
+ const userRoot = firstUserRoot(roots)
310
+ if (!userRoot) {
311
+ console.log(`${TAG} no user-trust preset root configured — nothing to materialize`)
312
+ return
313
+ }
314
+
315
+ let version = '0.0.0'
316
+ try {
317
+ version = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')).version ?? version
318
+ } catch {
319
+ /* fall back to the placeholder */
320
+ }
321
+
322
+ const skillsSource = await findSkillsSource(ctx.agentPresets)
323
+ const userRootPath = userRoot.path
324
+ purgeOrphans(userRootPath, presetIds)
325
+
326
+ for (const presetId of presetIds) {
327
+ const target = join(userRootPath, presetId)
328
+ const state = classify(target)
329
+ if (state === 'foreign') {
330
+ console.log(`${TAG} a preset not written by this plugin already exists at ${target} — leaving it alone`)
331
+ continue
332
+ }
333
+ if (state === 'user-modified') {
334
+ console.log(`${TAG} preset at ${target} was modified after materialization — keeping the user's version (delete the directory to re-materialize)`)
335
+ continue
336
+ }
337
+
338
+ // Reversible side effect, registered BEFORE the idle check so a quiet
339
+ // startup keeps uninstall hygiene.
340
+ ctx.effect(() => () => {
341
+ try {
342
+ const result = cleanupOnDispose({ target, packageJsonExists: existsSync(join(pkgDir, 'package.json')) })
343
+ if (result === 'removed') console.log(`${TAG} package uninstalled — removed the unmodified '${presetId}' preset`)
344
+ else if (result !== 'kept-package-intact' && result !== 'kept-absent') console.log(`${TAG} preset ${result} on disposal — kept`)
345
+ } catch (error) {
346
+ console.log(`${TAG} cleanup skipped: ${error?.message ?? error}`)
347
+ }
348
+ }, `dsh-gitbash-shell: preset materialization (${presetId})`)
349
+
350
+ const sourceHashes = presetId === 'cordis-gitbash' ? skillsHashes(skillsSource) : null
351
+ const marker = readMarker(target)
352
+ if (state === 'unmodified' && syncDecision({ state, marker, version, sourceHashes }) === 'idle') {
353
+ ctx.logger?.('gitbash-shell')?.debug?.( `preset '${presetId}' up to date (v${version}) — idle`)
354
+ continue
355
+ }
356
+
357
+ const skills = materialize({ target, presetId, skillsSource, version })
358
+ const verb = state === 'absent' ? 'materialized' : 'refreshed'
359
+ console.log(
360
+ `${TAG} ${verb} preset '${presetId}' into ${userRootPath} (v${version})` +
361
+ (skills === 'copied' ? " (skills copied from the installed 'cordis' preset)" : '')
362
+ )
363
+ }
364
+ }
365
+
366
+ // Test surface: pure helpers, no Cordis context required.
367
+ export const _internal = { PRESET_IDS, MARKER_FILE, classify, materialize, cleanupOnDispose, firstUserRoot, hashTree, skillsHashes, syncDecision, installRegisterShim }
package/src/shell.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * dsh-gitbash-shell/shell — the Git Bash ctx.shell executor.
3
+ *
4
+ * The shipped Windows host composes `dsh-pwsh-sandbox` as ctx.shell and the
5
+ * platform gates (`!!js process.platform === 'win32'`) disable the bash
6
+ * stack on Windows, because a bare `bash` on Windows resolves to the WSL
7
+ * shim in System32 — or to nothing at all when Git's bin directory is not on
8
+ * PATH. This plugin restores the bash stack on Windows while pointing it at
9
+ * Git for Windows' real bash.exe:
10
+ *
11
+ * - extends @deepseek-ai/dsh-bash-sandbox so every confined-mode
12
+ * (read-only / workspace-write) call keeps the exact sandbox policy,
13
+ * denial, and runner-failure semantics of the shipped stack;
14
+ * - overrides `confine` so the inner argv is [git-bash, -c, command];
15
+ * - overrides run/start ONLY for danger-full-access (the parent's
16
+ * full-access branch calls LocalBashExecutor.run, which hardcodes the
17
+ * bare `bash` name), routing through runArgv/startArgv with the same
18
+ * argv as the confined branch.
19
+ *
20
+ * Environment inheritance: bash.exe is spawned as a direct child of the host
21
+ * process (never through the git-bash.exe login launcher), so it inherits the
22
+ * full system environment plus the DSH_* snapshot exactly like the pwsh
23
+ * executor did.
24
+ *
25
+ * Loaded as a class plugin: Cordis constructs it and the ShellExecutor base
26
+ * registers `ctx.shell` (one implementation per context).
27
+ */
28
+
29
+ import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
30
+ import z from '@deepseek-ai/schemastery'
31
+
32
+ /** Default Git for Windows bash (forward slashes work on Windows too). */
33
+ export const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
34
+
35
+ /** Resolved configuration: the local executor's knobs, plus the Git Bash path. */
36
+ export const Config = z.object({
37
+ cwd: z.string(),
38
+ timeoutMs: z.number().default(120000),
39
+ maxTimeoutMs: z.number().default(600000),
40
+ maxOutputBytes: z.number().default(64000),
41
+ maxSpillBytes: z.number().default(64 * 1024 * 1024),
42
+ graceMs: z.number().default(3000),
43
+ bashPath: z.string().default(DEFAULT_GIT_BASH),
44
+ })
45
+
46
+ /** Git Bash executor — mirrors the shipped bash/pwsh sandbox executors. */
47
+ export class GitBashSandboxExecutor extends SandboxBashExecutor {
48
+ static inject = ['subprocess', 'sandbox', 'sandboxPolicy']
49
+ static Config = Config
50
+
51
+ /** Effective Git Bash path: composition/config value with the default fallback. */
52
+ get bashPath() {
53
+ return this.config.bashPath ?? DEFAULT_GIT_BASH
54
+ }
55
+
56
+ /**
57
+ * Wrap one shell command via the ctx.sandbox provider, substituting Git Bash
58
+ * for the shipped bare `bash` argv.
59
+ * @param command - shell source for the confined inner `bash -c`.
60
+ * @param policy - resolved confined execution policy.
61
+ * @returns the provider's exact argv and settlement-classification facts.
62
+ */
63
+ confine(command, policy) {
64
+ return this.ctx.sandbox.confine([this.bashPath, '-c', command], policy)
65
+ }
66
+
67
+ /**
68
+ * Full-access path with Git Bash argv: the parent's full-access branch
69
+ * delegates to LocalBashExecutor.run, which hardcodes the bare `bash`
70
+ * name, so override that branch here and keep everything else inherited.
71
+ */
72
+ async run(spec) {
73
+ const policy = spec.sandboxPolicy
74
+ if (policy === undefined) return super.run(spec)
75
+ const { mode } = policy
76
+ if (mode === 'danger-full-access') {
77
+ const result = await this.runArgv(spec, [this.bashPath, '-c', spec.command])
78
+ return { ...result, sandbox: { mode, denied: false } }
79
+ }
80
+ return super.run(spec)
81
+ }
82
+
83
+ start(spec) {
84
+ const policy = spec.sandboxPolicy
85
+ if (policy === undefined) return super.start(spec)
86
+ const { mode } = policy
87
+ if (mode === 'danger-full-access') {
88
+ const proc = this.startArgv(spec, [this.bashPath, '-c', spec.command])
89
+ proc.sandbox = { mode, denied: false }
90
+ return proc
91
+ }
92
+ return super.start(spec)
93
+ }
94
+ }
95
+
96
+ export default GitBashSandboxExecutor