dsh-multi-folder 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -56,6 +56,17 @@ Equivalent slash command for the user:
56
56
 
57
57
  The agent needs nothing extra: `read` / `glob` / `grep` work everywhere, and `write` / `edit` / `pwsh` / `bash` are intercepted and re-rooted automatically when the target path (or `workdir`) falls inside a configured secondary directory.
58
58
 
59
+ ## Permission model
60
+
61
+ Each confined command runs under **exactly ONE writable root** — the workspace root the call is re-rooted to (the Windows ACL runner grants a single workspace write SID per process tree). Consequences:
62
+
63
+ - A command whose cwd stays the **primary workspace cannot create files inside a secondary directory**. `git -C <secondary> commit`, `cd <secondary>` inside a script, `git clone <url> <secondary>`, or absolute-path writes all fail with an OS-level `Permission denied` (e.g. `fatal: Unable to create '.../.git/index.lock': Permission denied`).
64
+ - Symmetrically, a command re-rooted to a secondary directory cannot write to the **primary workspace** (or another secondary directory) in the same invocation.
65
+ - **Rule for file-creating commands: set `workdir` to the directory the command writes into.** For git, run the command from inside the repository (pass `workdir` pointing at it) instead of using `git -C` from the primary workspace.
66
+ - Reads are unrestricted and need no `workdir`.
67
+
68
+ When a shell run ends in such a denial and references a configured secondary directory, the plugin attaches a short diagnostic hint to the tool result explaining the workdir fix.
69
+
59
70
  ## How it works
60
71
 
61
72
  - **Interception** — a listener on the `tools/execute` around-dispatch waterfall short-circuits `write` / `edit` / `pwsh` / `bash` calls whose resolved path (or `workdir`) lands inside a configured secondary directory, and executes them with the session's standing sandbox policy **re-rooted to that directory** (`{ ...standingPolicy, workspaceRoot: secondaryDir }`). The mode itself is untouched, which is what gives every sandbox mode its identical primary-workspace semantics for free. Paths are canonicalized through `fs.resolve` + `processPath` before matching, so `..`, symlinks, and case differences behave correctly.
package/README.zh.md CHANGED
@@ -56,6 +56,17 @@ dsh plugin --profile web add dsh-multi-folder
56
56
 
57
57
  Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write` / `edit` / `pwsh` / `bash` 在路径(或 `workdir`)落入副目录时自动拦截并以该目录为沙箱根执行。
58
58
 
59
+ ## 权限模型
60
+
61
+ 每条受沙箱约束的命令只拥有**唯一一个可写根**——即本次调用被换根到的那个目录(Windows ACL runner 为每个进程树只授予一个工作区写 SID)。由此:
62
+
63
+ - cwd 停留在**主工作区**的命令**不能在副目录创建文件**。`git -C <副目录> commit`、脚本内 `cd <副目录>`、`git clone <url> <副目录>`、按绝对路径写文件等都会以操作系统级 `Permission denied` 失败(例如 `fatal: Unable to create '.../.git/index.lock': Permission denied`)。
64
+ - 对称地,被换根到副目录的命令在同一次调用中也**不能写主工作区**(或另一个副目录)。
65
+ - **创建文件的命令必须把 `workdir` 设为它要写入的目录。** 对 git 而言,请进入仓库目录执行(`workdir` 指向该仓库),而不是从主工作区用 `git -C`。
66
+ - 读操作不受限制,无需 `workdir`。
67
+
68
+ 当 shell 命令以这类拒绝失败且命令引用了已配置的副目录时,插件会在工具结果后附带一条简短的诊断提示,说明 workdir 的修正方式。
69
+
59
70
  ## 工作原理
60
71
 
61
72
  - **拦截**——监听 `tools/execute` 环绕分派瀑布,对解析路径(或 `workdir`)落在副目录内的 `write` / `edit` / `pwsh` / `bash` 调用短路,并以**换根后的会话站立策略**(`{ ...standingPolicy, workspaceRoot: secondaryDir }`)执行。模式本身不变,因此各种沙箱模式与主工作区的语义天然一致。匹配前先经 `fs.resolve` + `processPath` 规范化,`..`、符号链接与大小写差异均正确处理。
package/docs/design.md CHANGED
@@ -206,6 +206,23 @@ window.__ModuleLoader__.load({
206
206
 
207
207
  ## Known limitations
208
208
 
209
+ - Each confined command runs under exactly ONE writable root: the Windows ACL
210
+ runner grants a single workspace write SID per process tree (`--write-sid`
211
+ must match `--workspace`), and re-rooting replaces the root. A command whose
212
+ cwd stays the primary workspace therefore cannot create files inside a
213
+ secondary directory — `git -C <secondary> commit`, `cd <secondary>` inside a
214
+ script, `git clone <url> <secondary>`, and absolute-path writes fail with an
215
+ OS-level `Permission denied` (`fatal: Unable to create '.../.git/index.lock':
216
+ Permission denied`) that carries no sandbox marker. Symmetrically, a command
217
+ re-rooted to a secondary directory cannot write the primary workspace in the
218
+ same invocation. The injected prompt states the workdir rule, and a
219
+ `tools/post-execute` heuristic attaches a workdir-fix hint when a failed
220
+ `pwsh`/`bash` run both mentions a configured secondary directory and ends in
221
+ a denial (`permission denied` / `access … denied` / `is denied` / `eacces`;
222
+ the plugin's own `[sandbox: …]` marker lines are excluded from the scan).
223
+ Lifting this to real multi-root confinement needs an upstream change
224
+ (`SandboxExecutionPolicy` carrying extra write roots and the ACL runner
225
+ accepting several workspace write SIDs).
209
226
  - Intercepted secondary-directory writes bypass the fs observation policy: they emit
210
227
  no `fs/observed` event and do not participate in the `fs/write-intent` intent guard.
211
228
  This is deliberate — secondary directories sit outside the primary workspace's
package/lib/index.js CHANGED
@@ -39,6 +39,12 @@
39
39
  * directly. The `/multi-folder` command and the remote methods share
40
40
  * one core so validation, canonicalization, and the config guard are
41
41
  * identical on both channels.
42
+ * 7. Failure diagnosis: a `pwsh`/`bash` run that ends in an OS-level
43
+ * `Permission denied` touching a secondary working directory (the ACL
44
+ * runner confines each process tree to ONE writable root, so `git -C
45
+ * <secondary>` launched from the primary workspace cannot write the
46
+ * repo) gets a workdir-fix hint attached as an additional context at
47
+ * the `tools/post-execute` boundary.
42
48
  */
43
49
 
44
50
  import { join } from 'node:path'
@@ -321,6 +327,100 @@ export function apply(ctx) {
321
327
  return text
322
328
  }
323
329
 
330
+ // -------------------------------------------- failure diagnosis (post-exec)
331
+ // The Windows ACL runner confines each process tree to exactly ONE writable
332
+ // workspace root (a single `--write-sid` that must match `--workspace`). A
333
+ // command that creates files inside a secondary directory while its cwd is
334
+ // confined elsewhere therefore fails with an OS-level `Permission denied`
335
+ // (git: `fatal: Unable to create '.../.git/index.lock': Permission denied`)
336
+ // and carries NO sandbox marker — the sandbox worked as designed. These
337
+ // helpers surface the workdir fix at the next tool-call boundary instead.
338
+
339
+ const DENIAL_MARK = /permission denied|access(?: is)? denied|eacces|is denied/i
340
+
341
+ const flattenResultText = (result) => {
342
+ const parts = []
343
+ if (result && Array.isArray(result.content)) {
344
+ for (const block of result.content) {
345
+ if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
346
+ }
347
+ }
348
+ const value = result && result.value
349
+ if (value && typeof value === 'object') {
350
+ for (const stream of [value.stdout, value.stderr]) {
351
+ if (stream && typeof stream.text === 'string') parts.push(stream.text)
352
+ }
353
+ }
354
+ // The plugin's own `[sandbox: ...]` markers describe runner-level denials,
355
+ // not path-level EACCES; keep them out of the scan.
356
+ return parts
357
+ .join('\n')
358
+ .split('\n')
359
+ .filter((line) => !line.startsWith('[sandbox:'))
360
+ .join('\n')
361
+ }
362
+
363
+ /** The configured secondary dir a call's workdir lands in, or null. */
364
+ const workdirHitFor = async (exec) => {
365
+ if (!exec || !exec.agent || !exec.agent.session || !exec.agent.session.header) return null
366
+ const primary = exec.agent.session.header.cwd
367
+ if (typeof primary !== 'string' || primary.length === 0) return null
368
+ const dirs = dirsForSync(primary)
369
+ if (dirs === null) return null
370
+ const args = exec.arguments || {}
371
+ const rawWorkdir = typeof args.workdir === 'string' ? args.workdir : null
372
+ const joined =
373
+ rawWorkdir === null
374
+ ? String(primary)
375
+ : isAbsolute(rawWorkdir)
376
+ ? rawWorkdir
377
+ : String(primary).replace(/[\\/]+$/, '') + '/' + rawWorkdir
378
+ const target = await fs.resolve(joined, { cwd: primary })
379
+ const abs = fs.processPath(target)
380
+ return longestRootFirst(dirs).find((d) => pathInside(abs, d)) ?? null
381
+ }
382
+
383
+ /**
384
+ * A user-visible diagnostic for a shell run that ended in an OS-level
385
+ * permission denial touching a secondary working directory. Returns the
386
+ * hint text or undefined. Never throws — a hint failure must never touch
387
+ * the tool pipeline.
388
+ */
389
+ const permissionHint = async (exec, result) => {
390
+ try {
391
+ if (!exec || (exec.name !== 'pwsh' && exec.name !== 'bash')) return undefined
392
+ if (!exec.agent || !exec.agent.session || !exec.agent.session.header) return undefined
393
+ const exitCode =
394
+ result && result.value && typeof result.value.exitCode === 'number' ? result.value.exitCode : null
395
+ if (exitCode !== null && exitCode === 0) return undefined
396
+ if (!DENIAL_MARK.test(flattenResultText(result))) return undefined
397
+ const primary = exec.agent.session.header.cwd
398
+ if (typeof primary !== 'string' || primary.length === 0) return undefined
399
+ const dirs = dirsForSync(primary)
400
+ if (dirs === null) return undefined
401
+ const reRooted = await workdirHitFor(exec)
402
+ if (reRooted !== null) {
403
+ return (
404
+ 'This command ran confined to the secondary working directory "' +
405
+ reRooted +
406
+ '", so writes OUTSIDE that directory (for example to the primary workspace or another secondary directory) were denied at the OS level. ' +
407
+ "Split the work into per-directory commands and set each command's `workdir` to the directory it writes into."
408
+ )
409
+ }
410
+ const cmdNorm = wsKey(String((exec.arguments || {}).command || ''))
411
+ const referenced = longestRootFirst(dirs).find((d) => cmdNorm.includes(wsKey(d)))
412
+ if (referenced === undefined) return undefined
413
+ return (
414
+ 'This command ran with its cwd confined to the primary workspace, so creating files inside the secondary working directory "' +
415
+ referenced +
416
+ '" was denied at the OS level — each command can write inside only ONE root. ' +
417
+ 'Re-run it with `workdir` set to that directory; for git, run the command from inside the repository instead of using `git -C` from the primary workspace.'
418
+ )
419
+ } catch {
420
+ return undefined
421
+ }
422
+ }
423
+
324
424
  // ------------------------------------------------------ prompt injection
325
425
  systemPrompt.section({
326
426
  name: SECTION_NAME,
@@ -336,8 +436,11 @@ export function apply(ctx) {
336
436
  return (
337
437
  'Secondary working directories are available in this session (dsh-multi-folder plugin):\n' +
338
438
  dirs.map((d) => '- ' + d).join('\n') +
339
- '\nYou have the SAME read/write/edit and command-execution permissions on these directories as on the primary workspace under the current sandbox mode. ' +
439
+ '\nYou have the SAME read/write/edit and command-execution permissions on these directories as on the primary workspace under the current sandbox mode, ' +
440
+ 'but each command can write inside only ONE root — the directory its workdir resolves to. ' +
441
+ 'A command whose cwd stays the primary workspace CANNOT create files inside a secondary directory. ' +
340
442
  'For shell tools, pass `workdir` pointing inside one of these directories — foreground and background (`run_in_background`) runs alike. ' +
443
+ 'File-creating commands, git included, MUST set `workdir` to the secondary directory: do not run `git -C <secondary>` or `cd <secondary>` inside a command launched from the primary workspace. ' +
341
444
  'Reads from these directories work without `workdir`. The primary workspace remains the default working directory.'
342
445
  )
343
446
  },
@@ -358,17 +461,21 @@ export function apply(ctx) {
358
461
  // --------------------------------------- notification (tool-call boundary)
359
462
  ctx.on('tools/post-execute', async (exec, result, next) => {
360
463
  const decision = await next()
361
- const text = takeNotice(exec.agent)
362
- if (text === undefined) return decision
363
- const msg = noticeMessage(text)
464
+ const extras = []
465
+ const notice = takeNotice(exec.agent)
466
+ if (notice !== undefined) extras.push(notice)
467
+ const hint = await permissionHint(exec, result)
468
+ if (hint !== undefined) extras.push(hint)
469
+ if (extras.length === 0) return decision
470
+ const msgs = extras.map((text) => noticeMessage(text))
364
471
  if (decision.kind === 'block') {
365
472
  return {
366
473
  kind: 'block',
367
474
  feedback: decision.feedback,
368
- additionalContexts: [msg, ...(decision.additionalContexts || [])],
475
+ additionalContexts: [...msgs, ...(decision.additionalContexts || [])],
369
476
  }
370
477
  }
371
- return { ...decision, additionalContexts: [msg, ...(decision.additionalContexts || [])] }
478
+ return { ...decision, additionalContexts: [...msgs, ...(decision.additionalContexts || [])] }
372
479
  })
373
480
 
374
481
  // ------------------------------------------------- tool-pipeline intercept
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-folder",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "DeepSeek Harness plugin: secondary working directories for a project. The agent keeps the primary workspace as cwd, gains equal write/exec permissions on configured secondary directories under workspace-write mode, and is notified of configuration changes at the next message boundary. Configurable from the session header AND from the session-creation page (before the first message) through a sessionless multiFolder remote API.",
5
5
  "keywords": [
6
6
  "dsh-plugin",