{"slug": "catch-go-bugs-that-fuzzing-misses-with-gemini-and-a-constraint-solver", "title": "Catch Go bugs that fuzzing misses with Gemini and a constraint solver", "summary": "A Google engineer's pipeline pairing Gemini 4 Argon with the Z3 constraint solver caught all three bugs deliberately planted in skill-lint's test parsers and found three additional latent bugs in skill-lint, a Go CLI that validates SKILL.md files for Google's agent skills program, which now holds 175 agent skills. The workflow compiles target functions with GOOS=linux GOARCH=riscv64 and -gcflags=\"-S -d=ssa/check_bce/debug=1\" to surface surviving runtime.panicBounds traps, then has Gemini 4 Argon translate each trap's path into Z3 rules that synthesize concrete Go reproducers, which go test runs directly. The author reports that go test -fuzz missed two of the crashes and describes a one-line change that fixes it.", "body_md": "I help lead Google's [agent skills program](https://github.com/google/skills), currently with 175 agent skills that supplement community collections like [go-skills](https://github.com/spf13/go-skills). To keep that many skill files consistent, I wrote skill-lint, a standalone [Go](https://go.dev/?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026) CLI that checks SKILL.md files and applies LLM-generated line edits before they ship. Even with skill-lint's own unit and fuzz test suite, a small slice or line-number mistake inside its Markdown parsers or edit applier can sit dormant until an unusual file or edit range triggers a runtime panic in the linter.\n\nIn Go, every slice or index operation that can panic at runtime compiles down to an explicit bounds-check trap in machine code, after the compiler's optimizer strips out the checks it can already rule out. To hunt for crashes, I compiled key functions from my code to see which panic traps survived optimization, and [Gemini 4 Argon](https://ai.google.dev/gemini-api/docs/models/gemini-4-argon?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026) translated the path to each trap into rules for the [Z3 constraint solver](https://github.com/Z3Prover/z3). An LLM-written solver model isn't a sound proof system, but it gives the agent an independent way to reason about control flow and synthesize concrete Go reproducers, and every crash it finds is real because go test runs the offending bytes directly. That workflow caught all three bugs I planted in test parsers and turned up three latent bugs in skill-lint.\n\nIn this post, I'll walk through how the Gemini and Z3 pipeline works, why [go test -fuzz](https://go.dev/security/fuzz/?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026) missed two of those crashes (and the one-line change that fixes it), and where this approach fits in a day-to-day Go workflow.\n\nA [recent post from Anthropic](https://www.anthropic.com/research/formal-verification) showed how formal verification catches latent bugs in C and C++ by having frontier AI models write machine-checked [Rocq](https://rocq-prover.org/) proofs across [OSS-Fuzz](https://github.com/google/oss-fuzz) libraries. Instead of sampling random inputs like a fuzzer, a formal tool works backward from each failure condition to solve for the exact input that triggers it. Full theorem proving is still too heavy for most day-to-day Go projects, so I wanted to see how much of that bug-hunting power an agent can get from an LLM and a constraint solver in about a minute per function.\n\nIn C and C++, reading or writing past the end of a buffer corrupts memory. Go catches out-of-bounds access at runtime instead, compiling every slice or index operation it can't prove safe into an explicit branch to [runtime.panicBounds](https://github.com/golang/go/blob/go1.27.3/src/runtime/panic.go#L208) that panics, crashing the process unless recovered.\n\nBefore generating machine code, Go's [SSA](https://pkg.go.dev/cmd/compile/internal/ssa?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026) [prove](https://github.com/golang/go/blob/go1.27.3/src/cmd/compile/internal/ssa/prove.go) pass already eliminates most of those checks by tracking slice lengths and loop variables. That optimization pass leaves behind only the `runtime.panicBounds` traps the compiler couldn't rule out on its own, giving the solver a short list of candidate crash sites to target.\n\nI compiled each target with `GOOS=linux GOARCH=riscv64 go build -gcflags=\"-S -d=ssa/check_bce/debug=1\"` (`-S` prints the assembly listing and `ssa/check_bce/debug=1` logs every surviving bounds check) and passed both the Go source and the disassembly to the model. Looking at Go source alone, you can't see which bounds checks the compiler already removed or where a register gets truncated to 8 bits; in the disassembly, both show up. I targeted [RISC-V](https://en.wikipedia.org/wiki/RISC-V) because it's the instruction set Anthropic's post targets for its Rocq proofs, and Go cross-compiles to it out of the box.\n\nFrom there, Argon read the Go source and disassembly and returned a Z3 script modeling the path to each surviving trap, along with a patched function whenever it flagged a bug. Z3 then checked whether those constraints could be satisfied, and whenever it found a crashing input, `go test` replayed those bytes against the real function. Using an LLM saved me from translating assembly into solver constraints by hand. Argon's translation dominated the runtime, at about a minute per function, while Z3 itself finished in milliseconds.\n\nAnthropic's approach relies on [interactive theorem proving](https://en.wikipedia.org/wiki/Proof_assistant), where experts model machine semantics and Rocq checks LLM-generated proofs against those rules. In contrast, my workflow uses a constraint solver based on [satisfiability modulo theories (SMT)](https://en.wikipedia.org/wiki/Satisfiability_modulo_theories), relying on Argon to translate assembly directly into Z3 constraints. While a constraint solver doesn't provide a complete correctness proof without a semantic model, it is highly effective for finding bugs. Z3's [check-sat](https://microsoft.github.io/z3guide/docs/logic/basiccommands) command returns sat when input constraints can be satisfied and `unsat` when they cannot.\n\nA sat answer (\"definitely buggy\") comes with concrete bytes aimed at the trap, and `go test` runs them against the real function. Once go test panics, you have a concrete bug reproducer regardless of whether Argon's model was sound. Like a Bloom filter for bugs, an `unsat` answer only means \"maybe correct.\" Trading away a correctness proof is what makes the approach so practical: you get most of the bug-finding value of formal methods for a tiny fraction of the effort.\n\nAnthropic's setup proved no-crash properties on whole binaries under a 64 MB budget; I checked individual functions on fresh 64-byte slices where [cap(buf) == len(buf)](https://go.dev/ref/spec?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026#Length_and_capacity), so any slice past `len(buf)` panics. (If you want the full-binary Rocq setup for Go, [theorem-labs/oss-verify](https://github.com/theorem-labs/oss-verify) includes a [Go 1.27](https://go.dev/doc/go1.27?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026) runtime overlay that makes the runtime single-threaded and deterministic.)\n\nFor the fuzzing baseline, I ran unseeded [go test -fuzz](https://go.dev/security/fuzz/?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026) on Go 1.27.3 against each planted bug: one 3-second run and three 60-second runs, each with a fresh fuzz cache so a short time budget wouldn't explain a miss.\n\nI started with three safe parsers to make sure the pipeline didn't flag clean code. The compiler's `prove` pass removed every bounds check in a TOML key parser on its own, and Z3 returned `unsat` on the remaining traps in an HTTP header scanner and a varint decoder.\n\nThen I wrote three parsers with subtle panics. Here's the first one, `ParseTwoChunkFrame`:\n\n```\nfunc ParseTwoChunkFrame(buf []byte) ([]byte, []byte) {\n\tif len(buf) < 4 || buf[0] != 0xA5 || buf[1] != 0x5A {\n\t\treturn nil, nil\n\t}\n\thdrLen, bodyLen := buf[2], buf[3]\n\ttotal := hdrLen + bodyLen\n\tif int(total)+4 > len(buf) {\n\t\treturn nil, nil\n\t}\n\tendHdr := 4 + int(hdrLen)\n\tendBody := endHdr + int(bodyLen)\n\treturn buf[4:endHdr], buf[endHdr:endBody]\n}\n```\n\nIn the RISC-V disassembly, the bug is much easier to see:\n\n```\n0x0060  ADD    X16, X9, X5                 ; X5 = hdrLen + bodyLen\n0x0064  MOVBU  X5, X5                      ; TRUNCATE to uint8: X5 = X5 & 0xff\n0x0068  ADDI   $4, X5, X5                  ; X5 = int(total) + 4\n0x006a  BLT    X11, X5, 0x00bc             ; guard: if len(buf) < X5, return nil, nil\n0x006e  MOV    X9, X11                     ; X11 = int(hdrLen)\n0x0070  MOV    X16, X14                    ; X14 = int(bodyLen)\n0x0072  ADD    X14, X11, X9                ; X9 = int(hdrLen) + int(bodyLen) (unmasked!)\n0x0076  ADDI   $4, X11, X16                ; X16 = endHdr = 4 + int(hdrLen)\n0x007a  BLTU   X12, X16, 0x00ec            ; if cap(buf) < endHdr -> runtime.panicBounds\n...\n0x0090  ADDI   $4, X9, X9                  ; X9 = endBody = 4 + int(hdrLen) + int(bodyLen)\n0x0092  BLTU   X12, X9, 0x00e8             ; if cap(buf) < endBody -> runtime.panicBounds\n```\n\n`total := hdrLen + bodyLen` adds two `uint8` values, so `MOVBU` [wraps the sum at 256](https://go.dev/ref/spec?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026#Integer_overflow) before the length guard checks it, while `endBody` adds the full 64-bit ints. Argon turned those instructions into a Z3 query asking whether a fresh slice of up to 64 bytes can pass the guard and still fail the `endBody` check at `0x0092`:\n\n``` python\nimport z3\n\ns = z3.Solver()\nlength, capacity = z3.BitVecs(\"length capacity\", 64)\nb0, b1, hdrLen8, bodyLen8 = z3.BitVecs(\"b0 b1 hdrLen8 bodyLen8\", 8)\n\nwrapped_sum64 = z3.ZeroExt(56, hdrLen8 + bodyLen8) + z3.BitVecVal(4, 64)\nend_hdr64 = z3.BitVecVal(4, 64) + z3.ZeroExt(56, hdrLen8)\nend_body64 = end_hdr64 + z3.ZeroExt(56, bodyLen8)\n\ns.add(z3.UGE(length, 4), z3.ULE(length, 64), capacity == length)\ns.add(b0 == 0xA5, b1 == 0x5A)\ns.add(z3.UGE(length, wrapped_sum64), z3.ULE(end_hdr64, capacity))\ns.add(z3.UGT(end_body64, capacity))  # panic at 0x0092\nassert s.check() == z3.sat\nprint(s.model())\n```\n\nZ3 returned a 59-byte counterexample with `hdrLen = 48` and `bodyLen = 236`: their `uint8` sum wraps from 284 down to 28 (so 28 + 4 ≤ 59 passes the guard), and `buf[endHdr:endBody]` then slices to index 288 and panics.\n\nAt 59 bytes, 19.4% of all possible `(hdrLen, bodyLen)` pairs trigger this crash. Yet a 3-second fuzz run missed it, and so did three 60-second runs.\n\nGo checks a [slice expression](https://go.dev/ref/spec?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026#Slice_expressions) like `buf[endHdr:endBody]` against the slice's capacity, not its length, and Go's fuzzer rarely passes a slice without spare capacity. In a 10-second probe of 386,741 fuzz calls, 89% of inputs were views into a shared ~100 MB buffer, and only 0.07% had `cap == len`. With that much spare capacity, endBody (at most 514) never exceeds `cap(buf)`, so the buggy parser returns bytes past the end of `buf` instead of panicking.\n\nThe second target, `ExtractQuotedToken`, hit the same blind spot when a trailing backslash pushed the index one byte past `len(buf)`. Z3 caught it immediately with the 2-byte input `[]byte{'\"', '\\\\'}`, while the fuzzer absorbed the overrun in spare capacity.\n\nClipping capacity to length with a full slice expression turns any slice past `len` into a panic:\n\n```\nf.Fuzz(func(t *testing.T, data []byte) {\n\tdata = data[:len(data):len(data)] // cap == len: slicing past the end now panics\n\tParseTwoChunkFrame(data)\n})\n```\n\nAfter that change, the fuzzer crashed both `ParseTwoChunkFrame` and `ExtractQuotedToken` in all three runs (median 0.28 s and 0.17 s). The third target, `ParseMagicPacket`, was a control because its signed `int8` length bug panics on a negative index regardless of capacity, and the fuzzer found it in every run either way. Fuzzing still can't give you an `unsat` signal across the remaining paths, which is how I checked the three safe parsers and Argon's patches.\n\nNext, I pointed the pipeline at skill-lint itself. A whole Go CLI is too large to hand to a solver directly. Even a 10-line parser links 1,559 symbols from the Go runtime, and the 28 MB skill-lint binary has 32,321 symbols in total. Following [Functional Core, Imperative Shell](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell), I left CLI flags, network calls, and file I/O to `go test -fuzz` and `go test -race`, and ran the solver only on pure functions and slice helpers extracted from file-reading code.\n\nOf the five functions I tested, two scanners came back `unsat` (including a CRLF line splitter with six surviving bounds checks), and the other three had bugs. The sharpest was in `ApplyReplacementChunks`, which validates LLM-generated `{StartLine, EndLine}` edits against the initial line count and sorts them bottom-to-top so earlier edits don't shift later line numbers:\n\n```\nfor _, c := range chunks {\n\tif c.StartLine < 1 || c.EndLine > len(lines) || c.StartLine > c.EndLine {\n\t\treturn nil\n\t}\n}\nsort.Slice(chunks, func(i, j int) bool {\n\treturn chunks[i].StartLine > chunks[j].StartLine\n})\nfor _, c := range chunks {\n\tlines = append(lines[:c.StartLine-1],\n\t\tappend(c.Replacement, lines[c.EndLine:]...)...)\n}\n```\n\nZ3 immediately returned `sat` because sorting by `StartLine` doesn't prevent two chunks from overlapping. In the extracted helper, which checks bounds once up front, replacing lines 3 and 4 of a 4-line file with one line shrinks `lines` to 3 elements, so a second chunk spanning lines 2 through 4 evaluates `lines[4:]` and panics with `slice bounds out of range [4:3]`. In the shipped `ApplyChunks`, a per-iteration bounds check turns that case into a misleading range error, while overlapping chunks that stay within `len(lines)` silently overwrite the earlier edit. Rejecting overlapping chunks after the sort fixes both.\n\nThe other two bugs were similar accounting errors. In the local fixer, target line numbers were checked once per violation before the inner chunk loop applied deletions that shrank the slice (a latent panic if a fixer ever returns a second chunk after a deletion, though `DeleteLineFix` only returns one today). In the prompt packer, a truncation check reserved a flat 100 bytes for a ~70-byte wrapper plus file path, overshooting the 2 MB context budget in the shipped code when a path exceeds about 30 bytes and panicking in the extracted helper.\n\nWhen you check a function modularly like this, the target has to include its own entry-point guards (like the bounds check and sort in `ApplyReplacementChunks`). If an internal helper assumes preconditions that are only enforced by its caller or stated in a doc comment, Z3 will happily invent inputs that violate them. Because each extracted helper included its entry guards, false positives weren't an issue: across all six buggy functions in my tests, the first `sat` assignment Z3 returned triggered a real panic in `go test` without needing to filter out non-crashing candidates or loop back into the solver.\n\nOnce I pulled these helpers away from file I/O, a 3-second fuzz run caught all three bugs too. To make sure Argon wasn't leaning on source comments in my test files, I also reran the skill-lint targets with all comments stripped; Argon and Z3 still caught all three bugs and returned `unsat` on the two scanners and all three patches.\n\nStaying entirely inside the target language makes a whole class of bugs hard for an agent to see. When an LLM reviews Go source directly, a line like `if int(total)+4 > len(buf)` looks like a textbook bounds check, and when it runs `go test -fuzz`, target-language runtime details like spare slice capacity or multi-step precondition checks can hide the crash indefinitely. Asking the agent to translate Go and assembly into a constraint solver forces a change of representation that strips away surface syntax and makes every bit width and slice bound explicit, while `go test` turns any solver hit back into a concrete Go reproducer.\n\nFor most Go code, fuzzing pure helpers with slice capacity clipped to length will catch bounds bugs in seconds. A solver is worth the extra minute on the traps that survive both the compiler's range prover and a quick fuzz run, especially in parsers and editors that compute offsets from multi-field headers or line ranges.\n\nSingle-threaded solver models don't see data races or deadlocks, so concurrent code still belongs with the [race detector](https://go.dev/doc/articles/race_detector?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026), [deterministic concurrency testing](https://go.dev/pkg/testing/synctest?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026), and Go 1.27's [goroutine leak profiles](https://go.dev/doc/go1.27?utm_team=DSS+DevRel&utm_campaign=default&utm_source=blog&utm_medium=article&utm_content=b583878026#goroutineleak-profiles).\n\nIf you're experimenting with solvers or coding agents on Go, let's compare notes on [X](https://x.com/kweinmeister) or [LinkedIn](https://www.linkedin.com/in/karlweinmeister/).", "url": "https://wpnews.pro/news/catch-go-bugs-that-fuzzing-misses-with-gemini-and-a-constraint-solver", "canonical_source": "https://developers.googleblog.com/catch-go-bugs-that-fuzzing-misses-with-gemini-and-a-constraint-solver/", "published_at": "2026-11-12 20:52:31.788696+00:00", "updated_at": "2026-11-12 20:52:33.957673+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-research"], "entities": ["Google", "Gemini 4 Argon", "Z3", "skill-lint", "Go", "Anthropic", "OSS-Fuzz", "Rocq"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/catch-go-bugs-that-fuzzing-misses-with-gemini-and-a-constraint-solver", "markdown": "https://wpnews.pro/news/catch-go-bugs-that-fuzzing-misses-with-gemini-and-a-constraint-solver.md", "text": "https://wpnews.pro/news/catch-go-bugs-that-fuzzing-misses-with-gemini-and-a-constraint-solver.txt", "jsonld": "https://wpnews.pro/news/catch-go-bugs-that-fuzzing-misses-with-gemini-and-a-constraint-solver.jsonld"}}