dsh-multi-folder 0.2.1 → 0.2.3

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/docs/design.md CHANGED
@@ -46,9 +46,13 @@ A listener on the `tools/execute` around-dispatch waterfall handles `write`, `ed
46
46
  default pipeline, which raises the canonical abort error.
47
47
  The result carries the same canonical value/content shapes as the shipped tools, so
48
48
  downstream presentation keeps working.
49
- 5. Anything else — unknown tools, paths outside every secondary directory, escalation
50
- arguments (`sandbox_permissions`), missing optional services (`shell`, `jobs`),
51
- or any error — falls through to `next()` and the default pipeline.
49
+ 5. Anything else — unknown tools, paths outside every secondary directory, missing
50
+ optional services (`shell`, `jobs`), or a failure **before** the target is
51
+ resolved into a secondary directory (path resolution, config lookup, service
52
+ lookup) — falls through to `next()` and the default pipeline. An explicit
53
+ escalation request (`sandbox_permissions` carrying a non-empty mode string) also
54
+ belongs to the default pipeline, which owns the approval flow; a `null`/empty
55
+ value is not a request and is intercepted normally.
52
56
 
53
57
  **Why mode parity is free:** the mode field of the standing policy is never touched.
54
58
  The DSH sandbox backends treat the per-call policy as fully specified and fence by its
@@ -77,7 +81,17 @@ at apply time can yield `undefined` when the provider row activates later. There
77
81
  handler and the `multiFolder/*` remote endpoints) with an explicit
78
82
  `workspace-write` policy rooted at the config directory.
79
83
  - A per-process cache keyed by normalized workspace path hydrates lazily (on
80
- `agent/created`, `agent/pre-step`, and `tools/execute`).
84
+ `agent/created`, `agent/pre-step`, and `tools/execute`). On the interception
85
+ path hydration is **awaited**, not fire-and-forget: a first call that landed
86
+ before the config read resolved would otherwise see an empty cache, fall
87
+ through to the default pipeline, and be fenced against the PRIMARY workspace
88
+ root — a spurious `[sandbox: file access denied under workspace-write mode]`
89
+ for a secondary-directory mutation. `loadDirs` caches, so only the first call
90
+ pays the read. Because `sandbox-policy` realpath-canonicalizes the policy
91
+ workspace root while hydration is keyed by the session cwd **as spelled in
92
+ the header**, a workspace reached through a symlinked/junctioned ancestor can
93
+ spell the two differently; the interception consults BOTH keys (and the
94
+ config guard checks both config-path spellings) before falling through.
81
95
  - One shared **core** (`coreList` / `coreAdd` / `coreRemove` / `coreSet`)
82
96
  implements validation, canonicalization, sanitization, cache write-through,
83
97
  and persistence. The command channel and the remote channel both call it, so
@@ -259,10 +273,12 @@ window.__ModuleLoader__.load({
259
273
  Lifting this to real multi-root confinement needs an upstream change
260
274
  (`SandboxExecutionPolicy` carrying extra write roots and the ACL runner
261
275
  accepting several workspace write SIDs).
262
- - Intercepted secondary-directory writes bypass the fs observation policy: they emit
263
- no `fs/observed` event and do not participate in the `fs/write-intent` intent guard.
264
- This is deliberate — secondary directories sit outside the primary workspace's
265
- observation domain.
276
+ - Intercepted secondary-directory mutations do not participate in the
277
+ `fs/write-intent` / `fs/edit-intent` intent guards (the interception calls
278
+ the backend unconditionally, as a full replacement of the tool body), but
279
+ they DO emit `fs/observed` with a presence observation after success, exactly
280
+ like the shipped tools — so the observation layer stays coherent with the
281
+ file content a re-rooted write/edit produced.
266
282
  - `presentationMeta` is not computed on the short-circuit path; tool cards fall back to
267
283
  their default presentation.
268
284
  - `sandbox_permissions` escalation on `pwsh`/`bash` calls in secondary directories is
package/lib/index.js CHANGED
@@ -16,6 +16,19 @@
16
16
  * here with the session's standing sandbox policy re-rooted to that
17
17
  * directory — identical semantics to the primary workspace in every mode
18
18
  * (read-only denies, workspace-write allows, danger-full-access allows).
19
+ * Interception OWNS such a call from the moment its target is resolved
20
+ * inside a secondary directory: an interception failure is reported as the
21
+ * call's own error and NEVER falls through to the default pipeline, which
22
+ * fences every call against the PRIMARY workspace root and would therefore
23
+ * turn any ordinary failure (a missing `old_string`, a locked target, an
24
+ * unreadable file) into the spurious
25
+ * `[sandbox: file access denied under workspace-write mode]` marker.
26
+ * Interception hydrates the configuration AWAITED (never a fire-and-forget
27
+ * read) and looks the dirs up by BOTH the policy root and the header cwd
28
+ * spelling, so a cold first call and a symlinked workspace cannot fall
29
+ * through to the default pipeline and surface a spurious workspace-write
30
+ * denial for a secondary-directory mutation. Successful write/edit
31
+ * short-circuits emit `fs/observed` like the shipped tools.
19
32
  * Background shell runs (`run_in_background: true`) register with the
20
33
  * generic jobs runtime (`ctx.jobs`) under the same re-rooted policy,
21
34
  * mirroring the shipped pwsh/bash tools so `job_output` / `job_kill` and
@@ -538,43 +551,116 @@ export function apply(ctx) {
538
551
  return read.delta + (read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : '') + notices.join('\n')
539
552
  }
540
553
 
554
+ /**
555
+ * Model-facing failure for a call the interception OWNS (see the ownership
556
+ * rule below). A claimed call is never handed back to the default pipeline,
557
+ * so its real error reaches the model in the shipped tools' error envelope —
558
+ * `Error: <message>` plus the `FS_*` code — instead of the primary-rooted
559
+ * sandbox denial. A genuine sandbox denial (read-only mode) keeps the standard
560
+ * marker plus the one-shot escalation hint, exactly as the shipped
561
+ * `write`/`edit` tools render it, so the escalation flow is unchanged.
562
+ */
563
+ const ownedFailure = (error, policy) => {
564
+ const code = error && typeof error.code === 'string' && error.code.length > 0 ? error.code : undefined
565
+ const message =
566
+ code === 'FS_SANDBOX_DENIED'
567
+ ? '[sandbox: file access denied under ' + policy.mode + ' mode]\n' +
568
+ '[sandbox: escalation available \u2014 retry this exact operation once with sandbox_permissions ' +
569
+ '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'
570
+ : String(error && error.message ? error.message : error)
571
+ return {
572
+ isError: true,
573
+ error: { message, ...(code === undefined ? {} : { info: { code } }) },
574
+ content: [{ type: 'text', text: 'Error: ' + message }],
575
+ }
576
+ }
577
+
541
578
  ctx.on('tools/execute', async (exec, next) => {
542
- if (exec.agent && exec.agent.session && exec.agent.session.header) {
543
- hydrate(exec.agent.session.header.cwd)
579
+ // Hydration must be AWAITED on the interception path, not fire-and-forget:
580
+ // a first call that arrives before the config read resolves would see an
581
+ // empty dirs cache, fall through to the default pipeline, and be fenced
582
+ // against the PRIMARY workspace root — surfacing as a spurious
583
+ // `[sandbox: file access denied under workspace-write mode]` for a
584
+ // secondary-directory write/edit. `loadDirs` caches, so only the first
585
+ // call pays the read.
586
+ const headerCwd =
587
+ exec.agent && exec.agent.session && exec.agent.session.header
588
+ ? exec.agent.session.header.cwd
589
+ : undefined
590
+ if (typeof headerCwd === 'string' && headerCwd.length > 0) {
591
+ await loadDirs(headerCwd)
544
592
  }
545
593
  if (!INTERCEPT_TOOLS.has(exec.name)) return next()
594
+ // Non-null once the call's target has been resolved into a configured
595
+ // secondary directory: from that point the interception OWNS the call and a
596
+ // failure must be reported as this call's error, never handed back to the
597
+ // default pipeline (which fences against the PRIMARY workspace root and
598
+ // would answer any failure with the spurious workspace-write denial).
599
+ let owned = null
546
600
  try {
547
601
  const args = exec.arguments
548
602
  const standing = sandboxPolicy.resolve(exec.agent ? { session: exec.agent.session } : {})
549
603
  const primary = standing.workspaceRoot
550
- // An explicit escalation request belongs to the default pipeline.
551
- if (args && args.sandbox_permissions !== undefined) return next()
604
+ // An explicit escalation request belongs to the default pipeline (it owns
605
+ // the approval flow). Only a non-empty mode string is a request: a
606
+ // null/empty value is not, and must not hand a secondary-directory
607
+ // mutation to the primary-rooted pipeline.
608
+ if (args && typeof args.sandbox_permissions === 'string' && args.sandbox_permissions.length > 0) return next()
609
+ // The policy root is realpath-canonicalized by sandbox-policy while
610
+ // hydration is keyed by the session cwd as spelled in the header; on a
611
+ // workspace reached through a symlinked/junctioned ancestor the two
612
+ // spellings differ, so consult both keys before falling through.
613
+ const dirs =
614
+ dirsForSync(primary) ??
615
+ (typeof headerCwd === 'string' && headerCwd.length > 0 ? dirsForSync(headerCwd) : null)
552
616
 
553
617
  if (exec.name === 'write' || exec.name === 'edit') {
554
618
  const filePath = args && typeof args.file_path === 'string' ? args.file_path : null
555
619
  if (filePath === null) return next()
556
620
  // Resolve first so `..`, symlinks, and case differences canonicalize
557
621
  // before containment matching (same cwd the shipped tools use).
558
- const target = await fs.resolve(filePath, { cwd: primary })
622
+ let target
623
+ try {
624
+ target = await fs.resolve(filePath, { cwd: primary })
625
+ } catch (error) {
626
+ // An unresolvable ABSOLUTE path that is lexically inside a secondary
627
+ // directory is still this plugin's call to answer: the default pipeline
628
+ // would resolve it against the PRIMARY root and report the sandbox
629
+ // denial, hiding the resolution failure.
630
+ if (dirs !== null && isAbsolute(filePath)) {
631
+ const rawHit = longestRootFirst(dirs).find((d) => pathInside(filePath, d))
632
+ if (rawHit !== undefined) return ownedFailure(error, { ...standing, workspaceRoot: rawHit })
633
+ }
634
+ return next()
635
+ }
559
636
  const abs = fs.processPath(target)
560
637
  // Security boundary: configuration is user-managed. Reject direct
561
638
  // write/edit attempts against the host-owned config file, even before
562
- // any directory matching.
563
- if (wsKey(abs) === wsKey(configPathFor(primary))) {
639
+ // any directory matching. Both spellings of the workspace key are
640
+ // checked (see the dirs lookup above).
641
+ const guardHits = new Set(
642
+ [primary, headerCwd].filter((p) => typeof p === 'string' && p.length > 0).map((p) => wsKey(configPathFor(p))),
643
+ )
644
+ if (guardHits.has(wsKey(abs))) {
564
645
  return {
565
646
  isError: true,
566
647
  error: { message: 'multi-folder configuration is user-managed' },
567
648
  content: [{ type: 'text', text: CONFIG_GUARD_TEXT }],
568
649
  }
569
650
  }
570
- const dirs = dirsForSync(primary)
571
651
  if (dirs === null) return next()
572
652
  const hit = longestRootFirst(dirs).find((d) => pathInside(abs, d))
573
653
  if (hit === undefined) return next()
574
654
  const policy = { ...standing, workspaceRoot: hit }
575
655
 
576
656
  if (exec.name === 'write') {
657
+ owned = { policy }
577
658
  const outcome = await fs.writeText(target, String(args.content), undefined, exec.signal, policy)
659
+ // Keep the observation layer coherent with the shipped write tool's
660
+ // contract: a successful create/update is a presence observation.
661
+ if (typeof ctx.emit === 'function') {
662
+ ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
663
+ }
578
664
  const displayPath = displayPathOf(target, filePath)
579
665
  const value = {
580
666
  path: displayPath,
@@ -596,6 +682,7 @@ export function apply(ctx) {
596
682
  const newString = args && typeof args.new_string === 'string' ? args.new_string : null
597
683
  if (oldString === null || newString === null) return next()
598
684
  const replaceAll = args.replace_all === true
685
+ owned = { policy }
599
686
  const outcome = await fs.editText(
600
687
  target,
601
688
  { oldString, newString, replaceAll },
@@ -603,6 +690,9 @@ export function apply(ctx) {
603
690
  exec.signal,
604
691
  policy,
605
692
  )
693
+ if (typeof ctx.emit === 'function') {
694
+ ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
695
+ }
606
696
  const displayPath = displayPathOf(target, filePath)
607
697
  const value = { path: displayPath, before: outcome.before, after: outcome.after }
608
698
  const text = replaceAll
@@ -614,7 +704,6 @@ export function apply(ctx) {
614
704
  if (exec.name === 'pwsh' || exec.name === 'bash') {
615
705
  const shell = ctx.get('shell')
616
706
  if (shell === undefined) return next()
617
- const dirs = dirsForSync(primary)
618
707
  if (dirs === null) return next()
619
708
  const rawWorkdir = args && typeof args.workdir === 'string' ? args.workdir : null
620
709
  const joined = rawWorkdir === null
@@ -650,6 +739,7 @@ export function apply(ctx) {
650
739
  if (exec.signal && exec.signal.aborted) return next()
651
740
  const jobs = ctx.get('jobs')
652
741
  if (jobs === undefined) return next()
742
+ owned = { policy }
653
743
  const jobId = jobs.start({
654
744
  kind: exec.name,
655
745
  label: String(args.command),
@@ -670,6 +760,7 @@ export function apply(ctx) {
670
760
  }
671
761
  }
672
762
 
763
+ owned = { policy }
673
764
  const result = await shell.run(shell.resolve({ ...request, signal: exec.signal }))
674
765
  if (result.aborted) {
675
766
  return {
@@ -710,9 +801,14 @@ export function apply(ctx) {
710
801
  return { isError: false, value, content: [{ type: 'text', text: shellRender(value) }] }
711
802
  }
712
803
  return next()
713
- } catch {
714
- // Any interception failure falls back to the default pipeline.
715
- return next()
804
+ } catch (error) {
805
+ // A failure BEFORE the call was claimed (path resolution, config lookup,
806
+ // shell service lookup) falls back to the default pipeline. A failure
807
+ // AFTER the claim — the mutation or the shell run itself — does not: the
808
+ // default pipeline fences the call against the PRIMARY workspace root, so
809
+ // it could only answer with the spurious workspace-write denial and would
810
+ // hide the real cause (for example a missing `old_string`).
811
+ return owned === null ? next() : ownedFailure(error, owned.policy)
716
812
  }
717
813
  })
718
814
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-folder",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
@@ -42,6 +42,10 @@
42
42
  "compatibility": {
43
43
  "node": ">=20",
44
44
  "dshReleases": {
45
+ "0.1.2-alpha.5": "compatible",
46
+ "0.1.2-alpha.4": "compatible",
47
+ "0.1.2-alpha.3": "compatible",
48
+ "0.1.2-alpha.2": "compatible",
45
49
  "0.1.1-rc.2": "incompatible",
46
50
  "0.1.2-alpha.1": "compatible"
47
51
  }