Security
What changed on 9 September
Fourteen changes in one day: the builder now looks at what it ships, four security foundations move to audited libraries, /ultracode runs real agents instead of describing them, and your models are reachable from your terminal.
The builder now opens what it built
The run reported success and gave out a link. The link showed an empty page. Three faults stacked up behind it, and none of them came from the model.
At the edge, a Cloudflare setting called Rocket Loader rewrote the page's type="module" script tag, so the browser never ran the bundle. It is off now. In the site, the hero image was referenced as /images/hero-dashboard.webp; a generated site is delivered under a sub-path, so a leading slash points at the root of the domain, not the root of the site. And our own file route answered every unknown path with the home page at status 200, which meant a missing image came back as HTML and any check asking "does this address answer?" got yes every time.
What it checks
After a site is published, a new step opens each page in a headless browser, waits for it to run, and reads what a visitor would see. A page with no visible text fails, whatever its file size. Every image, stylesheet, script and link is then requested at the address the site is really served from, and the answer is compared against the home page byte for byte. An address that answers is not the same as an address with something behind it.
Absolute paths it can repair, it repairs: rewrite the source, rebuild, check again, twice at most, and only accept a replacement that genuinely resolves. On the reported site it found ten faults across three pages in just over a second.
And it says no
If a page still does not render, the run ends as failed rather than as a result, and the report lists every finding with its address. A site that does not appear is not a site.
Missing files now return a real 404. Addresses without a file extension still fall back to the home page, which is what that fallback was for.
The site builder asks, and waits less
The builder stops for facts it cannot invent: a phone number, an address, opening hours. It asks, and it runs a clock, because a job waiting for an answer that never comes is the half-finished state nobody notices. When the clock runs out it infers the value or leaves a visible gap, and the report says which of the two it did.
What was missing was any way to say "that's everything". Four questions, all four answered, 64 seconds of nothing. Typing pushes the deadline out, which is right while you are typing and wrong once you have stopped.
Three buttons
Done ends the wait at once. Anything still open is treated exactly as it would be after the deadline, and declared the same way. The button skips the waiting, not the honesty.
Pause stops the clock while you look something up, because nobody types while searching in another window. The remaining time is kept and resumes where it left off. A hard ceiling still applies ten minutes after the question was asked, and the panel shows you when that is.
Edit gives you ten seconds after sending to change an answer. Before, the run continued the moment the last field was filled, so a typo went straight into the finished site. Each new answer starts the ten seconds again.
Reloading while questions are open now shows the answers you already gave, instead of empty fields that suggest they were lost.
German federal law is in the corpus
Legal research now covers German federal law: 6,127 acts from gesetze-im-internet.de, read into 5,961 documents and 107,129 passages. One document per act, one passage per section, so a retrieved paragraph opens with the number you can cite it by. A passage of German with no way back to the law it came from answers nothing.
The corpus now holds 1,111,401 documents: France 918,113, Italy 156,809, Switzerland 30,506, Germany 5,961, and a handful of EU, US and Canadian texts.
The source we did not take
Germany publishes a second, richer offering. It carries the same legislation in a newer format and, unlike the one we used, it also carries case law, two gigabytes of decisions. Its robots.txt is Disallow: /.
This corpus already excludes South Tyrol's LexBrowser and ten German states on exactly that instruction. A rule that applies to other publishers but not to a convenient one is not a rule. The snapshot was downloaded, unpacked, looked at, and deleted unread.
So German case law is missing, and it is missing for a reason we can name rather than one nobody wrote down. Changing that needs an answer from the publisher, not a workaround.
What the licence actually is
German federal law names no licence. It is usable because statutes are official works under ยง 5(1) UrhG and the federal open-data catalogue marks the dataset as open. That is enough to reuse the text and not enough to claim a grant, so both travel with every row: the statute as the licence, and the missing grant as a caveat. A citation that overstates its own licence is a quiet kind of wrong.
Waiting now looks like waiting
Ninety-two files in the interface call the server. Most of them said so while they waited. Some did not, and the gap only shows up on a slow connection, which is the one case where it matters.
Six panels returned nothing at all until their first answer arrived: account settings, users, anonymous chats, AI tools, routing rules, ultracode. An empty area is a statement, and during a load it is one nobody checked. They now show a placeholder the size of the content that is coming, so the page does not jump when it arrives.
Four actions ran without showing anything: duplicating a chat, deleting one, attaching and detaching a container. A menu item that does nothing visible gets clicked a second time, and duplicating a chat twice makes two chats. Those controls now close while they work.
The dashboard was the subtle one. While the first request was in flight the three spend tiles showed a dash, and a dash next to "Today" reads as "nothing spent today". That is the question people open the page to ask. It now shows a placeholder while the answer is unknown, and keeps the dash for when the request actually failed.
No invented progress
Nothing shows a percentage that is not measured. A bar that fills by feel is a claim about a number nobody has, and when it stops at 90% it is not believed again. Where there is a real number, pulling a workspace back from storage, the screen shows it.
The newsroom can be filtered
The newsroom is a single timeline, newest first, because the order is part of the information: how long nothing happened, what happened on the same day, how often something was corrected. That does not change.
What was missing was a way to read one thread of it. There is now a row of categories at the top with the count next to each, and only categories that actually have entries, a filter that leads to an empty page is a button that promises nothing.
They are links, not buttons. This page runs no JavaScript and is not getting any: it is text with dates. Picking a category is a normal page load, so it works with the back button, it can be bookmarked, and it works with scripting off.
The rail answers
Pointing at an entry now lifts it, very slightly, with the light spreading out from the tick on the rail rather than appearing around the row. The tick grows at the same time. The rail is the one distinctive thing on this page, so the movement comes out of it instead of arriving as a second distinctive thing beside it.
Nothing moves on load. Movement is an answer to something you did; a fade-in is just the text arriving late, and in the still frame a shared link shows, there would be nothing there. If your system asks for reduced motion, the states stay but the transitions do not.
/ultracode plans in seconds, not minutes
Before an /ultracode run fans out, ZeroLog runs the planner for real and shows
you the plan and a price. That call happened inside the request your browser was waiting on.
On the default model, the free rotation, that call took 109 seconds, measured. When the first plan came back too thin, a stricter second call followed. Our edge stops waiting after about a hundred seconds. The browser got HTTP 524 after 125 seconds: no plan, no run, and nothing on screen that explained it. The command looked dead.
Two things changed. The plan is now written by a separate fast model, while the run itself stays on the model you picked. Measured on the same task: 7.4 seconds end to end through the edge, against 125 seconds and a failure before. The confirmation card names the model that planned, so the two are never confused.
And the wait is now visible. While the planner runs, the chat says so instead of showing an empty screen. A silent minute is indistinguishable from a broken feature, which is exactly how this was reported to us.
ultracode now runs real agents, not drafts
Before, /ultracode split your task into subtasks and answered each one in prose. If a subtask needed a script, you got the script as text, never run. The plan looked finished; the work was not. Now each subtask runs as a real agent inside your container. It writes the files, runs the commands, and runs your tests. Two adversarial reviewers then check the actual files, and can run the tests themselves, before the result is accepted. If they find a defect, a fix agent edits the files and the reviewers look again. Each subtask works in its own directory, so parallel agents do not step on each other, and the skills you have deposited are available to them. A chat with no container still works the old way, in text. This costs more than a single prose answer, since real agents make more calls. A spend ceiling stops a run before it goes past your limit and pauses with what is done and what is left, so you can raise the ceiling and continue.use your models from your terminal
Until now your models lived in the web workspace. If you wanted the same models inside an editor or a terminal coding tool, there was no way in.
There is now. In Settings you get a personal token and a base address. Point any tool that speaks the OpenAI chat format at that address, set the token, and pick one of your models. The request runs on the same pool as the web app, at the same prices, and against the same spend limit on your account. Nothing new to top up, nothing separate to track.
We also make a terminal client of our own, zerocode, that is set up for this from the start. It runs on Windows from a plain command window, and it uses your token the same way. It reaches your account over the public address, so it works from your own machine, not just from a workspace.
Network drives now work on every workspace
Most workspaces do not sit on the same machine that hands you your files. Until now that split had one visible effect: if you tried to mount such a workspace as a network drive, it simply would not connect, and the error told you nothing useful. That is fixed. The file server now reaches those machines and serves their files over both SMB and WebDAV, read and write, exactly like a workspace on the main machine. You do nothing different: open the workspace, turn on sharing, and connect. There is one honest edge. If a worker machine is out of reach for a moment, you get a clear message rather than an empty folder. Your files are not gone. They are on the other machine, and they come back when it does.Passwords now use Argon2id
Until today ZeroLog hashed passwords with bcryptjs. It is written in pure JavaScript, and one comparison at cost 10 occupied the single event loop of the server process for as long as it ran. Measured on our own machine: 91.2 ms, during which nothing else was answered.
Passwords now use Argon2id through a native binding. The same measurement, taken the same way: 84.1 ms, and the event loop kept running throughout. Signing in no longer blocks anybody else.
The parameters were not copied from a recommendation list. They were measured here. Sixty four mebibytes of memory and two passes land seven milliseconds away from the old procedure. That is deliberate. While both formats sit side by side, a visible difference in response time would tell an attacker which procedure ran, and therefore whether an account exists. Seven milliseconds disappears in the noise of a network round trip.
Existing hashes are not converted in bulk, because that would need the plaintext password. They move on the next successful sign in, at the one moment when the password is both present and known to be correct. If that rewrite fails, the old hash stays valid and the sign in still counts. A failed migration must never lock anyone out.
A wrong password leaves the stored hash untouched. We tested that on purpose, because a migration that can be triggered by a stranger is a way to destroy other people's credentials.
Dependencies update themselves now
The operating system, the intrusion rules, the block lists and the price tables on our server have updated themselves for a while. The libraries the application is built from did not. Measured today: twelve packages behind, and eleven known vulnerabilities in them, six rated high.
That gap matters more than it looks. The reason to build on maintained libraries rather than our own code is that flaws in them get published and fixed. A library nobody updates gives you the disadvantage of foreign code without the advantage of foreign maintenance.
From now on a weekly job updates them. It stays inside the version ranges the project already declares, so it fixes and improves without jumping across major versions. A nightly job that decides on its own to move a web framework from version four to five is not maintenance. It is an outage with a timer on it.
Every update has to pass the test suite before it is kept. The gate does not demand that everything is green, because one test is currently red for an unrelated reason, and a gate like that would quietly revert every update forever. It compares against a recorded starting point instead: no test file may start failing that was not already failing. If one does, the change is rolled back and left for a person.
The job does not restart the service. A running application holds its modules in memory, so a new version takes effect at a moment somebody chooses. The report says so in as many words.
First real run: eleven vulnerabilities down to seven, three of them still rated high.
Google sign-in now uses PKCE
Signing in with Google returns a short-lived authorisation code to us. Until today three
things protected that return trip: a single-use state, a __Host- cookie tying it
to the browser that started it, and a nonce tying the identity token to the same request.
What was missing was a binding between the CODE itself and the client redeeming it.
That gap is not theoretical. It was the basis of four bypass advisories against a widely used authentication framework in 2026. Anyone who obtained the code in transit, from a log, a referrer, an intermediate hop, could redeem it.
The authorisation request now carries a challenge, and the exchange carries the verifier that
produced it, with code_challenge_method=S256. The verifier never leaves the server; only
its SHA-256 goes out. A code redeemed without it is refused by Google.
The request is built and redeemed by arctic, an MIT-licensed library maintained in the open. What was NOT handed over: every claim check on the returned token (issuer, audience, expiry, nonce), the browser binding, the single use of the state, and the version of the terms accepted. arctic fetches the token. Whether it may be believed is still decided here.
A sign-in that began before the change and has not finished carries no verifier. Those requests are asked to start again rather than let through. They live ten minutes; after that the case does not arise. Redeeming a code without a verifier would have meant PKCE could be switched off by anyone who presented an old state.
Two-factor codes now run on a standard library
Six-digit codes from an authenticator app follow RFC 6238. The arithmetic behind them, counter, HMAC, dynamic truncation, base32, was written here and worked. It was also code that nobody outside this project had ever reviewed, in the one place where a subtle mistake is worth the most.
The arithmetic now comes from otpauth. Same standard, same digits, same window.
One part was deliberately not handed over: a code that has already been used must not work a second time. That needs memory of which step a given account last consumed, which lives in our database, not in a library. The check stayed where the knowledge is.
The existing test vectors from RFC 6238 still pass, as does the replay test. That was the point of having them.
Download your data, delete your account
The policy lists your rights under the FADP and the GDPR: a copy of your data, portability, erasure. It said to write to us, and that a response follows within 30 days. It also said something else, in the same paragraph: requests are honoured by proving control of the account, which means logging in. If signing in is the proof of identity, the signed-in account can serve itself.
The copy
Settings, under Account, has My data. It gives you a JSON file with everything this installation holds under your account. One rule holds it together: every table carrying a user id is either in the file or explicitly named as an exception, with a reason. There are 96 of them. A file that quietly skips one would look complete, carry a date, and be wrong in a way you could never notice.
Encrypted content is included. Your vault and your encrypted memory are your data, and you hold the key. Secrets are not: password hashes, session tokens, your TOTP secret. A downloaded file containing those is a set of instructions for taking the account over, and Art. 15(4) GDPR draws exactly that line. The file names the columns it withheld rather than dropping them silently.
The deletion
Before anything happens you see what would go: the tables, with row counts read from the database, and which ones stay. A dialog that only asks whether you are sure gets clicked away unread.
Accounting records stay for ten years with your account id removed, because Art. 958f CO requires them and an amount with a name attached would be more than the law asks for. Running containers are not deleted with the account, and the screen says so first. Then two locks: type your username, and enter your password. Being signed in proves a session exists, not that you are the one at the keyboard.