Skip to content

fix: bound the Agent Guard forwarder request and fail open on transport errors - #512

Merged
iamcristi merged 9 commits into
mainfrom
feat/guard-script-request-timeout
Oct 15, 2026
Merged

iamcristi merged 9 commits into
mainfrom
feat/guard-script-request-timeout

Conversation

@iamcristi

@iamcristi iamcristi commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Field report: #510. Agent-side half: #511 (65 s timeout on every synchronous hook entry). This PR is the script half. Until #511 is on main, Copilot (30 s default timeoutSec) and Claude Code's UserPromptSubmit (30 s default) kill the hook before its 60 s budget fires; both treat that as fail-open, so nothing is blocked, but the user-facing warning below is not shown there.

Why

The forwarder scripts had no timeout anywhere. A request that hangs instead of failing (a proxy or middlebox that completes the handshake and goes silent) held the hook until the agent killed it: 600 s per event on Claude Code and Codex by default. #510 reports exactly this on Windows: three tool calls hung 12+ minutes, PreToolUse hook p95 3.6 s with a 185 s maximum. For the matching tenant, agent-monitor answered every PreToolUse in under 0.5 s on those days, so the time was lost on the client or the network.

A timeout 60 bash … wrapper in the hook command line was considered and rejected: stock macOS ships no timeout binary (Homebrew coreutils has it only as gtimeout), Windows' timeout.exe only pauses, and PowerShell has no built-in run-with-a-limit that also keeps the agent's stdin pipe. Nothing may be assumed installed on endpoints, so every bound lives inside the scripts and uses only what they already require.

What changes

One constant per script, TIMEOUT_SECONDS = 60, bounding the request:

# File Wait Mechanism
1 snyk-agent-guard.sh whole request curl --max-time
2 snyk-agent-guard.ps1 whole request Invoke-WebRequest -TimeoutSec

60 s sits above what agent-monitor may legitimately take: it holds a PreToolUse for up to 25 s until the session's discovery arrives (SESSION_DISCOVERY_WAIT_SECONDS), then evaluates within its 25 s autorelease budget, so a verdict can take ~50 s plus network. It sits below the 65 s agent-side cap from #511, so the agent sees a clean exit rather than a killed process.

Fail open for every client. On any transport failure (timeout, DNS, refused, reset) or HTTP >= 400, both scripts exit 0 and write one line to stderr, snyk-agent-guard: <user-facing sentence> (<reason>; failing open) (curl's own stderr line is kept because it is the only place the DNS/refused/TLS reason is spelled out). Per the agents' docs: GitHub Copilot denies the tool call on any non-zero exit of a preToolUse hook, so the previous exit 1 turned a hung network into a denial there; Cursor permission hooks block on non-JSON stdout but treat no output as fail-open; Claude Code and Codex ignore exit 0 with empty stdout. Usage errors (missing PUSH_KEY / REMOTE_HOOKS_BASE_URL / MACHINE_ID / --client, empty payload, missing curl or base64) still exit 1.

Telling the user. Failing open silently would leave a broken network or a revoked push key invisible, since Claude Code never shows stderr from a hook that exits 0. So the fail-open path also returns a fixed sentence, Unable to reach Snyk Evo to validate agent behavior. Please check your network and configuration and try again., as {"systemMessage": "..."} on stdout for the clients whose hook output defines that field:

Agent systemMessage per its docs What the script emits on stdout
Claude Code yes, "Warning message shown to the user"; honored on every installed event except SessionEnd the JSON object
Codex yes, "Surfaced as a warning in the UI or event stream"; all seven installed events the JSON object
Cursor no such field; permission hooks block on stdout that does not match their schema nothing
GitHub Copilot no such field, no common output fields; preToolUse fails closed on hook errors nothing

The sentence is a constant with nothing to escape, so it is embedded verbatim; a test pins that it is identical in both scripts and JSON-safe. During an outage Claude Code and Codex show the warning on each event that fails open, so a turn can produce several.

Consequence worth knowing: an invalid or revoked push key (HTTP 4xx) is now a fail-open at run time on every agent, visible as the warning above on Claude Code and Codex and only on stderr elsewhere. Install still fails loudly because the test event requires "success": true in stdout (#509); _invoke_hook_script now folds the script's stderr into that failure detail so the operator sees the reason instead of only "push key may be invalid".

docs/cli-reference.md documents the fixed 60 s limit, the fail-open behaviour and the systemMessage warning under guard install. The discovery trampolines are unchanged: they never read stdin and guard discover already bounds itself.

Docs relied on

Verification

  • TDD: new tests in tests/unit/test_guard.py, red before the change, green after. Bash: hanging server, HTTP 500, connection refused, empty payload still a usage error, and a per-client case (claude-code, codex, cursor, github-copilot) pinning the JSON systemMessage on stdout for the first two and empty stdout for the last two. The hanging-server case runs a test-only copy of the script with the constant rewritten to 2 s (_forwarder_with_budget); the rest run the shipped script at 60 s. PowerShell: the same cases, Windows-only, so the windows-latest CI leg is the only place they execute. TestHookScriptRequestBudget pins budget > 50 (the upper bound against the agent-side cap is asserted next to that cap, in feat: write an explicit 30 s timeout on synchronous Agent Guard hooks #511), one shared constant, the constant outside the install-time section, and no stray numeric timeout literal in either script.
  • Full suite in a clean worktree: failure set byte-identical to the origin/main baseline (89 environmental entries: e2e [binary] params without dist/, SNYK_TOKEN, the managed-install tests that hit the real /Library/Application Support/ClaudeCode). Passed 2381 → 2403.
  • Manual, default budget, real /bin/bash 3.2 on macOS: hanging server → exit 0, curl: (28) … + the fail-open line on stderr, 60.0 s wall clock. HTTP 500 server with each of the four clients: claude-code and codex print exactly {"systemMessage":"…"} (parses with json.tool), cursor and github-copilot print nothing; all exit 0 with the sentence on stderr.
  • pre-commit run --all-files clean; local ruff 0.15 lint and format clean; shellcheck clean on the bash script; mypy reports nothing in guard.py.

Note

Medium Risk
Changes synchronous hook behavior when Agent Guard is unreachable—agents proceed without validation and may show warnings—but reduces long hangs and fixes Copilot incorrectly denying tools on network errors; install still fails on bad push keys when the test event requires success.

Overview
Agent Guard hook forwarders (snyk-agent-guard.sh / .ps1) now cap outbound requests at 60 seconds (curl --max-time / Invoke-WebRequest -TimeoutSec) so hung proxies no longer block hooks until the agent kills them.

On timeout, transport errors, or HTTP ≥ 400, both scripts fail open (exit 0, one stderr line) instead of exiting 1—important for Copilot, which denies tools on non-zero hook exit. Claude Code and Codex also get a fixed {"systemMessage":"…"} warning on stdout; Cursor and GitHub Copilot get empty stdout so invalid JSON does not block the agent. Usage errors (missing keys, empty payload) still exit 1.

guard install test-event validation in guard.py now surfaces the forwarder’s stderr when stdout lacks success: true, so operators see timeout/fail-open reasons instead of only “push key may be invalid.” Revoked keys at runtime now fail open with that warning on Claude/Codex.

CLI docs under guard install describe the 60s limit and fail-open behavior. Tests cover bash/PowerShell fail-open paths, shared timeout/message constants, and install-time invoke stderr handling.

Reviewed by Cursor Bugbot for commit 1ca6b6f. Bugbot is set up for automated code reviews on this repo. Configure here.

The hook forwarders had no timeout at all: snyk-agent-guard.sh read stdin
with cat and ran curl without --max-time; snyk-agent-guard.ps1 read stdin
with ReadToEnd() and ran Invoke-WebRequest without -TimeoutSec, which is
indefinite in Windows PowerShell 5.1. A stdin pipe left open, or a request
that hangs instead of failing, held the hook until the agent killed it:
600 s per event on Claude Code and Codex (#510).

Both scripts now resolve one timeout, 27 s by default, from
SNYK_AGENT_GUARD_TIMEOUT_SECONDS (a positive integer, else the default)
and apply it to every wait: the stdin read (bash `read -t` in chunks,
bash 3.2 compatible; Task.Wait on ReadToEndAsync in PowerShell), curl's
--connect-timeout and --max-time, and -TimeoutSec. 27 s sits above
agent-monitor's ~24 s policy budget, so a slow verdict is never cut, and
below the 30 s agent-side cap written by #511, so the agent sees a clean
exit instead of a killed process.

Transport and HTTP failures, and a stdin read that times out, now fail
open for every client: exit 0, empty stdout, one diagnostic line on
stderr. Copilot denies the tool call on any non-zero exit of a preToolUse
hook, so the previous exit 1 turned a hung network into a denial there;
Cursor blocks on non-JSON stdout but allows on no output; Claude Code and
Codex ignore exit 0 with empty stdout. Usage errors (missing variables,
empty payload, missing curl or base64) still exit 1.

The install test event requires "success": true in stdout (#509), so an
install against an unreachable endpoint still fails; _invoke_hook_script
now folds the script's stderr into that failure detail so the reason is
visible to the operator.
@iamcristi
iamcristi requested review from a team and marcelosousa as code owners October 15, 2026 05:38
@qodo-merge-etso

Copy link
Copy Markdown

PR Summary by Qodo

Bound Agent Guard forwarder waits and fail open on request errors

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Bound stdin and request waits in both Guard forwarders with a configurable timeout.
• Fail open on transport and HTTP errors while preserving installation failure diagnostics.
• Test hanging and failed requests, and document the timeout override.
Diagram

graph TD
  A["Agent hook"] --> B["Platform forwarder"] --> C{"Stdin complete?"} -->|yes| D["Guard endpoint"] --> E{"Request succeeds?"} -->|yes| F["Policy response"]
  C -->|timeout| G["Fail open"]
  E -->|failure| G
Loading
High-Level Assessment

Keep the bounds inside the existing forwarders: they need no additional endpoint dependencies and can return the fail-open result expected by the agents. An external timeout wrapper was considered but dismissed because it is not portable across the supported platforms and cannot reliably preserve the desired exit behavior.

Files changed (5) +483 / -24

Bug fix (3) +143 / -18
guard.pySurface forwarder diagnostics during install verification +5/-0

Surface forwarder diagnostics during install verification

• When a test event lacks a success payload despite a zero exit code, installation verification now includes the forwarder's stderr in its failure detail.

src/agent_scan/guard.py

snyk-agent-guard.ps1Bound PowerShell reads and requests +55/-13

Bound PowerShell reads and requests

• Adds a configurable 27-second default for the asynchronous stdin read and Invoke-WebRequest. Stdin timeouts, transport failures, and HTTP errors now exit successfully with empty stdout and a stderr diagnostic; usage errors remain failures.

src/agent_scan/hooks/snyk-agent-guard.ps1

snyk-agent-guard.shBound Bash reads and curl requests +83/-5

Bound Bash reads and curl requests

• Replaces the unbounded stdin read with a deadline-based, Bash 3.2-compatible read loop and applies the resolved timeout to curl connection and request waits. Timeout, transport, and HTTP failures now fail open without forwarding a partial response.

src/agent_scan/hooks/snyk-agent-guard.sh

Tests (1) +339 / -6
test_guard.pyCover forwarder budgets and failure behavior +339/-6

Cover forwarder budgets and failure behavior

• Adds timeout-budget checks and local-server tests for hanging requests, HTTP errors, refused connections, held-open stdin, and empty payloads across the applicable platforms. Also checks that install verification includes fail-open stderr diagnostics.

tests/unit/test_guard.py

Documentation (1) +1 / -0
cli-reference.mdDocument the Guard timeout override +1/-0

Document the Guard timeout override

• Adds SNYK_AGENT_GUARD_TIMEOUT_SECONDS to the Guard environment variable table, including its default, accepted values, and runtime fail-open behavior.

docs/cli-reference.md

@qodo-merge-etso

qodo-merge-etso Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📎 Requirement gaps (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Valid hook events never reach the server ✓ Resolved
Description
read_payload declares its own local payload, so the value read from stdin is discarded when the
helper returns to hook_main. Every non-empty valid request then sees the caller's still-empty
payload, triggers the usage error, and skips base64 encoding and the HTTP request.
Code

src/agent_scan/hooks/snyk-agent-guard.sh[R72-74]

+read_payload() {
+  local deadline=$(( SECONDS + timeout_seconds )) remaining chunk status
+  payload=""
Relevance

●●● Strong

This is an obvious shell scoping bug that discards every valid payload before transmission.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed helper creates a local payload, while the caller separately creates its own local
payload and does not capture a return value. The checklist requires meaningful regression tests
for changed code paths, but the new tests cover failure cases and do not catch this
successful-request path failure.

Rule 4: Every change must include automated tests; bug fixes add a regression test
src/agent_scan/hooks/snyk-agent-guard.sh[72-74]
src/agent_scan/hooks/snyk-agent-guard.sh[213-216]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`read_payload` shadows `hook_main`'s `payload` variable with a function-local variable, so successful stdin reads are lost and every valid hook invocation fails as an empty-payload usage error.

## Fix Focus Areas
- src/agent_scan/hooks/snyk-agent-guard.sh[72-99]
- src/agent_scan/hooks/snyk-agent-guard.sh[213-216]

## Recommended Fix
Have `read_payload` return the assembled payload and assign it in `hook_main`, or explicitly pass the destination variable without shadowing it. Add or update a successful forwarder test that verifies a non-empty payload reaches the test server and that the response is forwarded.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Transport errors expose internal details 📘 Rule violation ☼ Reliability
Description
snyk-agent-guard.ps1 interpolates $ex.Message directly into the fail-open diagnostic sent to
stderr. Any non-timeout transport exception can therefore expose exception details such as request
or host information to the hook consumer and to install-time error output.
Code

src/agent_scan/hooks/snyk-agent-guard.ps1[R196-199]

+    if ($timedOut) {
+        FailOpen "request timed out after ${timeoutSeconds}s"
+    }
+    FailOpen "request failed: $($ex.Message)"
Relevance

●●● Strong

Raw transport exception details conflict with the repository’s client-facing error-handling and
non-identifying diagnostic expectations.

PR-#320

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed catch block passes $ex.Message into FailOpen, and _invoke_hook_script forwards
stderr into the installation failure detail. The checklist requires client-facing messages to avoid
raw exception details and internal host information.

Rule 6: Handle errors explicitly; don't swallow; clean up; don't leak internals
src/agent_scan/hooks/snyk-agent-guard.ps1[196-199]
src/agent_scan/guard.py[1378-1384]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PowerShell forwarder sends the raw exception message to stderr, which can expose internal request or host details in user-facing hook and installation diagnostics.

## Fix Focus Areas
- src/agent_scan/hooks/snyk-agent-guard.ps1[188-199]
- src/agent_scan/guard.py[1378-1384]

## Recommended Fix
Replace the raw exception message with a generic, stable transport-failure reason while retaining only the necessary timeout or HTTP status information. Keep detailed exception data out of hook output and installation failure messages.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


3. Windows hook can still stall on a slow response body 🐞 Bug ☼ Reliability
Description
Invoke-WebRequest -TimeoutSec in snyk-agent-guard.ps1 bounds the connection and response-header
wait but not body reads, which can remain subject to PowerShell 5.1’s default 300-second
ReadWriteTimeout despite the script and CLI docs promising a timeout for the whole request. If a
server sends headers and then stalls, the forwarder cannot reach FailOpen at the configured
deadline, and this PR’s installed Claude hook has no explicit agent-side timeout to bound the wait.
Code

src/agent_scan/hooks/snyk-agent-guard.ps1[R177-178]

+    $response = Invoke-WebRequest -Uri $url -Method POST -Body $bodyBytes -Headers $headers `
+        -UseBasicParsing -TimeoutSec $timeoutSeconds
Relevance

●● Moderate

PowerShell body-read timeout behavior is plausible, but no close repository precedent confirms
acceptance of this platform-specific concern.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The handler’s comment states that -TimeoutSec does not cover a body stalled after headers and
leaves that wait to the agent’s hook timeout. That conflicts with the script header and the new
CLI-reference row, which describe the timeout as covering the whole request; the installed Claude
command entry has no explicit timeout. The hanging-server test sends no headers, so it does not
exercise a body stall.

src/agent_scan/hooks/snyk-agent-guard.ps1[10-13]
src/agent_scan/hooks/snyk-agent-guard.ps1[177-201]
src/agent_scan/guard.py[687-698]
tests/unit/test_guard.py[2443-2455]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
On PowerShell 5.1, `Invoke-WebRequest -TimeoutSec` does not bound response-body reads, so a server that sends headers and then stalls can keep the forwarder blocked rather than reaching `FailOpen` at the configured deadline. The script header and CLI docs also incorrectly say the timeout covers the whole request.

## Fix Focus Areas
- src/agent_scan/hooks/snyk-agent-guard.ps1[10-13]
- src/agent_scan/hooks/snyk-agent-guard.ps1[177-201]
- docs/cli-reference.md[410-410]
- tests/unit/test_guard.py[2443-2455]

## Recommended Fix
Enforce the configured deadline across both response-header and body retrieval on PowerShell 5.1, and route expiry through `FailOpen`. For example, use `System.Net.HttpWebRequest` with both `.Timeout` and `.ReadWriteTimeout` set to `$timeoutSeconds*1000`, or use `System.Net.Http.HttpClient` with a timeout covering the full response and `.Wait(ms)` on the task. Add a test server that sends headers and part of a body before stalling. If full-request enforcement is not implemented, at minimum correct the script header and docs row to say that the Windows timeout covers the connection and response headers, not the body.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗



Informational

4. Short timeout overrides can cut off a payload still arriving ⊘ Outdated
Description
read_payload sets its deadline from bash's whole-second SECONDS counter (`deadline=$(( SECONDS +
timeout_seconds ))`). The real budget can therefore be up to a second shorter than configured. On
bash 3.2 it also treats any EOF that lands once SECONDS >= deadline as a timeout. With small
overrides (for example SNYK_AGENT_GUARD_TIMEOUT_SECONDS=1, the value the new tests use), a payload
that finishes after the next whole-second tick, or a large payload read byte by byte (about 1.4 s
per MB), is wrongly reported as a stdin timeout and the event is dropped through the fail-open path.
Code

src/agent_scan/hooks/snyk-agent-guard.sh[R73-77]

+  local deadline=$(( SECONDS + timeout_seconds )) remaining chunk status
+  payload=""
+  while :; do
+    remaining=$(( deadline - SECONDS ))
+    if (( remaining <= 0 )); then
Relevance

●●● Strong

The short-timeout deadline can deterministically drop valid payloads, directly undermining the PR’s
stated bounded-read behavior.

PR-#320

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
SECONDS only moves in whole seconds. If the clock is at x.9 when the read starts, deadline is one
tick away, so remaining reaches 0, and on bash 3.2 the SECONDS >= deadline branch at line 90
classifies a normal EOF as a timeout.

src/agent_scan/hooks/snyk-agent-guard.sh[72-94]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The stdin deadline in read_payload uses whole-second SECONDS. The effective budget can be nearly a second shorter than configured, and on bash 3.2 a normal EOF right at the tick counts as a timeout.

## Fix Focus Areas
- src/agent_scan/hooks/snyk-agent-guard.sh[72-94]

## Recommended Fix
Compute `deadline=$(( SECONDS + timeout_seconds + 1 ))` and cap the per-chunk `-t` at `timeout_seconds`. Alternatively, on bash 3.2 only report a timeout when the read returned status 1 with an empty chunk and the deadline has passed. Either way, a payload that finishes inside the configured window is not misreported.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 8 rules
✅ Cross-repo context — repo relationships
Review mode: Auto: 🧠 Deep: Multiple platform-specific timeout paths and fail-open behaviors create dense independent logic risk.

Grey Divider

Tip of the day
💡 Did you know, you can hide the parts of a finding you never read, like the evidence or the agent prompt

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/agent_scan/hooks/snyk-agent-guard.sh Outdated
Comment on lines +196 to +199
if ($timedOut) {
FailOpen "request timed out after ${timeoutSeconds}s"
}
FailOpen "request failed: $($ex.Message)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Transport errors expose internal details 📘 Rule violation ☼ Reliability

snyk-agent-guard.ps1 interpolates $ex.Message directly into the fail-open diagnostic sent to
stderr. Any non-timeout transport exception can therefore expose exception details such as request
or host information to the hook consumer and to install-time error output.
Agent Prompt
## Issue description
The PowerShell forwarder sends the raw exception message to stderr, which can expose internal request or host details in user-facing hook and installation diagnostics.

## Fix Focus Areas
- src/agent_scan/hooks/snyk-agent-guard.ps1[188-199]
- src/agent_scan/guard.py[1378-1384]

## Recommended Fix
Replace the raw exception message with a generic, stable transport-failure reason while retaining only the necessary timeout or HTTP status information. Keep detailed exception data out of hook output and installation failure messages.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Comment on lines +177 to +178
$response = Invoke-WebRequest -Uri $url -Method POST -Body $bodyBytes -Headers $headers `
-UseBasicParsing -TimeoutSec $timeoutSeconds

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Windows hook can still stall on a slow response body 🐞 Bug ☼ Reliability

Invoke-WebRequest -TimeoutSec in snyk-agent-guard.ps1 bounds the connection and response-header
wait but not body reads, which can remain subject to PowerShell 5.1’s default 300-second
ReadWriteTimeout despite the script and CLI docs promising a timeout for the whole request. If a
server sends headers and then stalls, the forwarder cannot reach FailOpen at the configured
deadline, and this PR’s installed Claude hook has no explicit agent-side timeout to bound the wait.
Agent Prompt
## Issue description
On PowerShell 5.1, `Invoke-WebRequest -TimeoutSec` does not bound response-body reads, so a server that sends headers and then stalls can keep the forwarder blocked rather than reaching `FailOpen` at the configured deadline. The script header and CLI docs also incorrectly say the timeout covers the whole request.

## Fix Focus Areas
- src/agent_scan/hooks/snyk-agent-guard.ps1[10-13]
- src/agent_scan/hooks/snyk-agent-guard.ps1[177-201]
- docs/cli-reference.md[410-410]
- tests/unit/test_guard.py[2443-2455]

## Recommended Fix
Enforce the configured deadline across both response-header and body retrieval on PowerShell 5.1, and route expiry through `FailOpen`. For example, use `System.Net.HttpWebRequest` with both `.Timeout` and `.ReadWriteTimeout` set to `$timeoutSeconds*1000`, or use `System.Net.Http.HttpClient` with a timeout covering the full response and `.Wait(ms)` on the task. Add a test server that sends headers and part of a body before stalling. If full-request enforcement is not implemented, at minimum correct the script header and docs row to say that the Windows timeout covers the connection and response headers, not the body.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Comment thread src/agent_scan/hooks/snyk-agent-guard.sh Outdated
Windows retries the SYN on a refused loopback connect for about two
seconds, so with a 2 s budget -TimeoutSec fires first and the script
reports a timeout instead of a refused connection. Both are the fail-open
path: widen that test's budget to 5 s and accept either reason.
SNYK_AGENT_GUARD_TIMEOUT_SECONDS let anything that can set the agent's
environment (a shell profile, direnv, a project .claude/settings.json
env block in a cloned repo) shrink the 27 s budget to 1 s, so every
LLM-judged verdict timed out and the hook failed open. Neither the
script checksum nor the config tamper check sees the environment, so
the override was an unmonitored way to switch enforcement off.

Both forwarders now use one constant, TIMEOUT_SECONDS=27, and read no
override; the resolve step (and its subshell on every hook call) is
gone. The docs drop the variable and describe the fixed 27 s limit and
the fail-open behaviour under `guard install` instead.

Tests: the two cases per script that wait for a timeout to fire (hanging
server, stdin held open) run a test-only copy with the constant
rewritten to 1-2 s; the rest run the shipped script at 27 s, which also
makes the Windows refused-connect case deterministic again. New
test_environment_cannot_shorten_the_timeout (bash and PowerShell) sets
the variable and asserts the script is still waiting.
Drop the comments, docs sentence and tests that only described what the
timeout is not, so the scripts, docs and tests describe the fixed 27 s
budget and nothing else.
The budget regexes run on bytes so the patched copy keeps the shipped
line endings, but Windows checks the scripts out with CRLF and `$` only
matches before `\n`, so neither script's TIMEOUT_SECONDS was found
there. A `(?=\r?$)` lookahead matches either line end and leaves the
`\r` in place.
--max-time already covers DNS, TCP and TLS, and a connect that runs out
of time exits 28 the same way, so an equal --connect-timeout next to it
changed nothing.
Both forwarders read stdin as on main again (cat in bash, ReadToEnd in
PowerShell); TIMEOUT_SECONDS bounds the request alone. That drops the
bash 3.2 read -t loop, its timeout-versus-EOF guesswork and its
per-byte reads, and the PowerShell Task.Wait.
@iamcristi iamcristi changed the title fix: bound every wait in the Agent Guard forwarder scripts and fail open on transport errors fix: bound the Agent Guard forwarder request and fail open on transport errors Oct 15, 2026
Failing open silently left a broken network or a revoked push key
invisible: Claude Code never shows stderr from a hook that exits 0, so
Agent Guard could stop validating without anyone noticing.

On every fail-open path both forwarders now write a fixed, user-facing
sentence ("Unable to reach Snyk Evo to validate agent behavior. Please
check your network and configuration and try again.") to stderr together
with the technical reason, and return it as a `systemMessage` JSON object
on stdout for the clients whose hook output defines that field, Claude
Code and Codex, which render it as a warning. Cursor and GitHub Copilot
keep empty stdout: Cursor's permission hooks block on stdout that does
not match their schema, and Copilot documents no such field. The sentence
is a constant with nothing to escape, so it is embedded verbatim; a test
pins that in both scripts.

The install test event still rejects this body, since it requires
"success": true, and its failure detail already carries stderr.
agent-monitor holds a PreToolUse for up to 25 s until the session's
discovery arrives, then evaluates within its 25 s autorelease budget, so
a legitimate verdict can take ~50 s. At 27 s the scripts gave up on the
first MCP calls of a session, exactly where enforcement matters most.

The lower bound the budget test pins moves from 25 s to 50 s. The upper
bound against the agent-side cap is asserted next to that cap (#511),
which moves to 65 s.
iamcristi added a commit that referenced this pull request Oct 15, 2026
The forwarder scripts now give the request 60 s (#512), because
agent-monitor may legitimately take ~50 s to answer. A 30 s cap would
kill the hook before the script could fail open with its diagnostic,
and below Copilot's 30 s default the explicit value was redundant.

A new test asserts the scripts' TIMEOUT_SECONDS stays under the cap,
skipping until the forwarder budget is on the same branch.
@iamcristi
iamcristi merged commit 9c5784b into main Oct 15, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants