{"Canonical":"https://corpberry.com/blog","Desc":"Occasional technical writeups by Stas: bot detection, Go, and the tools on this site.","Feed":"https://corpberry.com/blog/feed.xml","Posts":[{"Slug":"the-bug-is-still-there","Title":"The bug is still there. There's just more code around it now.","Date":"2026-07-30T00:00:00Z","Desc":"I lost my temper at a coding agent for fixing a bug by burying it under four patches, then went looking for research on whether it's a real pattern. It is, it has a name, and most of the advice about it measures worse than nothing.","Image":"/static/img/patch-on-patch.png","Draft":false,"HTML":"\u003cp\u003e\u003cimg src=\"/static/img/patch-on-patch.png\" alt=\"A git diff titled \u0026quot;fix: stale value returned from cache\u0026quot;, showing four stacked patches added and zero lines deleted, with the original buggy line untouched at the bottom\"\u003e\u003c/p\u003e\n\u003cp\u003eLast week I asked an agent to fix one bug in a worker that reads from a cache.\nWhat came back was four patches stacked on top of each other like a wedding cake,\nand at the bottom, untouched, still breathing, was the line that caused the whole\nthing.\u003c/p\u003e\n\u003cp\u003ePatch one cleared the cache when the entry looked stale. Patch two handled clearing it\nracing the writer. Patch three initialised the lock patch two assumed existed. Patch four\ngave up after three tries and returned \u003ccode\u003e{\u0026quot;ok\u0026quot;: True, \u0026quot;stale\u0026quot;: True}\u003c/code\u003e, so the caller would\nstop asking.\u003c/p\u003e\n\u003cp\u003eForty-one lines added. Zero deleted. All tests green.\u003c/p\u003e\n\u003cp\u003eI typed something at it that I wouldn't say to a person. Lightly cleaned up,\nbecause my English gets worse in proportion to my blood pressure:\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eso you decided to add complexity, add code, add variables, add fucking\nworkarounds, add patches. Do you want to cause me harm? Make me suffer? Give\nme more work in the future to support those patches? Maybe do your job and\nwrite the code properly, from zero, to decrease complexity?\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eI'm 37. I've known for years that shouting at software fixes nothing. I did it anyway, at\ntwo in the morning, in a repo I write for fun on weekends, which is the part that annoys\nme most. Then I went to check whether I was being unfair.\u003c/p\u003e\n\u003cp\u003eI was, a bit. But less than I expected.\u003c/p\u003e\n\u003cp\u003eTwo claims are in play and I only get to keep one. The strong one, that AI makes\nrepositories grow, I can't support and will hand back later. The narrow one is the title:\naround the line that caused this bug there is now more code than there was, and less\nchance anyone makes the four things one thing. A repo can shrink on net while every bug\nsite quietly gets fatter.\u003c/p\u003e\n\u003cp\u003eMy prompt was also garbage. A stack trace and one sentence, no invariant. Skill issue,\nand there's a number on how much of one further down. What kept me reading is that\nthis isn't a story about one bad prompt.\u003c/p\u003e\n\u003ch2\u003eThe thing has a name\u003c/h2\u003e\n\u003cp\u003eIn May a group at ETH Zurich published a benchmark that does something I'd never thought\nto do. It gives coding agents 200 tasks where the correct answer is to do nothing. The\nbug is already fixed. The code is fine. Go ahead.\u003c/p\u003e\n\u003cp\u003eFive models across four frameworks changed the code anyway in 35 to 65% of those cases.\nEven the best of them touched code it should have left alone about a third of the time.\nThe diagnosis is the line I'd been looking for since Tuesday:\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eour results indicate that LLMs fall prey to an action bias: they choose to act\neven if inaction would be appropriate\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eThat's \u003ca href=\"https://arxiv.org/abs/2605.07769\"\u003eCoding Agents Don't Know When to Act\u003c/a\u003e.\nHand a model working code and tell it to fix the bug, and half the time it finds\nsomething to do to it, because doing something is what it's for.\u003c/p\u003e\n\u003cp\u003eIBM then looked at what happens when you let an agent iterate a patch against tests until\nthey pass, which is the loop most of us run all day. Overfitting got worse. Their figure 1\nis my Tuesday: refinement took a real fix and replaced it with a \u003ccode\u003etry\u003c/code\u003e/\u003ccode\u003eexcept\u003c/code\u003e that\nswallows the error and returns a placeholder. Passed the generated test, failed the real\none. Of 22 patches refinement rescued into passing, 14 failed the hidden tests\n(\u003ca href=\"https://arxiv.org/abs/2511.16858\"\u003eIBM\u003c/a\u003e). Offered the code or the test to edit, those\nagents went for the code six times out of seven, because the model \u0026quot;believes that the\ntests are mostly perfect.\u0026quot;\u003c/p\u003e\n\u003cp\u003eAnd the direction it diverges isn't random. Of the patches SWE-bench Verified calls\nsolved, 29.6% behave differently from what the developer wrote, and those \u0026quot;tend to\nintroduce additional changes rather than omitting necessary changes\u0026quot;\n(\u003ca href=\"https://arxiv.org/abs/2503.15223\"\u003ePatchDiff\u003c/a\u003e).\u003c/p\u003e\n\u003cp\u003eFine. I feel seen. The interesting question is why.\u003c/p\u003e\n\u003ch2\u003eWhy it does it\u003c/h2\u003e\n\u003cp\u003eThe oracle is \u0026quot;tests pass,\u0026quot; not \u0026quot;design improved.\u0026quot; This is the whole thing. The\ncheapest way to make a failure signal stop is to stop the signal, and nothing in the\nloop distinguishes a fix from a well-placed \u003ccode\u003eexcept\u003c/code\u003e. The IBM result is the proof: the\nmore you iterate against tests, the more of your fixes become suppressions.\u003c/p\u003e\n\u003cp\u003eIt can see the crash site but not the invariant. One taxonomy of repair failures has a\ncategory for exactly this: everything needed for the fix is in the codebase, and none\nof it is visible from the method body the model was handed. From inside a 40-line\nwindow, adding a guard is the only move the model can actually see.\u003c/p\u003e\n\u003cp\u003eAction bias, which isn't the same thing. \u0026quot;No change needed\u0026quot; isn't in the action space\nthe model believes it has, and the fact that this responds so strongly to phrasing\nsuggests a learned habit rather than a ceiling.\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://lucumr.pocoo.org/2026/6/23/the-coming-loop/\"\u003eArmin Ronacher\u003c/a\u003e gets at the\nshape of it better than I can, writing about present-day models being \u0026quot;too\ndefensive, too complex, too local in its reasoning,\u0026quot; and then landing the actual\nprinciple: they \u0026quot;add fallbacks instead of making bad states impossible.\u0026quot;\u003c/p\u003e\n\u003cp\u003eThat's my complaint, stated properly. The problem was never that it added code.\n\u003cstrong\u003eYou added a defence at a distance from the invariant it was compensating for.\u003c/strong\u003e Patch three existed to\nserve patch two. None of them knew why patch one was there.\u003c/p\u003e\n\u003cp\u003eAnthropic's own docs warn that a reviewer agent told to find gaps will find them, and that chasing all of them \u0026quot;leads to\nover-engineering: extra abstraction layers, defensive code, and tests for cases that\ncan't happen.\u0026quot; The vendor documented my bug.\u003c/p\u003e\n\u003ch2\u003eWhere I'm wrong\u003c/h2\u003e\n\u003cp\u003eNobody has measured the thing I actually said. No study separates bug-fix patches from\nfeatures and compares net lines added by a model against the human who fixed the same bug.\nI went looking three times. Everything above is mechanism and proxy, and one good study\ncould take it apart.\u003c/p\u003e\n\u003cp\u003eThe aggregate version doesn't survive either, and neither does the number I thought had\nkilled it. Faros AI has the ratio of lines deleted to lines added up 861% under heavy AI\nuse, across 22,000 developers. I read that as the end of this post for about an hour. A\nratio moving isn't a codebase shrinking, though: 0.05 to 0.48 is an 861% rise and still\ntwo lines added for every one removed, and Faros won't say which it is. Agent pull\nrequests are also \u003cem\u003esmaller\u003c/em\u003e than human ones, across \u003ca href=\"https://arxiv.org/abs/2601.17581\"\u003e29,000 of\nthem\u003c/a\u003e, though humans remove more and range wider.\u003c/p\u003e\n\u003cp\u003eDeletion is fine, then. I aimed my rant at the wrong number. What has stopped happening\nis the operation where somebody notices four things are the same thing and makes them\none thing. GitClear puts moved code, their proxy for refactoring, at 21% of changed lines\nin 2022 and 3.8% in 2026. And across \u003ca href=\"https://arxiv.org/abs/2511.04824\"\u003e15,451 agentic\nrefactorings\u003c/a\u003e, duplication was the stated purpose 1.1%\nof the time against 13.7% for humans. Agents refactor. They rename.\u003c/p\u003e\n\u003cp\u003eThe number I want most is GitClear's \u0026quot;error-masking constructs, +47%,\u0026quot; their name for\ndefensive idioms: rescues, null checks, mock guards. I want it badly enough that I should\nsay the rest out loud. GitClear sells code analytics and the definition sits behind a\nregistration wall. So I have a number I love, from a vendor, that I can't check.\u003c/p\u003e\n\u003cp\u003eAddition isn't the vice, either. A guard is often right, and the repair tools before\nLLMs had the opposite bug, deleting whatever made the test fail. None of this is new:\nHacker News has \u0026quot;afraid to delete code\u0026quot; comments going back a decade. What's new is\nthat it's free, instant, and carries no memory of why. A human's band-aid has a person\nattached who remembers. An agent's has a commit message, and it lands in a review queue that AI\nalready spent.\u003c/p\u003e\n\u003ch2\u003eRanked by whether there's any evidence\u003c/h2\u003e\n\u003cp\u003eWhich means the ones I like most are near the bottom. The most useful finding here is\na negative result, so it goes first: giving an agent test-first instructions and\nchanging nothing else measured \u003cem\u003eworse\u003c/em\u003e than giving it none at all. Most of what you and\nI tell agents, including my own instruction file, is that kind of advice.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e1. Give it a map of what it's about to break.\u003c/strong\u003e Hand the agent a plain text file\nlisting which tests and callers touch the code it's about to change. Regressions fell\nby two thirds (\u003ca href=\"https://arxiv.org/abs/2603.17973\"\u003eTDAD\u003c/a\u003e). Small study, two open-weight\nmodels, and still the best-designed thing I found, which tells you plenty. Its\nconclusion is the one I'd tape above my monitor: surfacing contextual information beats\nprescribing procedural workflows. Most of us are prescribing.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Say out loud that \u0026quot;nothing needs to change\u0026quot; is an allowed answer.\u003c/strong\u003e Cheapest\nthing on this list and I had never done it. Every prompt any of us writes starts\nfrom \u003cem\u003efix this\u003c/em\u003e, which quietly forbids the correct answer to a fair share of\ntickets. Telling the model abstaining is legitimate moved one from 60.5% to 88.5%. So\nwrite the permission down: \u003ccode\u003eIf the code is already correct, say so and stop.\u003c/code\u003e\u003c/p\u003e\n\u003cp\u003eThe lever has an ugly other end, which I should report since I'm leaning on it. Where\na previous agent's fix was genuinely wrong, that same framing pushed two models into\nreturning empty patches most of the time. So it moves the bias rather than removing\nit.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Put the rules you care about in a hook.\u003c/strong\u003e Anthropic's docs draw the line: CLAUDE.md\ninstructions are advisory, hooks are deterministic. And a git hook stops \u003cem\u003eme\u003c/em\u003e before the\npush, which is where the real teeth are, because the agent isn't the only one here who\nreaches for a quiet \u003ccode\u003eexcept\u003c/code\u003e at midnight. My pre-push hook already runs \u003ccode\u003ego vet\u003c/code\u003e and \u003ccode\u003ego test\u003c/code\u003e. The rule I care about is four more lines:\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-sh\"\u003eif git diff origin/master...HEAD -U0 | grep -nE '^\\+.*(except:[[:space:]]*$|_ = err)'; then\n  echo \u0026quot;new silent swallow. name the error or fix the invariant.\u0026quot; \u0026gt;\u0026amp;2\n  exit 1\nfi\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eCrude, false-positive-prone, and \u003ccode\u003e--no-verify\u003c/code\u003e exists for when it's wrong. It has\nstill caught more than the paragraph in my markdown file ever did, because the\nmarkdown file asks and this one refuses.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Harden the oracle.\u003c/strong\u003e If a weak test suite is what lets a suppression pass,\nstrengthen the suite instead of nagging the model. Mutation testing is the\nmechanically correct answer and it's no longer academic: Meta runs an LLM-driven\nversion in production, where engineers kept 73% of the generated tests. Their mutants\ntargeted privacy faults, not band-aid patches, so the transfer to my problem is my\nargument and not their measurement. Still, a guard that swallows an error will survive\na weak suite and die to a mutant.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e5. Read the plan. You aren't going to read the diff.\u003c/strong\u003e Dex Horthy's version is the\none everybody quotes and it holds: he can't read 2,000 lines of Go a day, he can read\n200 lines of a plan. Neither can I, and the plan is where the invariant gets named or\ndoesn't. Mixed evidence though, because a bad plan measures worse than no plan at all. The model\nfollows it off the cliff.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e6. After two failed corrections, throw the session away.\u003c/strong\u003e The one piece of vendor advice\nwith a mechanism under it: if refinement loops convert real fixes into swallowing\n\u003ccode\u003etry\u003c/code\u003e/\u003ccode\u003eexcept\u003c/code\u003e, the loop is the hazard. I've never once regretted doing this and I still\nforget.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e7. Measure your own repo.\u003c/strong\u003e GitClear says churn rose. \u003ca href=\"https://arxiv.org/abs/2606.13298\"\u003eA causal study of 151 Java\nrepos\u003c/a\u003e says it fell, while finding lines of code up\n12.8% and smells flat. Faros says the deleted-to-added ratio is up 861%. All three\ndefine churn differently, so stop arguing about their numbers and generate your own:\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-sh\"\u003egit log --first-parent HEAD --numstat --date=short --after=2025-01-01 \\\n  --pretty=format:'--%h--%ad--%aN' \\\n| awk -F'\\t' '\n  /^--/ { split($0, h, \u0026quot;--\u0026quot;); m = substr(h[3], 1, 7); next }\n  NF==3 \u0026amp;\u0026amp; $1 != \u0026quot;-\u0026quot; { a[m]+=$1; d[m]+=$2 }\n  END { for (k in a) printf \u0026quot;%s  +%-7d -%-7d  net %+d\\n\u0026quot;, k, a[k], d[k], a[k]-d[k] }\n' | sort\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eOne line per month, added, deleted, net. Leave rename detection on, because I got that\nwrong first: \u003ccode\u003e--no-renames\u003c/code\u003e counts every file move as a whole delete plus a whole add,\nwhich pumps both columns and hides what you're looking for.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e8. Schedule a deletion pass.\u003c/strong\u003e No evidence, just arithmetic. A standing pass whose\nonly permitted output is removal, no features in the same commit. You can now pay a\nconsultancy ten thousand a week to send three senior engineers in to delete things, so\nsomebody has already turned this bullet into a pricing model.\u003c/p\u003e\n\u003ch2\u003eThe rule I wrote anyway\u003c/h2\u003e\n\u003cp\u003eWeakest tier, and I'll say so. Bloated instruction files get ignored, and vendors\nalready ship this instruction: Anthropic's own phrasing is \u0026quot;address the root cause,\ndon't suppress the error.\u0026quot; It ships in the docs and I still got four patches. I wrote\nmine anyway, six bullets, in the file I've just told you gets ignored. I hear it.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eNO PATCHES, rewrite what produces the bug\u003c/strong\u003e (peer of DRY/KISS/YAGNI). Never\nfix a bug by layering a compensating mechanism over it. A fix that is visibly\nbolted on is not done.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eBefore writing any code, state what gets \u003cstrong\u003edeleted\u003c/strong\u003e. \u0026quot;Nothing deleted, net\nlines added\u0026quot; means the design is wrong. Rethink it first.\u003c/li\u003e\n\u003cli\u003eAsk why the buggy code exists at all. Removing a component beats correcting\nit.\u003c/li\u003e\n\u003cli\u003eAsk which component owns the truth being corrupted and fix it there. One\nowner, one write. Don't repair downstream what an upstream line got wrong.\u003c/li\u003e\n\u003cli\u003eStop if: a flag exists only to undo prior behaviour, a write exists only to\nrepair earlier state, or plumbing is threaded through call sites just to\nreach the correction.\u003c/li\u003e\n\u003cli\u003eReport size honestly. Never frame growth as cleanup.\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eThe first bullet is the one that earns its place. \u0026quot;State what gets deleted\u0026quot; is the only\nline in there a model can't satisfy by adding something.\u003c/p\u003e\n\u003ch2\u003eThe absurd part\u003c/h2\u003e\n\u003cp\u003eWhat gets me isn't the patches.\u003c/p\u003e\n\u003cp\u003eI've got a model that can read a paper, find the flaw in my reasoning, and write better\nGo than I did at 30. I asked it to fix a cache bug and it built four layers of defence\naround a line it never questioned. Then I swore at it, and it said\n\u0026quot;You're absolutely right,\u0026quot; which is what it says whether I am or not, and then it did\nthe correct thing, which was to ask whether the cache should exist at all.\u003c/p\u003e\n\u003cp\u003eThe capability was in there the whole time. What was missing was permission to delete\nsomething, and what granted it was a 37-year-old man losing his composure at 2am.\u003c/p\u003e\n\u003cp\u003eThe patches aren't new either. I've seen that exact shape in a PHP file from 2009,\nwritten by a contractor who is probably a director somewhere now. Retry, sleep, swallow,\nreturn true. The difference is that took a week and cost money, and I got it in nine\nseconds from something that had just explained Go's memory model better than the book\ndid.\u003c/p\u003e\n\u003cp\u003eTwenty years of tooling, and my best debugging technique is still: get annoyed enough to\nask why the code exists.\u003c/p\u003e\n"},{"Slug":"reverse-engineering-12-bot-detectors","Title":"What I learned reverse-engineering 12 commercial bot detectors (and the open-source checker I built from it)","Date":"2026-07-28T00:00:00Z","Desc":"I studied 12 bot detectors firsthand and built Bot check — a free, open-source, 68-signal bot-detection self-test that shows every signal and exactly how it scored you. Here's what I found.","Image":"","Draft":false,"HTML":"\u003cp\u003e\u003cimg src=\"/static/img/botcheck-demo.gif\" alt=\"Bot check scoring a browser in real time, with a per-signal breakdown\"\u003e\u003c/p\u003e\n\u003cp\u003eI run a small collection of tools at corpberry.com. The one I've put the most\ninto is Bot check: a live score of how much your browser looks like a human\nversus an automated bot. It's free, open source, and it shows every signal it\nuses. This post is about why I built it that way, and what I found when I studied\nhow the commercial detectors actually work.\u003c/p\u003e\n\u003ch2\u003eThe thesis: a browser can claim anything\u003c/h2\u003e\n\u003cp\u003eEvery signal a browser reports over JavaScript is spoofable. User-agent,\nplatform, screen size, the \u003ccode\u003enavigator\u003c/code\u003e object, canvas output, WebGL vendor: a\ndetermined script can lie about all of it, and stealth tooling like\npuppeteer-extra-plugin-stealth does exactly that, well.\u003c/p\u003e\n\u003cp\u003eSo trusting what the browser \u003cem\u003eclaims\u003c/em\u003e is a losing game. The detection power is in\nthe cross-check: compare what the browser says against what the connection and\nthe deeper fingerprint actually reveal, and look for the disagreements. A\nheadless browser can tell you it's Chrome on Windows. It's much harder for it to\nmake that claim survive its HTTP header order, its IP reputation, and the way its\nJavaScript engine feature-tests as Blink-versus-Gecko all agreeing.\u003c/p\u003e\n\u003cp\u003eBot check runs 68 tiered checks on exactly that principle. It fuses:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ea client-side JavaScript fingerprint,\u003c/li\u003e\n\u003cli\u003eserver-observed HTTP headers,\u003c/li\u003e\n\u003cli\u003eand IP reputation (datacenter / VPN / proxy / Tor),\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003ethen cross-checks the three and shows you every signal, why it fired, and how\nmuch it moved the score. Verdict comes out as human, suspicious, bot, or\n\u0026quot;good-bot\u0026quot; for a verified crawler like Googlebot.\u003c/p\u003e\n\u003ch2\u003eWhat the commercial services do well\u003c/h2\u003e\n\u003cp\u003eTo build it I studied 12 public detectors firsthand: CreepJS, FingerprintJS,\nDataDome, BrowserScan, iphey, pixelscan, sannysoft, whoer, AmIUnique, EFF's\nCover Your Tracks, incolumitas, and deviceandbrowserinfo. A few things stood out:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eThe best ones feature-detect the real engine.\u003c/strong\u003e Instead of trusting the UA\nstring, iphey's engine (the open-source MixVisit core) probes for APIs only a\ngiven engine has: \u003ccode\u003ewebkitResolveLocalFileSystemURL\u003c/code\u003e + \u003ccode\u003eBatteryManager\u003c/code\u003e +\n\u003ccode\u003enavigator.vendor\u003c/code\u003e for Chromium, \u003ccode\u003ebuildID\u003c/code\u003e + \u003ccode\u003eonmozfullscreenchange\u003c/code\u003e for\nGecko, \u003ccode\u003eApplePayError\u003c/code\u003e for WebKit — then compares the answer to what the UA\nclaims. Bot check ships its own version of this: one check fingerprints the\nJS VM itself from the \u003ccode\u003eError\u003c/code\u003e stack format (V8's \u003ccode\u003eat\u003c/code\u003e frames vs\nSpiderMonkey's \u003ccode\u003efn@url\u003c/code\u003e frames and proprietary \u003ccode\u003efileName\u003c/code\u003e/\u003ccode\u003elineNumber\u003c/code\u003e). When\nI pointed a browser claiming to be Firefox at it, the real engine stayed V8 —\nand the check fired.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCreepJS goes deep on lie detection.\u003c/strong\u003e It doesn't just check \u003ccode\u003etoString\u003c/code\u003e for\n\u003ccode\u003e[native code]\u003c/code\u003e; per API function it checks illegal own-properties and\ndescriptors (\u003ccode\u003eprototype\u003c/code\u003e/\u003ccode\u003earguments\u003c/code\u003e/\u003ccode\u003ecaller\u003c/code\u003e), and traps whether calling,\n\u003ccode\u003enew\u003c/code\u003e, \u003ccode\u003eapply\u003c/code\u003e, or class-\u003ccode\u003eextends\u003c/code\u003e throws the correct \u003ccode\u003eTypeError\u003c/code\u003e. The\nsobering part: I built six checks specifically targeting the patches\npuppeteer-extra-plugin-stealth installs — and stealth 2.11.2 evaded all six,\ncleanly, including hiding \u003ccode\u003enavigator.webdriver\u003c/code\u003e in the main thread, iframes,\n\u003cem\u003eand\u003c/em\u003e Service Workers. What caught it instead was three boring cross-context\nconsistency checks (UA, CPU cores, and WebGL renderer disagreeing between\ncontexts) — enough for a 25/100 \u0026quot;bot\u0026quot;. Then it got better: a single illegal\ncall, \u003ccode\u003eFunction.prototype.toString.call(null)\u003c/code\u003e, leaked stealth's Proxy\nthrough a raw stack frame, because a recent V8 stack-format change silently\nbroke stealth's own stack-stripping logic. With that check in, stealth's\nscore dropped from 25 to 0.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThe unforgeable layer is the network.\u003c/strong\u003e The edge vendors cross-check the\nTLS/TCP/HTTP2 handshake against the claimed browser. That's the one class of\nsignal a JavaScript spoofer can't touch from the page. It's also the one I\nstructurally can't see: Cloudflare terminates the visitor's TLS at its own\nedge and opens a \u003cem\u003eseparate\u003c/em\u003e connection to my origin, so the ClientHello my\nserver sees is Cloudflare's, identical for every visitor. (Cloudflare will\nsell you the real fingerprint as a header — gated behind Enterprise Bot\nManagement, which is a steep price for a personal tool.) I say so plainly on\nthe page instead of pretending otherwise.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eHonesty is a feature.\u003c/strong\u003e deviceandbrowserinfo states outright that its verdict\ndoesn't use IP or behavior; incolumitas warns \u0026quot;false positives are expected.\u0026quot;\nThat candor is what makes a checker trustworthy as a reference.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2\u003eWhat it catches in practice\u003c/h2\u003e\n\u003cp\u003eI ran the real thing against five automation setups. Playwright headless\nChromium: 0/100 — \u003ccode\u003ewebdriver\u003c/code\u003e, the \u003ccode\u003eHeadlessChrome\u003c/code\u003e UA, and the SwiftShader\nsoftware renderer gave it away. Selenium + chromedriver: 0/100 — plus\nchromedriver's classic \u003ccode\u003e$cdc_...\u003c/code\u003e global markers, all seven of them. The\nhumbling one was a hand-rolled raw-CDP Chromium with no automation flags: 40/100,\ncaught almost entirely by its \u003ccode\u003eHeadlessChrome\u003c/code\u003e user-agent while every\narchitectural check read clean. A disciplined custom client evades nearly\neverything client-side, and I've documented that as an accepted gap rather than\npretending the score is magic.\u003c/p\u003e\n\u003ch2\u003eWhy open source, why transparent\u003c/h2\u003e\n\u003cp\u003eMost polished checkers are either closed demos for a paid product or opaque about\nhow the number is computed. I wanted the opposite: a checker you can read. The\nscorer is open source, every signal is shown with a plain reason, and the repo\nincludes my writeups of all 12 services. A low score means \u0026quot;looks automated,\u0026quot;\nnot \u0026quot;is malicious,\u0026quot; and privacy-hardened or VPN users will score lower by design.\nIt stores nothing but a one-way hash of the stable fingerprint plus IP, on a\n30-day rolling window, purely to spot the same fingerprint reused across many\nIPs. It blocks nothing. It's a mirror, not a firewall.\u003c/p\u003e\n\u003ch2\u003eThe boring-but-nice engineering bit\u003c/h2\u003e\n\u003cp\u003eThe whole site, portfolio plus every tool, is one Go binary that routes by\nsubdomain. Server-rendered HTML with htmx and Alpine, and zero Node: Tailwind\nruns via its standalone CLI and the little JS is vendored. Every endpoint\ncontent-negotiates, so every tool is also a curl-able JSON API for free. I'm a\nPython backend dev by day; Go's \u0026quot;one static binary, no runtime\u0026quot; story is why\nthis is one \u003ccode\u003edocker compose up\u003c/code\u003e instead of a stack.\u003c/p\u003e\n\u003ch2\u003eTry it / read it\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eTool: \u003ca href=\"https://botcheck.corpberry.com\"\u003ehttps://botcheck.corpberry.com\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003ePoint a headless browser at it and watch it get caught, signal by signal.\u003c/li\u003e\n\u003cli\u003eCode + the 12-service research: \u003ca href=\"https://github.com/Landver/site-of-tools\"\u003ehttps://github.com/Landver/site-of-tools\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eI'd genuinely like to know what it flags on your setup, and where it's wrong.\u003c/p\u003e\n"}],"Title":"Blog — corpberry.com"}
