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 +24 -8
- package/lib/index.js +108 -12
- package/package.json +5 -1
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,
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
observation
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
715
|
-
|
|
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.
|
|
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
|
}
|