Repository navigation
[Bug] PreToolUse is not dispatched for tool calls carrying sandbox_permissions
#10599
Replies: 3 comments
Addendum — same-session A/B/A controlExtra evidence collected after the report above. It changes no conclusion; it exists to rule Three calls inside one session, using one tool (
Between A and B the host appended From the hook side this is observable only as an absence, at
Environment is the same as in the report: |
|
I checked the dispatch layer on There is exactly one path from a tool call to a tool body. Escalation is resolved inside the tool body, strictly after that stage. The write path resolves its policy in The field is never read to decide dispatch. Across the tree, The PTC second-entry hypothesis is out too. Sub-dispatch goes through the same scheduler stage ( The shipped artifact matches the source. In the published So the observation stands as an observation, but the cause is downstream of dispatch. Two explanations fit your evidence and are testable from what you already have:
What would not explain it: a tool-name matcher. The non-escalated If you can share the two |
|
This one matters to me directly, so I would rather understand the dispatch path than argue about the conclusion. My plugin registers on tools/pre-execute and counts persistent operations. If calls carrying sandbox_permissions skip that waterfall, then the exact subset I care about, a model retrying an escalation it does not need, would be counted as if it never happened. argszero says on 687f282 that the path cannot be reached at all, which would mean your four write calls and the two context calls went somewhere else entirely. Is there a way to see which hook chain a given call actually went through? That would settle it without depending on either reading, and I would like to know rather than assume mine is safe. You may already have that instrument. If so I would like to see what it prints. |
Uh oh!
There was an error while loading. Please reload this page.
Type: bug / hooks
Severity: high — the host's own sanctioned escalation path bypasses every PreToolUse gate
Version: dsh
0.2.1-rc.2(DSH Desktop), Windows 10/11 (win32)Component: tool dispatch / sandbox escalation (not the sandbox policy layer)
Date observed: 2026-10-19 → 2026-10-21 (the
writecase on 10-21; the shell cases acrossthose three days), all four
writecalls and both context calls in a single sessionSummary
When a tool call carries
sandbox_permissions(e.g."danger-full-access") plusjustification, DSH dispatches PostToolUse but not PreToolUse for that call.The call itself succeeds. Every hook registered on PreToolUse — policy checks,
budget guards, structure gates — is silently skipped.
This is not an attacker filling in a privileged field. It is the escalation retry that
the host itself prompts for: first attempt has no parameters → PreToolUse fires normally
→ sandbox denies → host appends
[sandbox: escalation available — retry this exact operation once with sandbox_permissions …]→ agent retries with the parameters → that retry gets no PreToolUse.
Environment
dsh 0.2.1-rc.2win32)workspace-write<workspace><skills root>,%APPDATA%\dsh-desktop\harness\skills\...%APPDATA%\dsh-desktop\harness\hooks\hooks.json, 2 separatecommandentries on PreToolUseSteps to reproduce
Minimal, 3 steps:
workspace-write).The first attempt is denied with
file access denied under workspace-write mode,followed by the host's own
[sandbox: escalation available — ...]hint."sandbox_permissions": "danger-full-access"+"justification": "...".tool_use_idhas PostToolUse only, no PreToolUse.The file is written.
Evidence
Ledger:
<workspace>\.dsh-hooks-state\hook-observations.jsonlplus the two aggregate ledgersunder
%APPDATA%\dsh-desktop\harness\hooks— 707 rows for 2026-10-21, 6734 rows total,backup snapshots excluded. Counted from disk by script, not sampled by hand.
The four
writecalls in that sessiontool_use_idin_bytescall_00_…emkc5031file access denied under workspace-write mode)call_00_…A5612call_00_…v5130call_00_…o7966Calls 1 and 2 form a clean A/B pair: identical content and identical target path
(
{"content": "probe\n", "file_path": "...\.probe.md"}), 116 bytes vs. 364 bytes —the only difference is the three escalation fields, and they flip "denied" into "written"
while also dropping PreToolUse. Calls 3 and 4 carry different content and, in the case of
call 4, a different target (DSH's own built-in skills root); they are additional instances
of the same behaviour, not the controlled comparison.
How "escalated?" was determined (the ledger does not store full
tool_input)The ledger keeps a 240-character
in_prevpreview plus the truein_byteslength.For the first call the preview holds the complete payload — 116 bytes, no escalation fields:
{"content": "probe\n", "file_path": "C:\\Users\\<user>\\...\\.probe.md"}For the second call the preview shows the field verbatim:
{"content": "probe\n", "file_path": "...", "sandbox_permissions": "danger-full-access", "justification": "..."}For calls 3 and 4 the JSON key order pushes
sandbox_permissionspast the 240-char window,but the payload still carries
justification, and the byte counts (376 / 412 vs. the 116-bytebaseline) leave no room for anything else. Decisive independent check: all three escalated
calls wrote successfully to paths outside the workspace, while the non-escalated call to
the same directory was denied. Writing outside
workspace-writeis impossible withoutthe escalation fields, so their presence is established behaviourally, not just textually.
The dispatch channel itself is healthy
On the same day, 28 distinct
tool_use_ids do have PreToolUse —read,grep,glob,web_fetch,web_search,ask_user,ask_user_question,validate_dsh_ui,anysearch_capabilities, and one non-escalatedwrite. So this is not "hooks are broken";it is specific to the escalation path.
Second tool family: shell
in_bytespwshpwshpwshThe shell case matters twice: it shows the defect is not
write-specific, and it showspwshis normally inside the PreToolUse dispatch surface (2 of 3 calls got Pre).Sample hygiene note
One more
PostToolUse-only call exists that day (evolve_write,in_bytes=93,{"kind":"memory","action":"delete","id":"..."}). It carries no escalation fields anddoes not touch the filesystem, so it is excluded from this report. Its missing PreToolUse
is a separate question (possibly "not in the dispatch surface at all") and is not counted here.
Expected behaviour
sandbox_permissionsdecides where a call may write; it must not decide whether gatesrun. The two are orthogonal. Specifically:
sandbox_permissionsshould dispatch PreToolUse like any other call.gate-free channel for exactly the calls that most need checking.
not a field the agent writes into
tool_inputitself.For comparison, Claude Code treats a new
tool_use_idas a new call and dispatches Pre/Postas a pair. Here 4 distinct
tool_use_ids were produced and only 1 received PreToolUse.Root-cause pointer
Byte scan of
app.asar(352 MiB):sandbox_permissionsPreToolUsePreToolUsecontext containing a skip/bypass predicateThere is no explicit branch of the form "if sandbox params present, skip hooks". The
effect comes from the dispatch path itself: the escalation retry is apparently not routed
as a tool call that needs the hook pipeline. So the fix belongs in the tool dispatch
layer, not in the sandbox policy module.
Impact
Anything registered on PreToolUse is bypassed for escalated calls: policy/permission
checks, budget guards, memory guards, structure gates, trace/proof guards. In my own
project this reduced a "pre-write gate" to a "post-write detector" for precisely the
scenario it exists for — writes outside the workspace. That day, 4 such writes were made;
3 were escalated, and all 3 escalated ones bypassed the gate while the single
non-escalated one was checked (and then denied by the sandbox).
Narrow workaround (for users, until fixed)
Adding the target root (e.g. a skills directory outside the workspace) to the sandbox's
writable roots makes the first attempt succeed, so the escalation branch is never
entered and PreToolUse dispatch is preserved. This widens exactly one directory.
Not recommended: switching the default mode to
danger-full-accessto "make hooksvisible again" — that trades the whole sandbox boundary for gate visibility.
How to verify a fix
Any hook on
PreToolUsecan self-check this on a stock DSH install. Register a hook thatappends the raw stdin payload to a file, then make two calls that differ only in the
sandbox_permissionsfield:sandbox_permissionssandbox_permissions: "workspace-write"+justificationThe fix is confirmed when row 2 also produces a
PreToolUserecord whosetool_input.sandbox_permissionsis visible in the hook payload itself. Two notes onreading the result:
tool_inputpreview is capped at 240 characters, which cut off exactly the field undertest on 3 of 4 calls — enough to reverse the conclusion if read carelessly.
tool_use_id, not by timestamp window: a hook process can write itsrecord a second or two after the event, and unrelated calls interleave.
The pairing rule that produced the table above is a dozen lines, so it is worth stating
explicitly rather than shipping a tool: group records by
tool_use_id, then for each idrequire both a
PreToolUseand aPostToolUserecord; any id with the latter but notthe former is a candidate gap. To confirm a specific gap, dump every record whose
timestamp falls in that one second — that is how the "no PreToolUse in that second" claim
in §Evidence was re-verified, since it rules out the possibility that a neighbouring call's
record was misattributed to this id.
One caveat on the pairing rule: if your hook runner writes records without a
tool_use_id, its records cannot be attributed to any call, and a naive "Post without Pre"sweep will report them as gaps. In our ledgers 2495 of 2559
PreToolUserows carry notool_use_id— 2231 of those from one legacy runner — so the false-positive rate is nottheoretical: filter by
tool_use_idpresence before computing the gap, and treatunattributable records as unknown rather than as evidence.
All reactions