Skip to main content
loomcycle
§ story · v1.119.0 → v1.121.0

Two audits, 6.5 to 8: what an outside review found and how we answered it.

1. An independent review from the specialist we trust

For most of its life loomcycle has been reviewed by the people who build it. Every pull request goes through an automated code review and a security pass, and I read every line that lands. That catches a lot. It does not catch what the builders have stopped seeing.

So in early October an independent SRE reviewer read the whole runtime. Someone who runs his own cluster, builds his own operations tooling, and had no reason to be polite. He was looking at it as the core of a document-based reference system we are building, which means the question was not "is this a nice open-source project" but "would I put my data behind it".

The report came back in two parts: ten security risks, and a list of what was strong and what was weak. The grade was 6.5 out of 10 for internal and startup use, 4 out of 10 for regulated enterprise.

That is a fair grade. It is also not a grade you can leave standing.

2. What the reviewer praised

The strong column is worth quoting first, because it shaped how we answered the weak one.

None of that needed work. It told us the foundations were right, and that the fixes would be about seams, not about rebuilding.

3. What the reviewer flagged

No critical findings. Three medium, the rest low or already documented.

  1. Symlink TOCTOU in the file tools. Read, Edit and Write resolved symlinks, then opened the file with a plain os.Open. Between the two steps another run on the same writable volume could swap a path component for a symlink and step outside the volume.
  2. A vouched hostname skipped the private-address check. A name on the allowlist was trusted wherever it resolved, so a compromised DNS record for it could reach the internal network.
  3. Bash did not kill the process group. A comment promised it; the code had no Setpgid, no SysProcAttr, no WaitDelay. A grandchild holding stdout could hang cmd.Wait() past the timeout.
  4. A webhook name oracle. An unknown webhook answered 404 before authentication, a bad signature 401, so the difference told you which names exist.
  5. A bearer in Sec-WebSocket-Protocol, a header proxies like to log.
  6. An empty token pepper was allowed, so a database dump held bare SHA-256 hashes.
  7. The HTTP tool returned every response header to the model, Set-Cookie and X-Auth-* included.
  8. Open mode. With no auth token and no minted tokens, every route was unauthenticated, with only a warning at startup, even on a public address.
  9. $cred: values in the Bash environment, by design, which leaves isolation to the container around it.
  10. gbash v0.0.40, an alpha, as the only basis of the in-process sandbox.

The weak column was structural: main() at ~3,200 lines, runLoop at ~1,600, handleRuns at ~1000; a store interface with 404 methods; about 11,000 lines mirrored between SQLite and Postgres; five copies of run dispatch, with HTTP going around the shared path; 69 context setters instead of a typed policy; 617 log.Printf calls and no structured logs; no SECURITY.md, Dependabot or SAST; and a bus factor of one. This is a result of rapid development and tons of new features added recently.

4. Several days of coding

We answered with two lines of work in parallel, filed as RFCs so every change could point at the finding it closed: RFC ES for security, RFC ET for structure. Most of it shipped in v1.119.0, the rest in v1.120.0.

The security fixes, in the shape they finally took:

Working through finding 8 turned up one the reviewer had not seen. Whether authentication was on was decided by counting admin tokens in the store, and any error reading that count meant open mode. On an unpatched build, 39 of 50 no-bearer requests to /v1/_me from a client that half-closed its connection came back 200 with open_mode: true. A store error now fails closed.

The most useful finding of the week was one the audit did not list. It came from reading the code around a finding the audit did list.

Two more changes went in because the review made the shape of the runtime easier to see: operator authority never comes from inside a run, and a shutdown pauses the runs in flight so a restart resumes them.

On the structural side, POST /v1/runs now starts runs through the same path every other transport uses, logs can be JSON, and 25 context setters became one typed RunPolicy with an inheritance table that a test holds complete. SECURITY.md, Dependabot, CodeQL and govulncheck arrived the same week.

5. How do you check a status table you wrote yourself?

At the end of those two days we had a table with a green tick next to every security finding. A table written by the people who did the work is a claim, not evidence. So the next step was an independent re-audit of main, with one rule: nothing would be taken from our table.

The method:

They failed where they should. Before the file-tool fix, Read returned a file outside the volume 23 times in 5,000 swap attempts, Grep 59 times, Glob listed outside entries 32 times, and Edit rewrote an outside file and left 10 temp files behind. Before the Bash fix, every call with a one-second timeout returned after the full 60 seconds of sleep 60. Before the header fix, every credential value reached the tool result. On main all of them pass, five times each under the race detector.

The re-audit found one more gap of its own: the swap-race test skipped itself on a filesystem without atomic rename exchange, and CI does not run with -v, so a green package could hide a proof that never ran. A flag now turns that skip into a failure.

The re-grade: 8 out of 10 for internal and startup use, 6 out of 10 for regulated enterprise. Eight of the ten findings closed with reproducible runtime proof.

6. Where our own table was too optimistic

Two of our green ticks did not survive.

A status table is a claim. A test that fails on the old code is evidence.

Neither changed a verdict. Both changed how we write about our own work.

7. The residuals, closed in v1.121.0

The re-audit ended with a short ordered list. v1.121.0 closed it as RFC EU:

8. What remains

Some of the weak column did not move, and it is better to say so.

The first review took a week of someone else's attention. The answer took several days of intensive work. The re-audit took longer than either, because its job was to distrust the answer.

That order matters more than the numbers. A fix you have not tried to break is a hope. A grade that went from 6.5 to 8 is pleasant, but what it rests on is three tests that fail on last week's code and pass on this week's, and a table that now says a little less than it used to, because it says only what is true.

The next review will find something new. That is what it is for.