Thirty Dollar Signs Beat the Guard: CVE-2026-77474 Turns a Complexity Bug in dcg Into an Agent Fail-Open
NVD published CVE-2026-77474 at 17:17:50.390Z on 16 October 2026 against destructive_command_guard (dcg), the Rust tool whose entire purpose is to stand between an AI coding agent and a destructive shell command. GitHub is the assigning CNA, the record is vulnStatus: Received, and the CVSS 3.1 score is 7.1 high on the vector AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H. NVD files it under CWE-400, uncontrolled resource consumption. The project’s own advisory adds two more that matter far more: CWE-407, inefficient algorithmic complexity, and CWE-693, protection mechanism failure.
That third weakness is the whole story. This is a denial of service with an integrity impact of HIGH and a confidentiality impact of NONE, which is an unusual shape and an accurate one. Nothing leaks. The guard simply stops answering, and the thing it was guarding happens anyway.
The payload is ninety bytes and most of it is punctuation
The reporter, @spartan8806, filed issue #189 at 00:50:44Z on 13 July 2026 with a reproduction we are quoting rather than running. The construction is one line:
cmd = "rm -rf / --no-preserve-root ; " + "$(" * n
A destructive command, a semicolon, and n unclosed command-substitution openers. That is the entire attack. The reporter fed it to the shipped v0.6.5 release binary through a normal agent hook envelope and recorded the following, which we reproduce verbatim from the issue because the shape of the curve is the finding:
n= 15 (60B) 34.1 ms {"decision":"deny",...} # guard works
n= 20 (70B) 46.3 ms {"decision":"deny",...}
n= 22 (74B) 85.1 ms
n= 24 (78B) 255.2 ms # already past the 200ms hook budget
n= 26 (82B) 924.9 ms
n= 28 (86B) 3606.3 ms
n= 30 (90B) HANG (>8s) # n=40 extrapolates to ~30 min
Each additional $( roughly doubles the work. The control case — rm -rf / with no padding — is correctly blocked in about thirty-three milliseconds. The advisory confirms the boundaries of the trigger: “Backticks do not trigger it; $( and "$( do, as does the $(( arithmetic form.”
Why a hang is a bypass
A slow security scanner is normally an availability problem. Here it is an authorisation problem, and the reason is architectural rather than specific to dcg. The advisory states it plainly:
“Because AI coding agents fail open when a PreToolUse hook times out or errors, a hung
dcginvocation causes the agent to proceed and execute the very destructive command in the crafted input. The guard’s primary protection is therefore bypassed.”
The payload is self-contained: the same string that hangs the guard carries the command the guard was supposed to stop. There is no second step. An attacker — or, as the advisory notes, “an injected agent instruction” — appends about thirty $( to a destructive command, and the control that would have denied it is no longer in a position to deny anything.
This is the distinction between a flaw that degrades a defence and a flaw that inverts it. A denial of service against an intrusion detection system costs you visibility. A denial of service against a blocking hook costs you the block, because the only two states the system has are “hook returned deny” and “hook did not return.” The second state is indistinguishable from a crashed hook, a misconfigured path, or a machine under load, and every agent runtime resolves that ambiguity the same way: let the tool call through. The user-interaction term in the CVSS vector (UI:R) is the human approving the agent’s turn; by the time it happens, the guard has already been removed from the path.
The reporter made the connection explicitly, to a separate issue on the same tracker. Issue #183, filed by @Styphax on 11 July 2026, is titled “Codex integration silently fails on Codex CLI 0.144.x (Windows): hook runs but deny is treated as hook failure, commands execute anyway.” The reporter’s own framing of the relationship, quoted verbatim: “that issue shows the fail-open path is real and reachable. This report shows that the same fail-open can be deliberately triggered.” One issue documents that the car has no brakes on a particular road. The other documents a stone you can throw to put it on that road.
The budget was outside the loop
There is a second finding in the issue that is easy to skip past and worth pulling out, because it is a common design error in anything that enforces a deadline. dcg has a 200 ms hook budget. It did not fire. From the issue, verbatim:
“dcg’s own 200 ms hook budget does not fire during this preprocessing loop — at n=24..28 it churns for 255 ms .. 3.6 s and still returns
deny, so the deadline is not enforced inside the scanner. It does not fail open at 200 ms; it just keeps computing (and then hangs).”
A timeout wrapped around a computation is not a timeout on that computation unless something inside the computation checks the clock or the work is bounded by construction. There was also a recursion-depth cap of 500, which bounds stack depth and does nothing at all about total work — the exponential blows up long before depth 500 is relevant. Two guardrails on the guardrail, both measuring the wrong quantity.
The fix is a question mark
We read the patch commit, ba16bdd0, authored at 16:08:45Z on 13 July 2026 — fifteen hours and eighteen minutes after the issue was filed. It is a triage batch of 417 added and 73 deleted lines across ten files; the security portion lives in src/packs/mod.rs. The root cause, as the advisory describes it, is that find_matching_command_substitution and find_matching_arithmetic_expansion “recursed on a nested opener and, when that inner scan returned ‘unterminated,’ fell through and advanced a single byte — so the next opener re-scanned the entire remaining suffix. This yields T(k) = 2·T(k−1) = O(2^k) in the nesting depth.”
The repair is three characters of Rust and one comment. We quote the comment from the diff verbatim, because it is a better explanation of the class than most advisories manage:
// For every nested opener below, a sub-scanner that returns `None` means
// the nested construct runs unterminated through `end`. The *enclosing*
// substitution therefore cannot close either, so we propagate `None`
// immediately. Falling through and advancing a single byte would rescan
// the same suffix once per opener, which is exponential in the nesting
// depth (#189). Propagating is also output-equivalent: the old code only
// ever reached its final `None` after that rescan found no closer anyway.
And the change itself, in the backtick branch, is the ? operator doing the propagation that the old if let Some(close) swallowed:
- if let Some(close) = find_matching_backtick(cmd, i + 1, end) {
- i = close + 1;
- continue;
- }
+ // `?` propagates the unterminated result (see the block comment above).
+ let close = find_matching_backtick(cmd, i + 1, end)?;
+ i = close + 1;
+ continue;
The same shape is applied to the $(( arithmetic branch and the $( branch, and collect_command_segments gains bounded handling of unclosed openers rather than falling through. The phrase we would underline is output-equivalent on well-formed input: the old code computed the same answer, exponentially slower, for inputs nobody types by accident. The commit message adds that regression tests “assert linearity and that the destructive segment still blocks” — the second half of which is the part that is easy to get wrong when you make a parser give up earlier. A guard that stops scanning sooner and therefore stops blocking is a worse outcome than the hang.
Version 0.6.6 was published at 20:57:08Z the same day. Its entire release body is the string Release 0.6.6. Issue to fix to release: under twenty-one hours.
Ninety-four days, and no coordinate
The advisory, GHSA-4cfr-w3v5-w5j5, was published at 02:18:34Z on 14 July 2026. The CVE arrived at 17:17:50Z on 16 October 2026 — ninety-four days later. In the interval the project shipped through v0.15.3 (8 October 2026); the vulnerable v0.6.5 is now nine minor versions behind.
So the patch-timeliness story is excellent and the discoverability story is not, which is the pattern we keep finding. Measured at the time of writing:
GET https://api.osv.dev/v1/vulns/GHSA-4cfr-w3v5-w5j5returns{"code":5,"message":"Vulnerability not found"}.GET https://api.osv.dev/v1/vulns/CVE-2026-77474returns the same.- A version query against OSV for
destructive_command_guardat0.6.5returns an empty object. GET /advisories/GHSA-4cfr-w3v5-w5j5against GitHub’s global advisory database returns404; the record is reachable as a repository advisory and, now, through NVD.
The mechanism is visible in the advisory’s own affected-package block. The ecosystem is other and the package name is the literal string destructive_command_guard (dcg). That is not a coordinate anything resolves. It is not crates.io, not npm, not PyPI. dcg is distributed as a release binary — the reporter names dcg-x86_64-unknown-linux-musl — and a binary pulled from a GitHub release into a developer’s ~/.local/bin appears in no lockfile and no SBOM. We have now written the same paragraph about OPNsense and about the CFML packages in the Wheels batch: when the artefact lives outside a package registry, the advisory infrastructure has nowhere to put it, and the people running it find out by reading GitHub.
Brave search surfaced no independent technical analysis of this record — only CVE mirrors and automated scoring pages reprinting the NVD description, plus third-party gists wiring dcg into OpenCode and Claude Code. Nobody has written this one up.
The guardrail is now part of the attack surface
We have spent a lot of October on this category and the records are starting to rhyme. ClaudeHookBridge parsed only the first field and let a deny decision through. Roo Code shipped five approval bypasses where the gate and the shell disagreed about what a command was. Ollama’s approval prompt did not understand shell operators. Four more arrived in a single VulnCheck batch.
Every one of those is a parsing disagreement: the gate read the command differently from the thing that executed it. CVE-2026-77474 is the first in the run where the gate read it correctly and simply did not finish in time. That is a different failure and, for defenders, a worse one, because the detection story is weaker. A gate that was tricked at least emitted an allow decision you can audit. A gate that hung emitted nothing, and the log line on the agent side is a hook timeout — an event that is normally boring, and in a busy repository is normally noise.
The general rule we would draw: any synchronous security control whose failure mode is “proceed” has an availability-to-integrity conversion built into it, and the cost of that conversion is whatever it takes to make the control slow. Where the control parses attacker-influenced text — and a command-line guard parses nothing else — that cost is often a short string. The useful question is not whether your guard can be tricked; it is what your runtime does in the second when your guard says nothing at all.
What to do
- Upgrade to 0.6.6 or later; current is 0.15.3. If
dcgreached your machines as a release binary rather than through a package manager, nothing will prompt you — check the installed binary’s version directly, on every workstation and CI image that registered the hook. - Do not expect a scanner to raise this. There is no OSV entry and no global GHSA record; the ecosystem is
other. Inventory agent-side tooling the way you inventory endpoint agents, by asking the hosts, not by reading a manifest. - Know your agent’s hook-failure behaviour before you rely on a hook. This is the control that actually decides your exposure, and it belongs to the agent runtime, not to
dcg. If a timed-out or erroringPreToolUsehook results in the tool call proceeding, then every hook you have installed is a best-effort filter, not a boundary — treat it that way in your threat model and in whatever you have told auditors. DCG_FAIL_CLOSED=1is not a workaround for this. The advisory is explicit: there is no configuration-level workaround for affected versions, and the setting “changes only unparseable-input handling and does not by itself bound this preprocessing loop.” It remains worth setting on its own merits; it does not substitute for the upgrade.- Alert on hook timeouts. A
PreToolUsehook that exceeds its budget is now a security-relevant event, not an ops annoyance. On affected versions the signature is specific and cheap to match: a hook invocation that runs for seconds against a command containing repeated$(or$((sequences. Keep the alert after the upgrade, because the next complexity bug in the next guard will look the same. - Apply the same question to the rest of the stack. Any pre-execution check an agent calls synchronously — secret scanners, policy engines, lint-based gates, MCP approval shims — inherits this property. Ask of each one: what happens if it takes thirty seconds, and can the input make it take thirty seconds?
Verification note. The CVE identifier, publication timestamp (17:17:50.390Z, 16 October 2026), vulnStatus: Received, assigning source, CVSS 3.1 score and vector, CWE-400 assignment and reference list were read first-hand from the NVD 2.0 API. The advisory identifier, publication and update timestamps, severity, summary, full impact and root-cause text, three-CWE list, affected and patched version ranges, ecosystem and package coordinate, workaround text and reporter credit were read first-hand from the GitHub repository security-advisory API; every sentence attributed to the advisory is quoted verbatim. Issue #189’s title, author, creation and close timestamps, reproduction script, measured timing table and impact analysis, and issue #183’s title, author and timestamps, were read first-hand from the GitHub API and are quoted verbatim where quoted. The fix commit’s author timestamp, message, file list and diff statistics, and the Rust hunks and code comments from src/packs/mod.rs, were read first-hand from the GitHub commit API and quoted verbatim. Release tags, publication timestamps and release-body text, and repository metadata (6,178 stars, 258 forks, Rust, created 7 January 2026), came from the GitHub API. The OSV and global-advisory absences were measured directly at the time of writing. The timing figures are the reporter’s measurements, not ours: we did not install dcg, download any release binary, construct or execute any payload, run any agent with or without the hook, or send any request to any third-party system; no proof of concept was executed. The fail-open behaviour described is the behaviour the advisory and the linked issue attribute to agent runtimes, and we did not independently test any agent’s hook-timeout handling. We have no evidence of exploitation and imply none.
Sources:
- NVD — CVE-2026-77474: destructive_command_guard before 0.6.6, exponential-time command-substitution preprocessing (7.1 high, CWE-400, Received)
- GHSA-4cfr-w3v5-w5j5 — impact, root cause, patch description, workarounds and credit (published 14 July 2026)
- destructive_command_guard issue #189 — reproduction, timing table and the hook-budget finding (filed 13 July 2026, closed the same day)
- destructive_command_guard issue #183 — deny treated as hook failure on Codex CLI 0.144.x, commands execute anyway
- Fix commit ba16bdd0 — propagating the unterminated scan instead of rescanning the suffix, in
src/packs/mod.rs - destructive_command_guard v0.6.6 — released 20:57:08Z on 13 July 2026, the fixed version
- OSV — GHSA-4cfr-w3v5-w5j5 not found (measured at time of writing)