In the tool execution pipeline of DeepSeek Harness, there are two stages that senior developers both love and fear: one is ctx.sandbox, which determines the boundary within which a command runs, and the other is ctx.approval, which determines whether this specific operation is allowed. The former governs constraints, the latter governs authorization; they appear to have distinct roles, but they share the same bottom line—fail closed by default. In other words, when the system cannot confirm whether something is safe, the answer is not to let it through, but to block it. This article is aimed at advanced readers who have already written tool plugins and understand the plugin lifecycle and tool registration mechanism. Following the main thread of "which two gates a dangerous operation must pass through," it will unpack one by one the sandbox decision, the approval flow, the three tiers of SandboxMode permissions, the enforcement completeness report, the three-layer policy fallback order, and the normalization semantics of workspaceRoot. After reading the first half, you should be able to answer one question precisely: when an Agent wants to execute a dangerous command, at exactly which points does Harness lock it in a cage, and what exactly does the lock on each cage secure?
Sandbox decision + approval flow panorama: which two gates a dangerous operation must pass through
First, let's put the two services in the same picture to understand that they are answering two sides of the same question: the Agent wants to do something risky—how do we constrain it. On the left is how the sandbox wraps argv; on the right is how approval makes a one-time decision. The sandbox fences off "which files the process can touch"; approval leaves "whether to let this operation through" to the responder. Neither is a post-hoc remedy; both render judgment before the action actually reaches the operating system.
The first intuition to establish here is separation of responsibilities. The sandbox does not care about "whether this operation should be done," but about "if it is to be done, how large is the filesystem range the process can reach." It is a boundary problem. Approval does not care about "how far the process can run," but about "whether this one time, this specific operation, is currently permitted." It is an authorization problem. Conflating the two is the earliest pitfall many plugin authors fall into: assuming that once inside the sandbox everything is fine, or assuming that once approval passes the sandbox can be ignored. In reality, an operation can be both wrapped by the sandbox and allowed by approval, or be rejected inside the sandbox, or be denied by approval; the two paths take effect independently.
The entry point for the sandbox decision is ctx.sandbox.confine(argv, policy), which consumes the exact argv and returns a wrapped result. The approval flow, before or outside the sandbox, gives a one-time judgment on "whether to execute." Note that the word "one-time" is crucial: approval does not give a tool a permanent green light, but makes a decision for a particular invocation. The next time the same operation comes around, it must go through the process again. This design pushes the authorization granularity down to the single-call level, preventing a broad "allow" from being silently inherited by subsequent calls.
There is also a conclusion that runs through the entire article and must be stated clearly in advance: both the sandbox and approval fail closed by default. Fail closed means that when a depended-upon component is unavailable, information is incomplete, or execution completeness falls short of what was promised, the system tends to refuse execution rather than let it through. This sounds conservative, but for an Agent framework that can read and write files and spawn processes, any "silent pass-through" is a potential gateway to disaster. Fail closed is not a slogan; it has concrete manifestations in the code paths: under a restricted policy, ctx.sandbox.confine throws SandboxUnavailableError when no backend is available, with error code SANDBOX_UNAVAILABLE; when a consumer receives a partial enforcement completeness, if it requires an absolute boundary, it must treat partial as "not enough." We will unpack these details one by one later.
Aligning the inputs and outputs of the two gates helps establish engineering precision: on the sandbox side, the input is an exact argv plus a policy object, and the output is the replaced argv plus the enforcement facts the backend actually achieved; on the approval side, the input is a specific operation request, and the output is a one-time allow-or-deny decision. What the two paths share is that neither trusts "execute by default," and both require explicitly passing through some controlled step. Once you understand this, looking at the specific mode tiers and fallback order no longer makes them feel like scattered rules, but rather projections of the same security philosophy at different layers.
ctx.sandbox.confine(argv, policy): Why you must hand over the exact argv rather than a shell string
The process sandbox model can be summed up in one sentence: the consumer hands over the exact argv, and the backend wraps it according to the file-effect policy. Every word here carries weight. "Consumer" refers to the tool or plugin calling the sandbox, "backend" refers to the underlying implementation actually responsible for enforcing isolation, and "file-effect policy" refers to the filesystem constraint goal this wrapping is meant to achieve.
Why emphasize "exact argv" rather than a shell string? Because what the sandbox needs to judge and constrain is what the process will actually execute. Before a shell string is interpreted, it is hard to say exactly which paths it will touch, or whether variable expansion will let it escape the expected boundary. An argv array, by contrast, is an already-split program name plus argument list, with determinate semantics and no second parsing. This is exactly the property a security boundary needs most: no ambiguity. So if a consumer is inherently shell-shaped, it must wrap the command into an argv form like ['bash', '-c', command] and hand that to the sandbox, rather than stuffing a bare string in directly. This is not a stylistic preference but a model requirement: the interface signature the sandbox consumes is argv.
The structure of the return value is likewise worth unpacking. ctx.sandbox.confine returns a ConfinedArgv, which contains two pieces of information: the replaced argv, and the backend's enforcement facts. The first part is easy to understand—the backend may need to wrap the original argv in a layer, or rewrite the executable path, so that the real process starts in a controlled environment. The second part is what advanced readers should focus on: it answers "how much the backend claims it achieved." Neither piece can be missing. If you only look at the replaced argv and spawn directly, you may overlook that the backend actually implemented only partial isolation; if you only look at enforcement and do not use the replaced argv, your spawn simply never enters the sandbox. The correct approach is to consume both.
The following TypeScript example strings together the full path of "resolve policy → hand over argv → distinguish danger-full-access from restricted mode → consume ConfinedArgv," and can be pasted directly as a plugin skeleton.
// File path: my-plugins/sandbox-demo/src/index.ts
// Demonstrates a sandboxed consumer: resolve the policy first, then let ctx.sandbox wrap the argv.
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'sandbox-demo'
export const inject = ['tools', 'sandbox', 'sandboxPolicy']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'sandboxed_echo',
description: 'Run echo inside the sandbox.',
parameters: {
text: { type: 'string', required: true, description: 'Text to echo' },
},
output: { schema: { type: 'string' } },
async execute(args, exec) {
// 1. Resolve the full policy for this call: the session cwd is the workspace boundary.
const policy = ctx.sandboxPolicy.resolve({ session: exec.agent?.session })
// 2. The consumer hands over the exact argv (program plus arguments), not a shell string.
const argv = ['bash', '-c', `echo ${JSON.stringify(args.text)}`]
// 3. danger-full-access spawns directly; everything else is handed to the sandbox for wrapping.
if (policy.mode === 'danger-full-access') {
const { spawn } = await import('node:child_process')
// ... spawn(argv) and collect output
return `echo ${args.text}`
}
// 4. Restricted mode: confine returns the replaced argv; without a backend it throws SANDBOX_UNAVAILABLE.
const confined = ctx.sandbox.confine(argv, {
mode: policy.mode,
workspaceRoot: policy.workspaceRoot,
})
// 5. The consumer then spawns confined.argv, and uses confined.enforcement to decide whether to require full.
if (confined.enforcement === 'partial' && policy.mode !== 'read-only') {
return {
isError: true,
error: { message: 'partial enforcement is not acceptable' },
}
}
return `confined echo ${args.text} (enforcement: ${confined.enforcement})`
},
}))
}
There are several engineering points in this code that deserve to be called out individually:
- inject declares dependencies: The plugin explicitly injects the three services tools, sandbox, and sandboxPolicy, ensuring they are already available during the apply phase and avoiding the situation where a missing sandbox service is only discovered at runtime.
- argv must be self-consistent: Wrapping user input with JSON.stringify before splicing it into the command is meant to keep echo's argument as a single unit in the shell. At the same time, it reminds readers that the real security boundary relies on the sandbox, not on string concatenation.
- The danger-full-access branch spawns on its own: This tier does not call ctx.sandbox at all, and the consumer bears full responsibility for its own behavior.
- Explicit handling of partial: In non-read-only mode, when a partial is obtained, the example directly returns an error. This is the concrete way of putting "not enough is not enough" into code.
There is also a detail that is easy to overlook: confine may throw an exception. When no backend is available, it does not return a degraded ConfinedArgv, but directly throws SandboxUnavailableError with the error code SANDBOX_UNAVAILABLE. This means the consumer must use try/catch or let the error bubble up, and must never, just because it cannot get a wrapped result, cobble together a raw spawn as a substitute—that is exactly "silent unisolated passthrough," which is explicitly prohibited behavior. Fail-closed here is embodied as: better to let the call fail than to let it quietly run without isolation.
SandboxMode's three permission tiers: the boundaries of read-only, workspace-write, and danger-full-access
SandboxMode is the most intuitive part of the entire sandbox policy, but it has a limitation that must be remembered: it only governs filesystem effects, and does not include network or process visibility. In other words, these tiers solve "which files the process can read and write," not "whether the process can access the network" or "whether the process can see other processes." Keep this boundary firmly in mind, and later discussions of enforcement will not become confused.
The three permission tiers progress by degree of openness, explained tier by tier as follows:
- read-only: Only necessary data sinks are allowed, such as targets that discard writes like /dev/null; all other writes are rejected. It is suitable for tools that only need to read data, perform computation, and produce results for an outer consumer. Note that the key phrase here is "necessary data sinks"—not all writes are rejected wholesale, but rather a whitelist-like set of sinks is preserved.
- workspace-write: On top of read-only, writes are additionally allowed under the workspace root directory and the temporary area promised by the backend. This tier is the default working range for most code-oriented tools: it can generate files in the project directory without overstepping to touch system paths. Here, "temporary area promised by the backend" is an honest phrasing—where the temporary area specifically is and how large it is depends on the backend implementation, not on the mode definition itself.
- danger-full-access: It directly bypasses isolation; the consumer spawns the raw argv itself and does not call ctx.sandbox at all. It does not enter the sandbox flow, so there is no notion of "how much enforcement the backend achieved." This tier should only be used in scenarios where the risks are clearly understood and isolation is impossible.
Comparing the filesystem effects of the three permission tiers in a single table is the fastest way to troubleshoot problems like "why was my write rejected":
| SandboxMode | Filesystem read | Filesystem write | Enters sandbox flow | Typical use cases |
|---|---|---|---|---|
| read-only | Allowed | Only essential data sinks such as /dev/null are allowed; everything else is denied | Yes, handed to the provider | Read-only analysis, pure computation, formatted output |
| workspace-write | Allowed | Allowed for the workspace root and the temporary areas promised by the backend | Yes, handed to the provider | Generating code, writing logs, build artifacts |
| danger-full-access | No isolation constraints | No isolation constraints | No, the consumer spawns on its own | Special operations that genuinely require global access and cannot be isolated |
There is a common engineering misconception: treating SandboxMode as a "network switch". It is not. read-only does not mean the process cannot access the network, and workspace-write does not mean the network is restricted. If you need a read-only, offline environment, you must handle the network policy separately outside the sandbox. Another misconception is the naive belief that the write scope of workspace-write is "the current directory and its subdirectories". The actual semantics are writes under the workspace root plus the temporary areas promised by the backend; how the root is determined depends on the workspaceRoot derivation rules covered later. If cwd contains symlinks or .., your intuition may well disagree with the directory the process actually runs in—we will expand on this in the workspaceRoot section.
Finally, there is one asymmetry among the three permission tiers worth remembering: only read-only and workspace-write are sent to the provider; danger-full-access does not enter the sandbox at all. This asymmetry is not an omission but part of the security properties, which the next section covers specifically.
Why danger-full-access does not enter the sandbox: the security property that confined execution must reach ctx.sandbox
Many people, upon first seeing that danger-full-access does not enter the sandbox, assume this is just implementation laziness—everything is fully open anyway, so why go through the sandbox flow again. But viewed within the framework of security properties, this design is deliberate, even necessary.
First, let us establish the facts: only the read-only and workspace-write modes are sent to the provider; danger-full-access does not enter the sandbox at all. This means the sandbox's confine call only happens in genuinely confined scenarios. From this follows a key security property: confined execution must reach ctx.sandbox; silent unisolated passthrough is never legitimate.
Let us unpack this property. "Confined execution" means the policy is in the read-only or workspace-write state. In this state, if the consumer wants the process to run, the only legitimate path is to call ctx.sandbox.confine. If it bypasses the sandbox and spawns directly, that is "unisolated passthrough"—nominally confined, in reality running naked. What this property guarantees is that such passthrough is never tacitly permitted by the system. It will not give you a degraded path of "there is no backend anyway, so let us just treat it as fully open"; on the contrary, when no backend is available, confine throws SANDBOX_UNAVAILABLE, making it impossible for confined execution to complete without isolation.
Looking at it the other way, it becomes clear why danger-full-access does not enter the sandbox: its semantics are inherently "no isolation." If it were still made to call confine, the sandbox would either return an argv that wraps nothing (which is self-deception), or it would have to define a whole set of backend behaviors for the "no isolation" mode as well (which would normalize danger). Rather than doing that, it is better to have it explicitly not enter the sandbox, so that "fully open" is visible and auditable on the code path. In the danger-full-access branch, the consumer imports child_process and spawns directly, and anyone reading the code can see at a glance that there is no isolation here.
The practical requirements this property imposes on plugin authors are:
- In restricted mode, do not write any logic that falls back to the original spawn after a try/catch. If SANDBOX_UNAVAILABLE is caught, it should be surfaced upward rather than silently downgraded.
- Do not try to open a back door for restricted execution by reasoning that "reads cannot be stopped anyway." The module boundary is the filesystem effect; if a write is denied, it is denied.
- If global access is genuinely needed, the mode should be explicitly switched to danger-full-access, exposing that decision at the policy layer rather than hiding it inside some if.
In other words, the sandbox design rejects "implicit full openness." Full openness is allowed, but it must be stated explicitly. This is why this property is worth remembering as an axiom: it turns "whether isolation occurs" from a contingent runtime fact into a determinate fact that can be reviewed at the policy layer.
enforcement: 'full' | 'partial': how the backend reports the enforcement completeness it actually achieves
The second piece of information in ConfinedArgv—the enforcement fact—is expressed through the enforcement field, which has only two possible values: 'full' or 'partial'. It is a self-reported completeness indicator, not a boolean of whether isolation occurred.
Semantically, full means the backend controls all filesystem effects that the mode promises. Note the qualifier "that the mode promises": different modes promise different scopes. read-only promises to allow only the necessary data sinks and deny all other writes, while workspace-write promises that the workspace root and temporary area are writable. full means all of these promises are fulfilled. partial means the backend controls only a subset of them—it did something, but not everything.
The official documentation currently lists two cases of partial enforcement: the older Landlock ABI, and the Everyone and hard link boundary of the Windows ACL runner. The former is a difference in kernel interface versions on the Linux side; older ABIs can express only limited constraints, and the backend cannot cover all the effects the mode promises. The latter is a limitation on the Windows side when isolation is implemented through the ACL runner, encountered with the Everyone principal and hard link boundaries. Neither of these cases means "the backend is broken"; rather, it means "the backend can only do so much on a particular platform." Reporting them honestly as partial is far safer than silently pretending they are full.
The correct way for consumers to handle partial depends on how strict their boundary requirements are. The principle given by the documentation is: consumers that require absolute boundaries must treat partial as "not enough" and either reject it or surface the distinction upward. There are two action options in this statement—reject, or expose the distinction to the upper layer. Which one to choose depends on the nature of the tool: if the consequences of the operation are irreversible and broad in impact, rejecting outright is safer; if the upper layer is capable of making a finer judgment based on partial, then pass the enforcement value through so that the decision-maker is aware of it.
Use a single table to clearly compare the handling strategies for full and partial:
| enforcement value | Meaning | Currently known triggering situations | How consumers with strict boundary requirements should handle it |
|---|---|---|---|
| full | The backend enforces all file effects promised by the mode | None (achieved normally) | Can continue execution |
| partial | The backend enforces only a subset of the promised effects | Older Landlock ABI; the Everyone and hard link boundaries of the Windows ACL runner | Treat it as "not enough" and reject it or expose this distinction upward |
Several engineering details are easy to overlook. First, partial is not an error code; confine will not throw an exception because of partial. It returns ConfinedArgv as usual and leaves the judgment to the consumer. This differs in nature from SANDBOX_UNAVAILABLE: the latter means "there is no backend available at all," while the former means "there is a backend, but its capabilities are incomplete." Second, the sample code treats partial as insufficient only for non-read-only modes, which implies a nuance: in read-only mode, even if enforcement is partial, the promised scope is inherently very small and the risk is relatively controllable; whereas workspace-write promises writability, and partial means that some dimensions of the write boundary are not fully controlled, so rejection is more reasonable in that case. Third, consumers should not treat enforcement as optional decorative information; it is one of the inputs to security decisions and must be explicitly consumed.
There is also a practical recommendation: record the actual value of enforcement in debugging or logs, especially when deploying across platforms. The same tool may obtain different enforcement on Linux and Windows. Recording it can help you quickly determine whether a problem is a policy issue or a platform capability issue when something goes wrong. This is also a low-cost way to "expose the distinction upward."
The three-layer fallback order for policy resolution: approved explicit mode > session sandbox/mode event > deployment default mode
Which SandboxMode a tool call ultimately uses is not determined by a single source, but follows a clear fallback order. The official documentation summarizes it as three layers, arranged from highest to lowest priority:
| Priority | Source | Description |
|---|---|---|
| Highest | Approved explicit mode | The mode passed in during a one-time privilege escalation retry overrides the session policy |
| Next | The last sandbox/mode event of the session | Persisted with the session log and can be replayed and reconstructed |
| Fallback | Deployment default mode | Calls without an agent and sessions without a cwd use the configured root directory |
Let us break it down layer by layer. The highest priority is the approved explicit mode. The scenario is a "one-time privilege escalation retry": an operation was blocked under the default policy, and the user or upper layer decides to allow it this one time, so a mode is passed in. This mode directly overrides the session-level policy. Why should it be highest? Because it is an explicit human decision, the most contextual and best-informed authorization. But note that it is one-time and will not be written back to the session policy; the next identical call still returns to the default track. This is the same design philosophy as the previously mentioned approval "one-time decision."
Next is the session's last sandbox/mode event. Mode switches may occur during a session, and each switch produces a sandbox/mode event; the last event determines the effective mode of the current session. The key property is that this event is persisted with the session log and can be replayed and reconstructed. This means the mode is not some volatile variable in memory, but part of the session history. You can replay the log to reconstruct "which mode this session was in at that moment," which is extremely important for auditing, reproducing issues, and incident analysis. For advanced readers, there is an implicit requirement here: any code path that changes the mode should emit an event rather than secretly mutating a variable, otherwise the replay becomes distorted.
The lowest layer is the deployment default mode. When no agent is involved in the call, or when the session has no cwd, policy resolution falls back to the configured root directory and the default mode. This layer is the fallback, ensuring that any call has at least a definite starting point rather than "no policy available." Note that it also determines the value of workspaceRoot: sessions without a cwd use the configured root directory.
Looking at the three layers together, the logic is: single explicit authorization > current session state > global default. This order is intuitive, but there are several engineering pitfalls:
- Don't cache a one-off mode as session-level. Privilege escalation is safe precisely because it is not persistent. Once cached, it is equivalent to silently raising the session policy.
- Mode changes must emit events. If your plugin can switch modes at runtime, be sure to emit a sandbox/mode event, otherwise replay reconstruction will yield incorrect history.
- Calls without an agent should be aware that they go through the default layer. If your tool supports agent-less scenarios, confirm that the deployment default mode matches expectations, and don't assume the caller always carries a session.
- Pass the correct session when resolving. In the example, resolve({ session: exec.agent?.session }) naturally falls back to the fallback layer when the agent is missing; this ?. is not written casually—it corresponds to the real path of "a call without an agent."
The first code example already demonstrated the call form of resolve. Here is another snippet from a more operations-oriented perspective, showing how to use the resolved policy fields for logging and self-checks, so you can troubleshoot mode-origin issues in real deployments:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'policy-inspector'
export const inject = ['sandbox', 'sandboxPolicy']
export function apply(ctx: Context) {
// Resolve the policy once before tool execution, log it, and run self-checks.
function inspect(session: unknown) {
const policy = ctx.sandboxPolicy.resolve({ session })
// The mode and workspace root are the starting point for all subsequent boundary decisions.
console.log('[policy] mode=%s workspaceRoot=%s', policy.mode, policy.workspaceRoot)
if (policy.mode === 'danger-full-access') {
// Fully open, no sandbox; leave an explicit trace here for auditing.
console.warn('[policy] sandbox bypassed: danger-full-access')
} else {
// Restricted mode: confirm that confine is indeed used and that the backend is available.
try {
const confined = ctx.sandbox.confine(['bash', '-c', 'true'], {
mode: policy.mode,
workspaceRoot: policy.workspaceRoot,
})
console.log('[policy] enforcement=%s', confined.enforcement)
if (confined.enforcement !== 'full') {
// Treat partial as insufficient and surface it upward rather than silently continuing.
throw new Error(`partial enforcement rejected: ${confined.enforcement}`)
}
} catch (err) {
// When there is no backend, confine throws SANDBOX_UNAVAILABLE; passing through without isolation is not allowed.
console.error('[policy] confined probe failed', err)
throw err
}
}
return policy
}
// This inspect can be called at the tool execution entry point or in health checks.
void inspect
}
This code ties several core concepts of this section into an observable self-check: parsing the policy, determining whether to go through the sandbox, consuming enforcement, and letting errors surface when there is no backend. It does not replace a real sandbox call, but it lets you quickly confirm at deployment time whether "the mode parsed from the policy matches expectations" and "whether the backend is actually available."
Where workspaceRoot comes from: normalization of the immutable cwd and the actual directory semantics of symlink/..
We have repeatedly mentioned the workspace root above; now let us make its origin clear. An ordinary tool call derives workspaceRoot from the immutable cwd of the calling session. Note the word "immutable": the cwd is fixed at the session level and will not be changed back and forth by operations such as chdir while the process is running. This guarantees that all tool calls in the same session see a consistent boundary.
After being derived, the root must go through two-stage normalization:
- First normalize according to filesystem semantics: this step resolves symlinks and restores the path to the location it truly points to on the filesystem. Symlinks are "expanded" at this point.
- Then perform lexical normalization: handle relative path components such as .. and . and collapse the path into its simplest form.
This order is critical, and it also explains an easy pitfall: a cwd containing symlink/.. identifies the directory in which the process is actually running. If lexical normalization were done first, a/b/.. would be collapsed into a, but if b is a symlink pointing elsewhere, under filesystem semantics it may be a completely different location. Only by resolving symlinks according to filesystem semantics first and then collapsing .. do you get the directory where the process truly resides. This is why the order cannot be reversed.
The practical impact on plugin authors is:
- Do not assemble workspaceRoot yourself. It is the product of policy parsing, and assembling it yourself can easily be inconsistent with the sandbox backend's understanding, leading to contradictions such as "I think I am inside the workspace, but the backend thinks I am outside it."
- Do not make assumptions about cwd. If your tool logic depends on "the current directory is some known path," it is very likely to fail in a deployment environment containing symlinks. Trust the normalized root.
- Sessions without a cwd use the configured root directory. This is consistent with the last layer of policy fallback, meaning that the workspaceRoot your tool gets in a sessionless scenario comes from the deployment configuration, not from some implicit default.
- The root can be rebuilt during replay. Because the cwd is immutable and session events are persisted, workspaceRoot is reproducible during replay, which is very important for reproducing production issues.
Looking at workspaceRoot within the entire security chain of this section, it is the physical anchor of the concept of "boundary": SandboxMode determines what types of file operations are allowed, enforcement reports how much the backend actually achieved, and workspaceRoot defines where writes may occur. Only together do the three form a complete, auditable filesystem constraint.
At this point, the first half has finished covering the sandbox line: the interface shape of confine, the boundaries of the three modes, why danger-full-access does not enter the sandbox, the completeness semantics of enforcement, the three-layer fallback of the policy, and the normalization origin of workspaceRoot. But dangerous operations must pass through two gates, and the sandbox is only one of them. The other—approval—determines "whether this specific operation is allowed," handles authorization issues orthogonal to the sandbox, and has its own mechanism for fail-closed behavior by default. The next section will unfold the approval flow of ctx.approval, the implementation details of one-time decisions, and how the sandbox and approval work together to form a complete constraint system.
In the previous section, we broke down the division of labor between ctx.sandbox and ctx.approval, the tier semantics of SandboxMode, and the three-layer policy fallback order, and we also saw the existence of the two field values full and partial in enforcement. In this section, we will no longer stay at the conceptual level, but instead directly complete the picture of "how exactly this sandbox cage is implemented" from four directions: error codes, example code, decision branches, and an acceptance checklist. The central conclusion is just one sentence: under a restricted policy, silent unisolated passthrough is never legal, and the consumer must take explicit responsibility for judging partial enforcement.
SandboxUnavailableError and SANDBOX_UNAVAILABLE: fail-closed when no backend is available
The most core design premise of the sandbox service is fail-closed, not fail-open. The two differ by only one word at the linguistic level, but in security semantics they are worlds apart: fail-open means "if uncertain, let it through," while fail-closed means "if uncertain, block it." When there is no usable sandbox backend in the host environment at all, the system must not pretend that isolation is already in effect and then directly spawn the original argv—doing so is equivalent to secretly executing an unisolated command while the policy declares read-only or workspace-write, making the cage nothing but an empty shell.
For this reason, ctx.sandbox.confine(argv, policy) does not return a "degraded but usable" result when it cannot find any usable backend; instead, it directly throws SandboxUnavailableError. The error code carried by this error object is SANDBOX_UNAVAILABLE. Designing it to throw an exception rather than return some kind of sentinel value is an engineering consideration: the caller cannot quietly skip isolation by "not checking the return value," because it must either write try/catch or let the error bubble up. Any code that tries to ignore it will, at best, result in a failed tool call, and at worst be captured and logged by the upper-layer unified error handling path, but under no circumstances will it become a "successful unisolated execution."
What must be especially emphasized here is the sentence that appears repeatedly in the source material: under a restricted policy, silent unisolated passthrough is never legal. This sentence has two layers of meaning. The first layer is behavioral: under read-only or workspace-write, the consumer must never, just because "the sandbox is unavailable," decide on its own to switch to directly spawning the original argv, even if that makes the tool appear to "run successfully." The second layer is architectural: the restricted execution path must be forced to go through ctx.sandbox.confine, which is the only legal entry point; only danger-full-access, a mode that itself declares that isolation is abandoned, is allowed to bypass ctx.sandbox and spawn directly. In other words, "bypassing isolation" must be an explicit decision written into the policy, not an implicit accident caused by a missing backend.
From the perspective of error-handling practice, consumers usually have three reasonable reactions to SandboxUnavailableError:
- Throw it upward as-is: let the upper-layer tool framework turn it into a failed call, keeping the SANDBOX_UNAVAILABLE error code in the error message so that operations can locate "this machine does not have the backend installed properly."
- Convert it into a structured error return: if the tool framework requires execute to return a structured result rather than throw an exception, put the error code and message into the isError structure, and still do not execute any command.
- Explicitly switch to full-access: only when the business semantics truly allow it and the approval chain has agreed can the mode be changed to danger-full-access and then go through direct spawn. Note that the semantics have changed at this point: it is no longer "sandbox failure fallback," but "approved unisolated execution."
A pattern that should absolutely never be recommended is try { confine() } catch { spawn(argv) }. On the surface, this code makes the tool "more robust," but in reality it quietly turns fail-closed into fail-open—the most typical and most dangerous anti-pattern in sandbox mechanisms. If a similar structure appears in your codebase, it should be treated as a security defect rather than a fault-tolerance enhancement.
Code walkthrough: how the sandboxed_echo tool resolves policy, wraps argv, and then spawns
Below, using the my-plugins/sandbox-demo/src/index.ts example from the source material, we break down the chain of "resolve policy → wrap argv → spawn" section by section. To make the code directly pasteable into a project and runnable, I have filled in the omitted spawn output-collection part, keeping the rest of the structure consistent with the source material.
// File path: my-plugins/sandbox-demo/src/index.ts
// Demonstrates a sandboxed consumer: first resolve policy, then let ctx.sandbox wrap argv.
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { spawn } from 'node:child_process'
export const name = 'sandbox-demo'
// Declare the three services this plugin depends on: tool registration, sandbox wrapping, policy resolution.
export const inject = ['tools', 'sandbox', 'sandboxPolicy']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'sandboxed_echo',
description: 'Run echo inside the sandbox. The runoob demo command.',
parameters: {
text: { type: 'string', required: true, description: 'Text to echo' },
},
output: { schema: { type: 'string' } },
async execute(args, exec) {
// 1. Resolve the full policy for this call: the session cwd is the workspace boundary.
const policy = ctx.sandboxPolicy.resolve({ session: exec.agent?.session })
// 2. The consumer hands over the exact argv (program plus arguments), not a shell string.
const argv = ['bash', '-c', `echo ${JSON.stringify(args.text)}`]
// 3. danger-full-access spawns directly; everything else goes through sandbox wrapping.
if (policy.mode === 'danger-full-access') {
const result = await runAndCollect(argv)
return result.stdout.trim()
}
// 4. Restricted mode: confine returns the replaced argv; without a backend it throws SANDBOX_UNAVAILABLE.
const confined = ctx.sandbox.confine(argv, {
mode: policy.mode,
workspaceRoot: policy.workspaceRoot,
})
// 5. Decide whether to accept this execution based on confined.enforcement.
if (confined.enforcement === 'partial' && policy.mode !== 'read-only') {
return {
isError: true,
error: { message: 'partial enforcement is not acceptable for runoob demo' },
}
}
const result = await runAndCollect(confined.argv)
return `confined echo ${args.text} (enforcement: ${confined.enforcement})`
},
}))
}
// Small utility: spawn an argv and collect stdout/stderr, uniformly decoded as UTF-8.
function runAndCollect(argv: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
return new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8') })
child.on('error', reject)
child.on('close', (code) => resolve({ stdout, stderr, code }))
})
}
Let's go through this paragraph by paragraph. First, the inject array: ['tools', 'sandbox', 'sandboxPolicy']. All three are indispensable—tools is used to register tools, sandbox is used for argv wrapping, and sandboxPolicy is used to resolve the mode and workspace root that this particular invocation should use. Declaring dependencies in inject means that Cordis will check whether these services exist during the application startup phase; if a service hasn't been installed, the plugin will fail to start outright, rather than discovering at runtime that ctx.sandbox is undefined. This approach of "declaring dependencies up front" is essentially a form of fail-closed: better to not start at all than to get halfway through and discover the cage wasn't installed properly.
The second step is ctx.sandboxPolicy.resolve({ session: exec.agent?.session }). Note that the argument in the parentheses is { session }, not { mode }, and not a bare session object. Why pass session? Because policy resolution needs to determine the mode by following the three-tier fallback order described in the source material: highest priority is the "approved explicit mode," i.e., the mode explicitly passed in during a one-off privilege-escalation retry, which overrides the session policy; next is the "session's last sandbox/mode event," which is persisted alongside the session log and can therefore be replayed and reconstructed; last is the fallback to the "deployment default mode." The policy object returned by resolve contains at least the two fields mode and workspaceRoot. A normal tool invocation doesn't fabricate workspaceRoot on its own; instead, it derives it from the invoking session's immutable cwd: this cwd is first normalized according to filesystem semantics, then lexically normalized once more, so even if the path contains symlinks or .., the final result is the directory the process actually runs in, not the directory that merely looks that way at the string level.
The third step is the question of argv form. The example clearly shows:
const argv = ['bash', '-c', `echo ${JSON.stringify(args.text)}`]
Note that this is an array, not a single shell string. The source material specifically emphasizes this point: the process sandbox model is "the consumer hands over the exact argv, and the backend wraps it according to the file-effect policy," and the key point is exact argv rather than a shell string. If a consumer naturally works in shell form, it must also explicitly hand over something like ['bash', '-c', command], writing "I want to launch a shell" plainly into the argv, rather than letting the sandbox guess how a string should be split. The benefit of doing this is twofold: on one hand, the backend can accurately know the program name and argument boundaries that will be executed; on the other hand, quotes, spaces, and redirection symbols appearing in the arguments won't be interpreted a second time, avoiding injection-type issues.
The fourth step is branch handling. In danger-full-access mode, the consumer directly spawns the raw argv, without calling ctx.sandbox at all. This is not a "fallback after the sandbox fails," but rather this mode itself is an explicit declaration of "bypassing isolation." The source material states it bluntly: only the read-only and workspace-write modes are sent to the provider; danger-full-access doesn't enter the sandbox at all. This yields a rather elegant security property: restricted execution necessarily reaches ctx.sandbox, and execution in any restricted mode cannot bypass this entry point; conversely, once a code path doesn't go through ctx.sandbox, it must be in danger-full-access, and therefore must be an explicitly authorized unisolated execution. These two paths are mutually exclusive and both auditable.
Step five: the restricted branch calls ctx.sandbox.confine(argv, { mode, workspaceRoot }). Its return type is ConfinedArgv, and according to the source material, this structure contains "the replaced argv plus the backend's enforcement facts." In other words, what the consumer receives is not the original argv, but the argv processed by the backend according to its file-effect policy; it also carries an enforcement field that tells the caller how much isolation the backend actually achieved. After obtaining the result, the consumer uses confined.argv to spawn, rather than the original argv. There is a common pitfall here: if the consumer habitually spawns the original argv, then even if confine was called, the isolation does not actually take effect. The correct approach is to always spawn the argv returned by confine.
How the consumer makes decisions based on confined.enforcement: partial errors out directly under non-read-only
This conditional in the source material is the part of the entire example most worth reading over and over:
if (confined.enforcement === 'partial' && policy.mode !== 'read-only') {
return { isError: true, error: { message: 'partial enforcement is not acceptable for runoob demo' } }
}
The policy it expresses is: partial is acceptable if and only if the mode is read-only; under workspace-write or stronger modes, partial is always treated as unacceptable and isError is returned directly. Why is it designed this way? Because the semantics of the enforcement field are "the completeness of enforcement actually achieved by the backend," and it has only two values: 'full' means the backend controlled all the file effects promised by that mode, while 'partial' means it controlled only a subset. For read-only, the effects it promises are mainly "reject writes and allow only the necessary data sinks (such as /dev/null)"; even if the backend can only achieve a subset, the remaining risk surface is relatively limited, and usually such subset differences do not introduce write capability. But for workspace-write, what it promises is "allow writes under the workspace root and the temporary areas promised by the backend." Once it is partial, it means the workspace boundary may not be solid, and some write paths that should have been blocked may leak through; treating partial as full at this point is self-deception.
Here is a clear behavioral guideline for the consumer: a consumer that requires an absolute boundary must treat partial as "not enough" and either reject it or surface the distinction upward. That is exactly what the source material means, and the code in the example is one concrete implementation of it—it chooses "reject," turning partial into a failed tool call; another compliant approach is to "surface it upward," for example by carrying the enforcement value as-is into the return structure or logs, so that the upper-level approver can see that "this execution only obtained partial isolation" and let a human decide whether to allow it. Whichever is chosen, the core point is not to let partial quietly masquerade as full.
The table below compares the three tiers of SandboxMode side by side across three dimensions—"whether it enters the sandbox," "the promised file effects," and "whether partial is acceptable"—for easy reference when designing a consumer:
| SandboxMode | Whether it enters ctx.sandbox | Promised filesystem effect | Recommended handling when enforcement = partial |
|---|---|---|---|
| read-only | Yes | Only allows the necessary data sinks (such as /dev/null), rejects writes | Acceptable, but it is recommended to log partial for later review |
| workspace-write | Yes | Allows writes under the workspace root and the temporary area promised by the backend | Not acceptable; execution should be rejected or the discrepancy should be surfaced |
| danger-full-access | No, directly spawns the raw argv | No isolation promise, bypasses the file effect policy | Not applicable (confine will not be called) |
One easily confused point needs to be clarified again: SandboxMode only governs filesystem effects; it does not cover network or process visibility. In other words, even if you choose read-only, do not mistakenly assume that this mode will also control network access or process list visibility—it does not promise those. If your threat model includes "the Agent secretly makes network requests" or "the Agent probes other processes on the host," then you need a separate mechanism outside the sandbox, rather than expecting that changing a mode will solve everything. Clearly stating the capability boundaries of a mode is far more responsible than describing the mode as "fully secure."
How the defineTool output contract works with the sandbox: output.schema, execute(args, exec), and exec.agent?.session
The tool definition side looks like just a few field declarations, but it is directly coupled with sandbox decisions, so it is worth discussing separately. Returning to the tool definition in the example:
ctx.tools.register(defineTool({
name: 'sandboxed_echo',
description: 'Run echo inside the sandbox. The runoob demo command.',
parameters: {
text: { type: 'string', required: true, description: 'Text to echo' },
},
output: { schema: { type: 'string' } },
async execute(args, exec) { /* ... */ },
}))
First look at required in parameters. In the example material, the text field is marked with required: true, meaning this parameter is mandatory; it is also paired with description: 'Text to echo'. Do not underestimate these two attributes, required and description; they serve two functions at the same time: first, they provide the contract explanation for the model, which needs to know what this parameter is for and whether it must be provided; second, they let the tool framework perform input validation, so calls missing required fields are intercepted before entering execute. For sandboxed_echo, making text required avoids edge cases such as "empty input causing the command to have no arguments."
Now look at output.schema. The example writes { schema: { type: 'string' } }, which requires the return value of this tool to be a string. Note that this is paired with the return inside execute: on the normal path it returns a string (for example confined echo xxx (enforcement: full)), whereas in the partial branch we instead return an object { isError: true, error: { message: ... } }. This shows that the tool result type usually follows two paths: "the success value conforms to output.schema, or it goes through a unified structured error channel." The design recommendation is: put sandbox-related diagnostic information into the error structure as much as possible, rather than forcing it into the success value defined by output.schema. Because once you mix fields like enforcement into the success string, any downstream consumer that depends on that output.schema may be disturbed by this extra text.
The most critical coupling point is the second parameter of execute(args, exec). The first step of the example is:
const policy = ctx.sandboxPolicy.resolve({ session: exec.agent?.session })
Why get the session from exec.agent?.session rather than somewhere else? Because the fallback chain of policy resolution needs session context: the session's last sandbox/mode event is persisted with the session log and can be replayed and reconstructed; the explicit mode passed in during a one-off privilege escalation retry has the highest priority; only when neither exists does it fall back to the deployment default mode. Without a session, this resolution cannot be completed. And exec.agent?.session uses optional chaining, meaning the session may be absent.
There is an engineering detail to note here: the material mentions in the fallback order that "calls without an agent" and "sessions without a cwd" use the configured root directory. That is, when exec.agent?.session is undefined, or the session has no cwd, policy resolution falls back to the deployment default mode and uses the configured root directory as workspaceRoot. For the consumer, this raises a design question: do you accept this fallback, or do you treat "missing session" as unsafe to execute and reject it? If the tool's semantics involve write operations, it is recommended to explicitly require the session to exist; if it is only a read-only probe, accepting the fallback to the deployment default mode is fine. The basis for the judgment is still your threat model, not whether the code is easier to write.
Connecting the above three points, the "cooperation between tool definition and sandbox" can be summarized in the following field-level comparison table:
| Field / Parameter | Purpose | Relationship to sandbox decisions |
|---|---|---|
| parameters.text.required | Declares the input parameter as required; the framework validates it before entering execute | Avoids abnormal command shapes caused by empty input, indirectly reducing unexpected behavior in restricted mode |
| parameters.text.description | Shows the parameter semantics to the model | Helps the model construct argv content that matches expectations |
| output.schema | Constrains the type of the success return value (here, string) | Sandbox diagnostic information should go through the error structure and not pollute the success value |
| execute second parameter exec | Carries runtime context | exec.agent?.session is the input to the policy resolution fallback chain |
Division of Responsibilities Between Sandbox and Approval: Who Defines Which Files a Process Can Touch, and Who Decides Whether to Let This One Through
Many developers new to this mechanism conflate the sandbox and approval, or even treat approval as a fallback for the sandbox. The source material draws a very clear distinction between these two services, and it's worth restating its meaning verbatim: the sandbox fences off "which files a process can touch," while approval hands "whether to let this operation through" to the responder to decide. In other words, the sandbox addresses "what boundary the command runs within," while approval addresses "whether this specific operation is permitted." One is a spatial constraint, the other an event-based decision.
From a temporal perspective, the two also intervene at different points. The sandbox acts at the very moment a command is about to execute, wrapping argv according to the file-effect policy—this happens before every confined execution, passively and inevitably. Approval, on the other hand, occurs when a risky operation is proposed, handing it to the responder for a one-time decision: allow or deny this time. The source material calls it a "one-time decision," and this term matters—it means the approval's conclusion does not constitute a blanket authorization for subsequent similar operations; the next operation still requires a fresh judgment.
So why have both mechanisms at once? Because they guard against different forms of risk. The sandbox guards against "once the command runs, will it cross the line and touch files it shouldn't"—even if the command itself has been approved, the sandbox must still ensure its range of action is constrained once it runs. Approval guards against "this operation should never have been allowed in the first place"—even if the sandbox can narrow the risk, certain operations should still be decided by a human. Only by stacking the two can you constrain both the execution boundary and the willingness to execute.
There's one more commonality that must be emphasized: both default to fail-closed. The sandbox's fail-closed behavior manifests as the SandboxUnavailableError and SANDBOX_UNAVAILABLE we discussed earlier; approval's fail-closed behavior manifests as "no execution without explicit clearance." Stacking these two fail-closed behaviors together yields the core security property of the entire mechanism: if the Agent wants to do something risky, it must both pass through the gate of approval and operate only within the boundary given by the sandbox; if any link fails to obtain a definite "yes," execution will not happen.
Below is a side-by-side comparison of the two services' responsibilities, trigger timing, and failure modes, to help you quickly locate "which side this requirement should fall on" when writing new tools:
| Dimension | ctx.sandbox | ctx.approval |
|---|---|---|
| Problem solved | What boundary the command runs within (which files the process can touch) | Whether this specific operation is permitted |
| Decision maker | Backend automatically wraps argv according to the file-effect policy | Responder makes a one-time decision |
| Trigger timing | Before every spawn of a confined execution | When a risky operation is proposed |
| Fail-closed behavior | Throws SandboxUnavailableError (SANDBOX_UNAVAILABLE) | No execution without explicit clearance |
| Typical misuse | Spawn the original argv directly after confine fails | Treating a single clearance as blanket authorization for similar operations |
Troubleshooting Checklist in Restricted Mode: A Step-by-Step Verification Path from policy.mode to enforcement
When a sandboxed tool behaves abnormally in a real environment—for example, a command that should write to the workspace gets rejected, or an operation that should be rejected somehow passes—the most effective approach is not to haphazardly change code, but to verify items one by one along a fixed path. The following sequence aligns with the various fields and error codes in the source material and can be used directly as a troubleshooting checklist.
- Confirm whether policy.mode falls into a restricted tier. The first step is to check whether
policy.modeisread-only,workspace-write, ordanger-full-access. Because if it is already danger-full-access, then the restricted execution path will not be triggered at all, and your troubleshooting direction should shift to "why was this session resolved as full-access" rather than continuing to investigate the sandbox. A common cause is that an explicit privilege-escalation retry in the session log planted a high-priority mode, or the deployment default mode itself is full-access. - Confirm whether confine throws SANDBOX_UNAVAILABLE. If the tool error contains SandboxUnavailableError or the error code SANDBOX_UNAVAILABLE, it means the host environment had no available sandbox backend at that time. The correct handling here is to install or repair the backend, or to explicitly switch to danger-full-access when business allows and after approval; absolutely do not add a catch block and then directly spawn the original argv.
- Confirm whether confined.enforcement is full. If execution runs but the results don't match expectations, check the enforcement value. The two partial cases given in the source material are: older Landlock ABI, and the Everyone and hard link boundary of the Windows ACL runner. If your tool relies on "absolute boundaries," then upon encountering partial you should refuse execution as shown in the example, or surface this distinction upward.
- Confirm whether the workspaceRoot normalization result matches expectations. workspaceRoot is derived from the session's immutable cwd: it is first normalized according to filesystem semantics, then lexically normalized, so a cwd containing symlinks or
..will correctly identify the directory where the process actually runs. If you find that a command was allowed to write to a location it shouldn't, the first thing to do is print out the final workspaceRoot and see whether it is the directory you had in mind—in symlink scenarios it is very easy to encounter the situation where something "looks like it's in the workspace but after normalization is actually elsewhere."
Compress this checklist into one sentence: first check the mode, then check backend availability, then check enforcement, and finally check boundary resolution. If any one of the four steps locates the problem, there is no need to continue checking further. Conversely, if all four steps pass and the problem persists, then the problem is very likely not on the sandbox side, and you should turn to the approval side or the tool's own argv construction logic.
Here are two high-frequency engineering pitfalls and their solutions. The first pitfall is "argv contains shell syntax but no shell is explicitly started": someone directly writes ['echo', args.text], but puts redirection symbols in text, so they are not interpreted at all, and the command behavior doesn't match expectations. According to the principle in the source material, a shell-form consumer must explicitly hand over ['bash', '-c', command], so either honestly split it into an argv array, or explicitly state that bash should be started. The second pitfall is "after obtaining ConfinedArgv, still spawning the original argv": the isolation is not actually in effect, but the code looks completely correct. The solution is to uniformly encapsulate a runConfined(confined) helper function, so that all restricted execution must pass ConfinedArgv, blocking misuse at the type level.
Latest Practice as of September 2026: Treat partial enforcement as a first-class citizen
Fast-forward to September 2026, and the most important consensus that this mechanism has evolved into in practice is that enforcement should no longer be treated as an incidental field that can be ignored; instead, partial enforcement must be treated as a first-class citizen. "First-class citizen" means it has its own type slot, its own branch handling, and its own logging and acceptance criteria, rather than being quietly let through by being stuffed into some default case.
On the consumer side specifically, there are two implementation requirements. First, explicitly distinguish full from partial. Do not write something like if (confined.enforcement !== 'full') { /* do nothing */ }; instead, handle the two values separately so that each value has a clear destination: full executes normally, and partial is handled according to the second point below. The example if (confined.enforcement === 'partial' && policy.mode !== 'read-only') is a typical explicit branch—it reads with no ambiguity: partial is tolerated only under read-only.
Second, reject partial or surface the distinction upward. The original wording of the material is that "a consumer that requires an absolute boundary must treat partial as 'not enough' and either reject it or surface the distinction upward." This sentence gives two compliant options. Choosing "reject" means directly returning isError and letting this call fail; choosing "surface upward" means carrying the enforcement value as-is to the upper layer so that the approver or operations staff can see that "this execution only obtained partial isolation." Either is acceptable; the key is that partial must never silently degrade into full.
At the acceptance level, the practice as of September 2026 has already incorporated two specific partial situations into routine acceptance items rather than treating them as rare edge cases: the Everyone and hard link boundary of the Windows ACL runner, and older Landlock ABIs. This means that in CI or release checks, you should specifically run the sandboxing tool against these two types of environments to confirm that consumer behavior under partial scenarios meets expectations—either cleanly rejecting or clearly surfacing. Leaving these two scenarios until they are first encountered in production is the most typical acceptance oversight.
Finally, the combined semantics of read-only and partial should be reiterated. According to the example's implementation, partial is acceptable under read-only. But this does not mean that read-only requires no concern for enforcement at all—it is still recommended to log partial. The reason is that read-only only governs filesystem effects and does not itself include network and process visibility; if you happen to be in a network-sensitive environment, the partial record under read-only can help you determine whether a given execution landed on an unexpected backend, thereby providing a basis for subsequent stricter policy adjustments.
Summary and Best Practices
Compress the entire article—including the sandbox decision and approval flow from the previous section, as well as the error codes, code walkthrough, enforcement decision, and September 2026 practice from this section—into a checklist you can directly follow:
- Confined execution must go through ctx.sandbox.confine. The two modes read-only and workspace-write necessarily reach the sandbox entry point, and silent unconfined passthrough under a confined policy is never legal. Only danger-full-access is allowed to bypass ctx.sandbox and directly spawn the raw argv.
- Treat SandboxUnavailableError and SANDBOX_UNAVAILABLE as hard failures. When no backend is available, confine throws this error; never write code such as "catch and then directly spawn the raw argv" that turns fail-closed into fail-open.
- Always hand over an exact argv, not a shell string; when a shell is needed, explicitly write
['bash', '-c', command]. And always spawn theconfined.argvreturned by confine, not the original array. - Use ctx.sandboxPolicy.resolve({ session }) to resolve the complete policy for this run, relying on the sandbox/mode events persisted in the session log and the fallback chain of the deployment default mode; get the session context from exec.agent?.session, and note that calls without an agent and sessions without a cwd fall back to the deployment default mode and the configuration root directory.
- Treat partial as a first-class citizen. Handle it with explicit branches such as
if (confined.enforcement === 'partial' && policy.mode !== 'read-only'); any partial outside read-only must be rejected or surfaced upward, and must never be disguised as full. - Remember that SandboxMode only governs filesystem effects, and does not promise network isolation or process visibility; network- and process-related threats require separately designed mechanisms outside the sandbox.
- Align the responsibility boundary between sandboxing and approval: the sandbox defines which files a process can touch, approval decides whether this particular run is allowed, and both default to fail-closed; an approval grant is a one-time decision and does not constitute general authorization for subsequent similar operations.
- Incorporate partial scenarios into routine acceptance: older Landlock ABIs, the Everyone and hard link boundary of the Windows ACL runner, and similar cases should all have corresponding test cases to verify the consumer's rejection or surfacing behavior under partial.
- When troubleshooting, follow a fixed order: whether policy.mode falls into a confined tier → whether confine throws SANDBOX_UNAVAILABLE → whether confined.enforcement is full → whether the workspaceRoot normalization result meets expectations.
- Keep tool definitions paired with the sandbox: parameters.required and description help the model construct correct input, output.schema constrains success values, and sandbox diagnostic information goes through structured errors rather than polluting successful return values.
If this article were to be condensed into a single sentence you could paste directly into a code review, it would be this: make every restricted execution necessarily pass through the sandbox entry point, make every instance of insufficient isolation explicitly visible, and leave an approval trail for every bypass of isolation. Only when these three things are achieved can an Agent's dangerous actions truly be locked in a cage.