DeepSeek Harness runner hang and silent sandbox denials
Sandbox problems on Windows have two extreme manifestations: one is "everything was said but nothing ran" — a tool call hangs forever in the running state, the interface keeps spinning, and it never returns even after two hours; the other is "it ran and was denied, but nobody told the model", so the model sees no denial marker and finds another spelling to bypass the sandbox. The former comes from the sandbox child process's start handshake having no timeout at all: the runner has forked and IPC is connected, but the start frame is lost in the pipe, so the two sides wait on each other forever (the community's source review confirms setTimeout appears 0 times in the entire runner.js file); the latter comes from the denial classification's match surface being too narrow: the sandbox only matches the denial signature against stderr, while some runtimes print EACCES on stdout, so sandbox.denied is never set and neither the [sandbox: file access denied …] marker nor the escalation hint ever appears (#9515, #9497). This article gives "how to judge" and "how to fix / how to work around" respectively; both can be handled without restarting all of DSH.
DeepSeek Harness: tool calls hang forever, the start frame is lost with no timeout
In one line: the essence of this hang is a "lost start handshake", not a slow command — because from the parent to the runner to the tool layer, no layer ever put a timeout on it. The symptom is: the GUI keeps showing running (up to two hours or more), the session has neither tool/result nor an error, and only restarting the app recovers it (#9515).
DSH plugin: mechanism, why all three timeout layers miss it
According to the community's line-by-line source review, there are three places missing a timeout, layer by layer:
- The runner itself has no timeout:
dsh-subprocess-local/lib/runner.jsenters a wait while awaiting the IPCstartmessage, with nosetTimeoutanywhere in the file, so a loststartframe means both sides wait forever; - Session stats have no watchdog:
sessionStats.pendingCallsis only a count and takes part in no timeout decision; - The tool layer is a cooperative timeout:
tools/execute's timeout wrapper onlyabortsexec.signalthen continues toawait next(), and cannot race out a promise that never settles; moreover it arms only when the tool declarestimeoutMs, and on the scene no tool ever declared it (#9515).
Connecting the three makes it clear: a "lost frame" is a low-probability event, but once it happens no layer along the chain cuts it off — so it hangs indefinitely until you restart.
DeepSeek Harness: three-step judgment, this is a zombie runner
The test is three things holding at once — the process is alive, uses almost no CPU, and has no child processes. Three steps:
- See whether the runner process is still there. In a non-confined PowerShell list the candidates:
Get-Process node -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like "*dsh-subprocess-local*" } |
Select-Object Id, ProcessName, StartTime, CPU
Expected: you see a process whose StartTime is clearly earlier than now and whose CPU value is very small.
- See whether it has child processes. Run:
$runner = <the Id from the previous step>
Get-CimInstance Win32_Process -Filter "ParentProcessId=$runner" | Select-Object ProcessId, CommandLine
# -> expected no output: the runner started, but the target command was never launched
- See whether it is "alive but not moving". Sample
CPUtwice 30 seconds apart; if the two are nearly identical and under 1 second, whileStartTimeis already more than 3 minutes ago, it can be judged a zombie runner (#9515).
Cross-check: at this point the session should have no tool/result event for that tool — if a result already exists, this is not the family, so look elsewhere (a child that fails to start at loader init with 0xC0000142 is another family, covered in DSH plugin: Windows subprocess fails with 0xC0000142).
DSH plugin: handling, kill the zombie and add a non-cooperative backstop
Recovery is quick; preventing recurrence relies on a non-cooperative timeout. Two steps:
- End the zombie runner (the fastest recovery). According to tests in the thread, after killing the process it self-heals in about 2 seconds and the tool call returns immediately, without restarting DSH:
Stop-Process -Id $runner -Force
# -> expected: the stuck tool call returns as a failure within about 2 seconds and the session is usable again
- Install a non-cooperative deadline backstop. The community plugin
@argszero/cordis-plugin-tool-deadline-guardhookstools/pre-executeand force-ends a call with a wall-clock bound independent of the underlying promise — exactly the layer that "cooperative timeout" lacks:
npm i @argszero/cordis-plugin-tool-deadline-guard
Mount it in the profile's patch layer:
- insert:
- id: tool-deadline-guard
name: '@argszero/cordis-plugin-tool-deadline-guard'
- Verify: after reopening, run a command that will time out (for example
pwsh -Command "Start-Sleep 600"); expect a timeout error within the plugin's wall-clock bound, rather than infinite spinning.
Boundary reminder: this family and "sandbox path hang" are two different things — the latter only appears under
workspace-writeand is fine once you switch todanger-full-access; whereas a zombie runner reproduces even in a no-sandbox scenario. So check "does switching to full access recover it" first; that immediately separates the two (#9515).
DeepSeek Harness: sandbox denials are not reported because the classifier misses stdout
In one line: the sandbox really did deny this write, but "denied" is recognized in only one place — the signature match on stderr; when the denial code appears on stdout, neither the marker nor the escalation hint is rendered, so the model sees "the command failed" rather than "you were blocked by policy". The consequence is more serious than it looks: the model does not know this is a policy boundary and switches to another spelling to bypass it (the example in the thread is overriding XDG_CONFIG_HOME to write config inside the workspace), so "the security control is silently dissolved"; another member of this family is failure being silently dropped (pipe stdio discarding output as 0 bytes, CIM returning silent defaults, and so on), covered in DeepSeek Harness command failures (#9497).
DSH plugin: mechanism, three conditions unmet at once so the marker never appears
According to the community's source review, the chain controlling the marker rendering has three gates:
- Classification only recognizes stderr:
classifyDenial/matchesSignaturematchDENIAL_SIGNATURESonly against the command's stderr; - Some tools print the denial code on stdout: for example some runtimes' telemetry writes
EACCESto stdout, leaving only the launcher's info lines on stderr, so not a single signature matches; - The render layer has another gate: the marker and hint are produced by the render layer, gated on
result.sandbox?.denied; the escalation hint also needs the composition to provide a non-emptyescalationModes. AndDENIAL_SIGNATURESis a module-private constant, not exported, so a plugin cannot directly reuse it to add the judgment (#9497).
DeepSeek Harness: three-step judgment on whether this really was a sandbox denial
- See whether the path is really outside the workspace. Check the target path being written in the failing command and confirm it is not inside the current workspace (the workspace path is visible under Settings → Workspace).
- See the exit code and the result text. Expect "a non-zero exit code" + "the result has no
[sandbox: file access denied …]marker" + "no escalation hint". "Really denied, but with no sandbox marker" is this family's fingerprint (#9497). - Manually verify which stream the denial happens on. In the confined session run:
pwsh -Command "echo probe > $env:USERPROFILE\probe-denied.txt"
# expected: the file is not created and the command exits non-zero
# Change the same content to write an in-workspace path; expected to succeed — this control rules out "the command was written wrong"
DSH plugin: handling, add the marker in post-execute
The core idea is not to change the sandbox, but to set sandbox.denied explicitly true after the result comes out, so the upper layer renders the standard marker itself. Three steps:
- Install the community plugin
@argszero/dsh-stdout-denial-guard(it setssandbox.denied = trueintools/post-execute, and the hint is delivered viaadditionalContexts):
npm i @argszero/dsh-stdout-denial-guard
- Mount it in the profile's patch layer:
- insert:
- id: stdout-denial-guard
name: '@argszero/dsh-stdout-denial-guard'
- Verify: after reopening the session, run the denied command from step 2 again; expect the
[sandbox: file access denied …]marker to appear in the result, and the model to receive a hint saying "this is a policy denial and you can request an escalation" instead of routing around it.
A boundary you must know: the plugin only covers the model side — it tells the model "you were denied" but does not change the sandbox's actual behavior. And for paths assembled on a shell command line it only does a best-effort scan and cannot be treated as a security boundary (#9497).
DSH plugin troubleshooting notes
The two symptoms look unrelated, but both belong to "the sandbox's behavior was not correctly expressed to the upper layer" — one is a missing timeout, the other a missing marker. The recommended order is to separate the two first, then dig in. Seven points:
- Do one separation first: switching to
danger-full-accessmakes the problem disappear → leans toward the sandbox path; does not disappear → leans toward the runner/process layer (#9515). - When it hangs, do not rush to restart DSH: first run the three-step judgment to confirm a zombie runner, then kill just that one process — restarting interrupts all sessions (#9515).
- "Runner alive ≥3 minutes + CPU < 1 second + no child processes" directly means zombie; no need to wait longer (#9515).
- Cooperative timeouts are unreliable: for any "it timed out but still does not return" phenomenon, suspect first that the underlying promise never settles, not that the timeout value is too small (#9515).
2>&1merged streams do not save the denial marker: the denial code was never on stderr to begin with, and after merging it is still not a match target (#9497).- When the model "cleverly changes its approach", check the marker first: the model routing around the sandbox is often because it was not told it was denied, not because it is fighting the policy (#9497).
- State the fix status honestly: both are unfixed — the timeout patch and the denial classification are still in community-plugin form, and the plugin layer can only "backstop" (add a deadline, add a marker), not fix the root cause for the core.
When troubleshooting cross-process problems like these, besides watching process state, it is worth centralizing install/uninstall-type operations in one place to reduce variables. If you want plugin install/uninstall, updates and system logs all in one panel, use DSH Plugin Hub instead of typing commands everywhere — it gathers plugin install/uninstall, update confirmation, diagnostics and logs together, helping you rule out the "plugin layer" variable first.

Source: Discussion #9515, Discussion #9497.
FAQ
In DeepSeek Harness a tool call staying 'running' with no result even after two hours is most likely the sandbox child process's start frame being lost. According to the community's source review, subprocess-local's runner sets no timeout while waiting for the IPC start message, so once that frame is lost in the pipe, the parent and runner wait on each other forever; and the upper tools/execute timeout wrapper is cooperative (armed only when the tool itself declares timeoutMs), so no layer cuts it off. The fastest recovery is to end that zombie runner process (measured at about 2 seconds to self-heal), with no need to restart all of DSH.
Under DeepSeek Harness workspace-write a denied command showing no [sandbox: file access denied] marker happens because the denial signature is only matched against stderr, while some runtimes print this EACCES on stdout, leaving only the launcher's info lines on stderr, so the classifier matches nothing and neither the marker nor the escalation hint is rendered. A workable approach is to set sandbox.denied explicitly to true in a tools/post-execute hook so the upper layer renders the standard marker, and add an escalation hint via additionalContexts; the community already has @argszero/dsh-stdout-denial-guard doing this, but it only covers the model side and does not change the sandbox's behavior.
To tell whether a DeepSeek Harness hang is a zombie runner, look at three things: whether the child process already exists (the runner has forked and IPC is connected), whether it has shown no CPU time and no child processes for a long time, and whether a tool/result event for the corresponding tool has appeared in the session. Per the test given in the thread, a runner alive for more than 3 minutes with its own CPU time under 1 second and no child processes can be judged a zombie runner; at that point the tool call will definitely never return, so just end that process to recover.
In DeepSeek Harness, merging streams with 2>&1 hits the same problem, because the denial code was originally printed on stdout and after merging it is still not a match target on the stderr path, so the classifier still cannot match DENIAL_SIGNATURES. The test is simple: if the command really was denied by the sandbox (non-zero exit code and the path really outside the workspace) but the result carries no sandbox marker, then it belongs to this section's family and you must fix it by adding the marker in post-execute.
Related Terms
- runner (sandbox child process)
- The runner is the auxiliary child process DeepSeek Harness uses to carry confined commands under the Windows ACL sandbox; subprocess-local starts it with a restricted token, and the parent hands it start and execute instructions over IPC. Its start handshake being lost with nobody timing it out is the direct cause of this family's 'tool call hangs forever'.— https://github.com/deepseek-ai/deepseek-harness/discussions/9515
- DENIAL_SIGNATURES
- DENIAL_SIGNATURES is the set of signatures the sandbox uses to recognize that 'this failure really was a sandbox denial'; on a match it marks the result with sandbox.denied and attaches an escalation hint. It is a module-private constant matched only against stderr, so a denial message appearing on stdout is not recognized.— https://github.com/deepseek-ai/deepseek-harness/discussions/9497
- cooperative timeout
- A cooperative timeout is a timeout implementation that relies on the callee actively checking a cancellation signal, rather than the outer layer forcibly racing the promise. DeepSeek Harness's tools/execute timeout wrapper is this kind: it only aborts exec.signal then continues to await next(), so when the underlying promise never settles the timeout effectively does not exist.— https://github.com/deepseek-ai/deepseek-harness/discussions/9515
- tools/post-execute hook
- tools/post-execute is the hook DeepSeek Harness fires after a tool finishes executing; a plugin can rewrite the result at this step (for example adding a sandbox.denied marker or appending additionalContexts). It is currently the main landing point for fixing 'denials not classified' without changing the core.— https://github.com/deepseek-ai/deepseek-harness/discussions/9497
Sources
- #9515 — [Bug][Windows] Tool calls hang forever: the runner has forked and IPC is connected, but the start request is lost· deepseek-ai (GitHub Discussions)
- #9497 — [sandbox] Denials reported on stdout are not classified, so the escalation hint never appears· deepseek-ai (GitHub Discussions)