preToolUse hooks silently bypassed under parallel tool calls (timeout->allow fallback + serial dispatch)
- Dominant language
- Shell
- Stars
- 11.2k
- Forks
- 1.9k
- Avg merge
- 14h 16m
- Merged PRs (30d)
- 6
Description
## Describe the bug
`preToolUse` hooks are silently bypassed under parallel tool calls because:
1. **`timeoutSec` does not terminate the hook process** — when a hook takes longer than `timeoutSec`, the CLI stops waiting but the hook subprocess keeps running. The CLI then proceeds with an **implicit allow** (fail-open). Any `permissionDecision: deny` that the hook eventually writes to stdout is discarded.
2. **Hook invocations are serialized** — when several tools are invoked in parallel (e.g. from the agent), `preToolUse` hooks are dispatched one-by-one with ~1.5–4 s gaps, even though the tool calls themselves land within a <100 ms window. Combined with (1), the later items in the queue are the ones most likely to exceed `timeoutSec` and get fail-open'd.
The net effect is that a security-oriented hook (e.g. a local guard that blocks dangerous commands) becomes probabilistically unreliable as soon as the agent fires more than a handful of tools concurrently. The reported "hook race condition" turns out not to be a race inside the hook, but a silent allow-fallback on timeout.
## Affected version
```
1.0.35-2 (Windows_NT)
```
Likely affects other recent versions; not tested across matrix.
## Steps to reproduce the behavior
Minimal reproducer using a Python 3 hook. Save as `slow-deny.py`:
```python
import json, sys, time
sys.stdin.read()
time.sleep(15) # longer than timeoutSec
print(json.dumps({"permissionDecision": "deny", "permissionDecisionReason": "should block"}))
```
And `~/.copilot/hooks/hooks.json`:
```json
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "python3 \"$HOME/slow-deny.py\"",
"powershell": "py -3 \"$HOME\\slow-deny.py\"",
"timeoutSec": 10
}
]
}
}
```
Start `copilot` and ask the agent to run any shell command (e.g. `echo hi`).
**Expected**: the command is blocked (the hook wrote `deny`, even if late).
**Actual**: the command executes. The hook logs `permissionDecision: deny` after the CLI has already moved on.
A second reproducer shows the serialization behavior: ask the agent to run ~5 shell commands in the same turn. Logging the hook's start time (`datetime.now()`) shows the hooks firing at +0 s, +1.5 s, +4.6 s, +7.1 s, +11.1 s despite the tool calls all starting within ~100 ms.
## Expected behavior
At least one of:
- `timeoutSec` should kill the hook process (so subsequent failure modes are visible), **and** a timed-out hook should fall back to `deny` (fail-closed) — not to an implicit `allow`.
- Provide an explicit configuration knob (e.g. `onTimeout: "deny" | "allow"`) so operators can opt into fail-closed semantics.
- Document the current behavior clearly; right now the docs describe `timeoutSec` without specifying the fallback direction.
Ideally also: dispatch `preToolUse` hooks in parallel rather than serializing them, since each tool call is independent.
## Additional context
- OS: Windows 11, `powershell` invocation path
- Reproduced with minimal `py -3` hook (no `uv`, no third-party dependencies), so the latency is CLI-side scheduling rather than hook startup cost
- Observations:
- `dur_stdout_written - dur_invoked` for the slow hook is ~15 s (full sleep), confirming the process is not killed at the 10 s timeout
- For 5 parallel tool invocations, hook start times were spaced 1.5–4 s apart — consistent with a serial queue on the CLI side
- The bypass probability grows with parallelism, matching community reports of "preToolUse race condition under high concurrency"
- Security impact: any user who relies on `preToolUse` hooks for safety (e.g. blocking writes to sensitive paths, blocking dangerous URLs) silently loses that protection under high concurrency
Happy to share a full minimal repro repo or detailed logs on request.
Contributor guide
Assessment
This issue has not been assessed yet.