beicause/dsh-extra-sandbox-presets

dsh-workspace-write-extra

A DeepSeek Harness permission preset that grants write access to configured extra directories on top of the session workspace. Linux bwrap.

미분류

dsh-extra-sandbox-presets

English | 简体中文

Registers any number of configured permission presets in DeepSeek Harness. Each preset selects a sandbox mode and an approval policy, plus the directories that become writable on top of whatever that mode already allows.

That covers both directions the harness can express — a workspace-write preset that opens directories beside the session workspace, and a read-only preset that opens exactly the directories it lists and nothing else — and any number of each.

The three stock sandbox modes are not modified in any way: read-only, workspace-write, and danger-full-access keep their exact meaning, their persisted schemas, and their escalation ladder.

What it adds

One entry in the permission picker per configured preset. A typical table:

PresetSandbox modeApprovalWrites
read-onlyread-onlyasknothing
workspace-writeworkspace-writeaskworkspace + temp areas
workspace-write-extraworkspace-writeaskthe above + its configured directories
scratchread-onlyneveronly its configured directories
danger-full-accessdanger-full-accessnevereverything

A preset is a permission preset, not a fourth SandboxMode. That distinction is the whole point: the sandbox mode vocabulary is a closed three-value union that is written into sandbox/mode session events and re-validated by several foreign projections when a session is restored. A fourth value there would make every session that used it unreadable. Bundling an existing mode with extra behaviour is the same approach the shipped auto preset takes.

Enablement is per session: a preset's directories apply while that session's last recorded permission preset is that preset, which survives a restart because the choice lives in the session log.

How it stays out of the way

The plugin does not disable, replace, or reorder any stock row. It wraps the two live enforcement services in place, and unwraps them exactly on unload:

  • the mounted filesystem's containment check (checkedTarget) gains a retry of its denial against the directories the selected preset grants;
  • the sandbox provider's confine gains the matching --bind pairs in the bwrap profile.

The fence retry deliberately covers read-only as well: the stock fence refuses a read-only mutation before it ever resolves the target, so the wrapper resolves the target itself and compares it against the preset's directories. That is what lets a read-only preset grant writes, and it grants exactly the directories it lists.

The model-facing note needs the same correction, because the stock sandbox:policy note is rendered from the mode alone: under read-only it states that nothing may be modified, and under workspace-write it names only the workspace, while a selected preset may in fact make further directories writable. The plugin therefore registers a runtime context of its own, right after the stock note, naming the directories the selected preset grants. In both confined modes the note reads as a closed boundary, so the sentence states the exception outright instead of relying on "additionally" alone to be read as an override of the sentence before it: under read-only it says the directories are writable even under the read-only policy above (the stock sentence reads as a flat prohibition), and under workspace-write it says they are writable whether or not they are inside the session workspace (the stock sentence names only that path and reads as the whole writable area). A danger-full-access preset contributes nothing: that mode already restricts nothing, so "additionally writable directories" would be a false statement rather than a correction.

The note is a context of its own rather than an amendment of the stock one, and that distinction is what makes it reach the model. The agent loop re-resolves every runtime context from its registered provider while preparing each request, then logs the result as the snapshot the next step reads. A copy of another subsystem's entry that this plugin rewrote in the system-prompt/assemble waterfall is therefore present in a direct assemble() call and absent from every real step — the provider's own text replaces it. A provider of this plugin's own survives that refresh, and re-rendering from the live selection is what makes the note follow a preset switch. Registering under the stock name is not an option either, since the system-prompt service keys contexts by name within one layer and rejects a duplicate.

Two consequences follow, and both matter:

  • Failure is safe. If this plugin does not load, the harness keeps the stock filesystem and sandbox rather than losing them. (The alternative — disabling the stock rows and inserting replacements — has no such fallback, and Cordis refuses to register two providers of the same service name, so it cannot be made safe by shadowing.)
  • It is additive. The stock check stays authoritative for every case it already handled; only its own denial is reconsidered.

Configuration

Both surfaces are supported.

Profile config

- id: extra-sandbox-presets
  name: dsh-extra-sandbox-presets
  config:
    presets:
      # The session workspace plus a few caches.
      workspace-write-extra:
        writableDirs:
          - /srv/shared-assets
          - ~/notes
      # No workspace write at all; only these two directories.
      scratch:
        sandbox: read-only
        approval: never
        writableDirs:
          - /tmp
          - ~/scratch
      # A relabelled entry: the client shows `name`, falling back to the key.
      rust-tools:
        sandbox: workspace-write
        writableDirs: [~/.cargo, ~/.rustup]
        name: Rust tools
        description: Workspace plus the cargo and rustup caches
FieldDefaultMeaning
sandboxworkspace-writeread-only, workspace-write, or danger-full-access
approvalaskask or never
writableDirs[]Directories writable on top of the selected mode
namethe preset keyOptional display label
descriptionnoneOptional one-line description

Omit name unless you want a specific label: the picker derives its text from the preset key otherwise (workspace-write-extra renders as Workspace Write Extra), while a supplied label is shown verbatim, in English, because the client localizes only its own built-in presets.

Directories must be absolute; a leading ~ is expanded. Symlinks are resolved, and a missing or non-directory entry is ignored with a warning rather than created — granting a path nobody named would be the unsafe direction. Expansion is asynchronous, so a directory only becomes writable after its own successful expansion. A preset whose sandbox or approval is not one of the values above is likewise refused with a warning instead of being published, as are the reserved names custom and auto.

An entry prefixed with workspace: names a path relative to the session workspace, and is resolved separately for each session:

workspace-write-extra:
  writableDirs:
    - /srv/shared-assets
    - workspace:notes        # <session workspace>/notes
    - workspace:cargo/target # <session workspace>/cargo/target

A directory below the workspace is resolved per call, against the root the harness folds for the calling session, rather than once while the plugin loads: the workspace belongs to the session, and the plugin does not know it yet at that point. One entry therefore means the same thing in every session, and the model-facing note names the real path.

A workspace: entry must be relative — workspace:/etc and workspace:~/x are refused with a warning — and may not contain .., which is refused rather than silently clamped so a configuration cannot escape the workspace. A directory below the workspace that does not exist yet is still granted: the path is only compared, and the bwrap profile skips a bind source that is missing.

writableDirs only has an effect under a confined mode. A preset selecting danger-full-access may list them — they stay visible in the table — but that mode confines nothing, so they grant nothing extra and the model-facing note is left untouched.

Preset order is configuration order, which is the order the picker offers them.

Settings page

presets is a volatile field, so the whole table also appears on the Settings page and edits apply live to every session in the profile. The page edits it as one document rather than as a nested form, so the profile patch above stays the natural place to write it.

Compatibility with the harness

This plugin declares no @deepseek-ai/dsh-* peer range, and the harness checks only such peers. Its compatibility therefore rests on something narrower and more exact: the shape of two mounted services (plus one event and one context name). That shape is what the code actually uses, and the service half is pinned by test/contract.test.js` against the real classes, so a harness upgrade that moves any of it fails the suite immediately rather than narrowing a preset in production.

Depends onUsed by
fs.checkedTarget(target, policy) rejects with code FS_SANDBOX_DENIEDsrc/fs.js`
fs.resolve(path) resolves a target carrying targetKeysrc/fs.js`
sandbox.confine(argv, policy, signal) resolves to an object carrying argvsrc/provider.js`
Policy fields mode and sessionId; result field argvboth wrappers
permissionPresets.presets and emitCatalogChanged()src/service.js`
The prompt registry, and a sandbox:policy context to place the note besidesrc/service.js`

Two properties keep this robust across upgrades:

  • The wrappers call the stock methods through await, so a stock method that changes between synchronous and asynchronous keeps working.
  • Both wrappers operate on the returned object rather than assuming a bare argv, so a return value that grows fields keeps working.

When a mounted service does not expose the expected seam at all, the plugin reports it once per service and leaves the instance untouched. It never disables a stock row, so the failure mode of any unexpected harness shape is a logged warning and stock behaviour — never a missing filesystem or sandbox.

Platform support

  • Linux with bwrap — fully supported. The configured directories join the bubblewrap profile as --bind <dir> <dir>, so confined commands agree with the write/edit tools.
  • Linux with landlock, and macOS seatbelt — the write/edit tools are widened, but confined commands are not, because those profiles are built inside packages this plugin cannot extend. A warning is logged once instead of pretending otherwise.
  • Windows — not supported.

Layout

FileRole
src/service.js`ctx.sandboxPresets: owns the configured preset table, decides per session which preset applies, resolves workspace: entries against the calling session, wraps the two enforcement services, publishes the presets, and registers the model-facing note beside the stock sandbox note.
src/presets.js`Normalizes the configured table and expands each preset's directories.
src/fs.js`Wraps the live filesystem's containment check.
src/provider.js`Wraps the live sandbox provider's confine.
src/roots.js`Path expansion and containment checks.
src/plan.js`Pure bwrap argv transformation.
cordis.patch.ymlInserts the one plugin row.
test/contract.test.jsPins the harness seams this plugin extends.

The sources are plain JavaScript with JSDoc types, and src/contracts.d.ts carries the structural shapes JSDoc states poorly. The package runs from source: main and exports point at src/, so there is no build step and no lib/ — what you edit is exactly what the harness loads.

Note that the profile installs this package by symlink (link:), pointing at this working tree, so an edit to src/ is live for the profile immediately; only the plugin must be reloaded.

Typecheck

pnpm run typecheck  # tsc -p tsconfig.json: checkJs over src/ and test/

The DSH packages are peer/dev dependencies only for their .d.ts; the runtime imports nothing from them that the harness does not already provide.

Tests

pnpm test                        # node --test test/*.test.js
node scripts/require-no-skips.mjs # same run, failing on any skip

src/roots.js, src/plan.js, src/presets.js, and the two wrappers are dependency-free and run anywhere. The contract and integration suites need the DSH packages to be resolvable, so they run from an installed profile: the contract suite pins the real stock seams, and the integration suite assembles the real filesystem and sandbox provider and asserts both the widening and its exact reversal on unload. Every suite that needs those packages skips with a reason when they are absent, and never passes vacuously. Nothing to build first: the suite runs against the same src/ the harness loads.