RFC: capability capsules for DeepSeek Harness plugins
August 15, 2026 ยท View on GitHub
Status: implemented by the installable v0.1 release.
Scope
DeepSeek Harness loads ordinary Cordis plugins into one process. An installed in-process plugin therefore receives the DSH process's ambient filesystem, credential, network, and service authority, including during activation, listeners, timers, and dependency initialization. Tool approval and the tool sandbox do not constrain that code.
dsh-capsule changes the execution model for compatible guests. The Capsule Host is a trusted Cordis plugin, but a guest is a standalone process speaking dsh-capsule/0; it never receives a Cordis Context or another host object. v0.1 supports Linux Bubblewrap isolation, model tools, static system-prompt sections, and exact read-only mounts of existing regular workspace files. It does not transparently run arbitrary Cordis plugins.
Runtime components
Trusted host
The host owns package admission, immutable artifact staging, descriptor validation, DSH registrations, scheduling, cancellation, process joins, and staging cleanup. It exposes only detached capsule inventory through its Cordis service.
Linux Bubblewrap provider
The only v0.1 isolation provider starts a trusted launcher through an attested DSH managed-subprocess runtime and then starts Bubblewrap. It supports Linux only. Any SubprocessRuntime may be used if its execution-world attestation passes; the built-in local provider is the normal satisfying implementation, not a required class identity. The selected provider remains in the trusted computing base for executable resolution, process creation, bounded capture, termination, and complete process-tree joins.
Before package admission or guest execution, the host writes a private random challenge and asks the candidate runtime to start the resolved Node executable with an empty environment. One startupTimeoutMs deadline bounds executable resolution, host-side identity reads, the probe, response collection, validation, and normal process-tree join. Probe stdout is limited to 65,536 bytes, stderr uses maxStderrBytes, and cleanup terminates and waits for an incomplete tree.
The strict attestation response must echo the challenge value and report decimal identities. The host compares dev, ino, and size for the child Node executable, resolved Bubblewrap executable, every reported non-addon shared object selected for the runtime, and the private challenge file. It also compares dev and ino for /proc/self/ns/mnt and /. Unknown fields, malformed or truncated output, a different object or filesystem view, timeout, abnormal exit, or incomplete join fails activation. Attestation has no weaker fallback and does not remove the provider from the trusted computing base.
Activation also fails on another platform, when bwrap or the active Node executable cannot be resolved, when the installed Bubblewrap lacks descriptor-bind support, or when Node cannot report the absolute non-addon shared libraries needed inside the sandbox. No weaker isolation provider is selected.
The host reads the built launcher and its strict launch-spec parser before guest execution, copies those immutable bytes beside the staged guest, and writes a private exact-field JSON launch document. Every mount has an explicit runtime, guest, or workspace-read role. The parser requires the guest role at /capsule/guest.mjs, workspace-read roles below /workspace, exactly one runtime role at /runtime/node, and no other runtime role in a controlled tree; mount destinations cannot overlap. For each cell, the launcher walks every absolute mount source from / with Linux O_PATH | O_NOFOLLOW, requires directory ancestors and a regular-file leaf, rejects symbolic links, and keeps the resulting descriptors open. It gives Bubblewrap only descriptor numbers through --ro-bind-fd; a source pathname replacement after opening cannot redirect that cell's mount. The launcher itself receives an empty environment, runs only with the attested managed process tree, and mirrors Bubblewrap's exit status before closing every descriptor.
Each cell uses a new user, PID, mount, and network namespace. Bubblewrap starts from an empty root and mounts:
- the resolved Node executable as read-only
/runtime/node; - Node's reported non-addon shared objects at their required absolute destinations, read-only; destinations are POSIX-lexically normalized without resolving loader aliases, and a normalized collision with different source objects fails activation;
- the integrity-verified artifact as read-only
/capsule/guest.mjs; - each admitted regular workspace file at the same relative destination under
/workspace, read-only; - a size-bounded private tmpfs at
/tmp, plus a new/procand minimal/dev.
The provider uses a new session, drops every capability, disables nested user namespaces, assigns a fixed hostname, and does not join the host network namespace. It removes every inherited environment entry before starting the launcher, asks Bubblewrap to clear the environment again, and sets only HOME=/tmp and PATH=/runtime for the guest.
Node starts with the Permission Model enabled. Its filesystem allowlist contains /capsule/guest.mjs and /tmp, then adds workspace reads only for mounts carrying the workspace-read role. Destination text alone cannot classify a runtime mount as workspace authority. Addons and the SIGUSR1 inspector entry are disabled, prototype mutation throws, and V8 old space has a configured ceiling. These Node controls are defense in depth. Bubblewrap and the operating system enforce the isolation claim.
Contribution bridge
The host registers admitted tools with the real DSH tool registry and static prompt sections with the real system-prompt registry. Tool arguments pass through the DSH JSON Schema validator before entering a cell. Tool execution, timeout propagation, rendering, model exposure, and session logging remain on the ordinary DSH path. Prompt content is data returned during describe; the guest receives no callback or prompt-registry object.
Guest SDK
dsh-capsule/guest implements strict framing, message parsing, descriptor publication, one-tool dispatch, bounded structured errors, and the shutdown handshake. The SDK redirects console output to stderr so stdout remains a protocol-only stream.
The guest entry must be one standalone ESM file containing the SDK and every JavaScript dependency. The sandbox has no package tree or module resolver input beyond that file. Built-in Node modules remain subject to the OS sandbox and the defense-in-depth Node permissions.
Package and policy admission
One configured mount contains:
root: the absolute root of an inert capsule package;manifest: a forward-slash relative manifest path insideroot;- optional
workspaceRoot: the absolute authoritative root for workspace grants; policy: the deployment-owned capsule id, tool names, prompt-section names, and resource grants.
Execution-world attestation completes before the following package operations and before any contribution is visible:
- Resolve the package root and manifest without executing guest code; reject traversal, out-of-root resolution, non-regular files, invalid UTF-8 or JSON, oversize input, unknown fields, and the wrong protocol.
- Resolve the manifest's entry inside the package root, retain it under the artifact byte limit, and verify the declared lowercase SHA-256 digest.
- Resolve every
workspace-readrequest againstworkspaceRoot. Each path must already exist as a regular file, remain inside the authoritative root, contain no symlink traversal, and remain disjoint from private Host staging. A directory or other special file fails admission. - Copy the retained artifact bytes to a private
0400staging file, then compare capsule identity, contribution names, and resource requests with deployment policy. Policy is an upper bound: each contribution must be allowlisted and each workspace request needs an exact same-pathworkspace-readgrant. A parent path does not imply a descendant grant. Extraworkspace-readpolicy entries undergo syntax validation but do not create mounts or require a filesystem target until requested. - Reject every
workspace-write,network-connect,subprocess-exec, orstoragerequest or grant. v0.1 implements only regular-fileworkspace-read; an empty request/grant set is valid and needs noworkspaceRoot. - Start one fresh
describecell, validate its descriptor names exactly against the manifest, validate schemas and bounded text, complete shutdown, and join the process tree. - Publish all tool and prompt registrations as one host-owned generation. Any failure before publication leaves no partial contribution set.
The host does not ask the user to elevate policy during activation. A changed artifact digest, manifest, policy, or workspace mapping is admitted only by a new activation; the host does not weaken the requested controls to make that activation succeed.
Workspace capability semantics
Manifest requests use workspace-relative forward-slash file paths and reject absolute forms, Windows drive syntax, empty segments, and embedded . or .. segments. The manifest grammar's standalone . names workspaceRoot, but v0.1 rejects that directory target. A deployment grants the exact same workspace-read path in policy and supplies one absolute workspaceRoot for every nonempty workspace request set.
After resolving the authoritative root, the host resolves every requested path with realpath. The result must equal the lexical target, which rejects a symlink at the leaf or in any traversed component, must remain inside the root, and must be a regular file disjoint from private Host staging. The trusted launcher repeats no-symlink traversal for every cell and pins the requested file before Bubblewrap maps its descriptor read-only to /workspace/<path> with --ro-bind-fd. Node receives only the corresponding read permission.
The grant is a read-only mount of the existing regular file safely opened at cell startup, not a path-pattern broker. Replacing a path before the next cell may select a different non-symlink regular file now located at that same authorized name; replacing it after the launcher opens it does not redirect that cell. A trusted external process may still change the pinned file's contents during a call. v0.1 does not create missing paths, follow later policy changes, expose other workspace paths, or provide guest-controlled persistent state.
Directory mounts are excluded because their contents can include Host IPC endpoints and other special files. Raw write mounts are excluded because inode aliases can extend a write's effects beyond the granted pathname. Directory and write access require a future Host-owned broker or snapshot-and-commit mechanism that validates transferred data and controls side effects instead of exposing either mount directly.
Ephemeral-cell lifecycle
Guest code runs only in one-shot cells:
activation:
host -> fresh guest hello(protocol, mode=describe)
guest -> host ready(protocol, tools, promptSections)
host -> guest shutdown
guest -> host shutdownAck
host joins process tree, then publishes the generation
each tool call:
host -> fresh guest hello(protocol, mode=invoke, callId, tool, arguments)
guest -> host ready(protocol, tools, promptSections)
guest -> host result(callId, outcome)
host -> guest shutdown
guest -> host shutdownAck
host joins process tree, then returns the result
The invocation cell must repeat the same admitted descriptor values. Descriptor drift terminates the call. One cell executes at most one tool, and no guest process or in-memory guest state is reused between calls. v0.1 provides no guest-controlled cross-call state; a mounted workspace file is read-only and may reflect trusted external changes between cells.
Every normal cell ends with shutdownAck, closed stdout, a zero exit status, and a joined managed process tree. Cancellation, malformed protocol, timeout, abnormal exit, extra output, or shutdown failure aborts the cell, terminates the managed tree, waits for exit, and surfaces a structured failure through the tool call.
Wire protocol
Each message is a discriminated JSON value in a four-byte unsigned big-endian length-prefixed frame. The parser rejects invalid UTF-8, invalid or non-finite JSON values, cycles before encoding, truncated frames, oversize frames, unknown message types, unknown fields, missing fields, the wrong message order, mismatched call ids, and unsupported protocol versions.
The host owns limits for frame bytes, retained stderr, description and prompt bytes, JSON schema depth and node count, startup, invocation and shutdown time, active cells, queued calls, V8 old space, and private tmpfs. Calls may share scheduler slots up to the configured limit. Queue overflow fails immediately. On host disposal, registrations disappear first, queued work is rejected, active cells are aborted and joined, and staged artifacts are removed.
These bounds reduce accidental and some adversarial resource consumption; they are not a complete denial-of-service defense. v0.1 does not install cgroup CPU, process-count, aggregate-memory, or workspace-I/O quotas.
Failure and security claims
- A configured capsule either activates with
linux-bwrapand the exact admitted generation or does not activate. There is no silent fallback to an in-process, worker,node:vm, Permission-Model-only, or protocol-only mode. - Unknown manifest or wire input fails closed. One invocation-cell failure fails that call without executing guest JavaScript in the DSH process.
- The host network namespace is absent from each cell, and v0.1 provides no network broker.
- Direct host filesystem visibility is limited to the descriptor-pinned runtime files, private tmpfs, exact artifact, minimal virtual filesystems, and explicitly mounted workspace files described above.
- Static prompt text is untrusted model input. Exact registration and session logging do not make its content safe or prevent prompt injection.
- The isolation claim assumes an uncompromised Linux kernel, Bubblewrap, Node runtime, DSH managed-subprocess provider, Capsule Host, and host-side DSH services. Kernel exploits, sandbox implementation vulnerabilities, microarchitectural side channels, host compromise, and robust denial-of-service resistance are outside the claim.
The complete boundary and non-goals are maintained in the threat model.
Deferred capabilities
workspace-write, network-connect, subprocess-exec, and storage remain reserved manifest names so their exact syntax can be rejected consistently, but v0.1 never admits them. A later release must add a Host-owned broker or snapshot-and-commit mechanism, configuration and policy semantics, protocol messages where needed, teardown behavior, threat-model analysis, and invalid/adversarial tests before enabling directory or write access or any reserved capability. Directory-entry validation, write alias control, network origin matching, executable lookup, shell semantics, storage namespace ownership, quotas, and durable cleanup are not implied by the current parser types.