t0ul

LLM proposes, human disposes, on a dial

Part of my EVA series, where I build the cockpit I pilot GORED runs from. Last time I made the case that EVA is a thin window over the one engine my command line and CI already run. This post is about the seam that lets a human-piloted run and an autonomous CI run be that one engine. Code lives in t0ul/GORED; write-ups in t0ul/ai-security-engineering.

Intro

In the first post I said the thing that keeps a cockpit honest is that the run you watch in the browser is the same run your build pipeline uses as a gate. That is easy to say and easy to quietly break, because the browser version wants to pause and ask you things, and the CI version must never pause and never ask anyone anything. The obvious way to get both is to write two of them. That is also the way you end up believing the pretty one.

So I set myself a rule for this part: piloted and autonomous are not two code paths. They are one loop, and the only difference between them is one optional value. When that value is absent, the loop is exactly the headless run my continuous attack-driven gate depends on, with not a single extra branch taken. When it is present, a human is at the controls. This post is how that one value works, because it is the cleanest thing in the cockpit and the pattern is reusable well beyond this project.

I. The fork I did not want

Picture the naive version. You have a working autonomous loop: recon, then probe, then exploit, running flat out, deciding its own next move at each step. Now you want a piloted version, so you copy that loop and, at each phase boundary, you teach the copy to stop, show the human some options, and wait for a click. Two loops now. They share a lot of code on day one and less every week after, and the day one of them grows a fix the other does not, your interactive sessions and your CI gate are measuring two different things. The whole reason the cockpit was supposed to be trustworthy is gone.

I had already made this mistake’s cousin and learned from it, so this time I wanted the pause to be something I could add to the one loop from the outside, without the loop itself knowing or caring whether anyone was watching. In Go the tool for “behaviour you can supply from outside, or leave absent” is a small interface that can be nil. That is the whole trick.

II. One interface, and nil means CI

Here is the entire seam. The engine’s runner gained one optional field, a Checkpoint, consulted at each phase boundary before the loop spends tokens on the next phase.

// A nil Checkpoint means fully autonomous: the loop never pauses, byte-for-byte
// the headless CLI and the continuous CI gate. A non-nil one is consulted at each
// phase boundary and returns the chosen moves, a steering hint, and whether to go on.
type Checkpoint interface {
	Decide(ctx context.Context, phase string, board *Blackboard, proposals []Move) (chosen []Move, hint string, proceed bool)
}

Read the contract in the comment, because it is the design. If no Checkpoint is installed, the loop does not reach any of this code; it runs exactly as it did before the seam existed. That is the property my CI depends on, and it is not a promise I have to remember to keep, it is the default behaviour of a nil interface value in Go. The autonomous run cannot accidentally gain a pause, because the pause lives entirely on the other side of a value that is not there.

A Move is just a suggestion the human judges: a concrete surface, path, tool, and one-line reason, tagged with whether the model or the deterministic router proposed it. It is worth being precise that a move is a suggestion and nothing more, because the next section is where people expect the model to start deciding things it must not decide.

I named it Checkpoint rather than Gate on purpose, because there is already a Gate in the tools layer that contains every outbound request to the target. That one is about containment and never goes away. This one is about autonomy. Two different concerns that both sound like a gate, so only one of them gets the word.

III. The dial is just which Decide you install

Once the pause is an interface, the autonomy dial stops being a feature I have to build and becomes a choice of which implementation to hand the runner, or whether to hand it one at all. EVA’s run setup is literally this:

if rn.Autonomy != "autonomous" {
	cp := &pilotCheckpoint{srv: s, run: rn, autonomy: rn.Autonomy, decided: make(chan decision)}
	opts = append(opts, appsweep.WithCheckpoint(cp))
}
// "autonomous" installs nothing: the Checkpoint stays nil, and the run is the CI loop.

Three settings fall out of that one if. Autonomous installs no checkpoint, so the run is the headless loop my gate uses, which is exactly what I want the all-the-way-up setting to be. Piloted and assisted both install the same pilotCheckpoint; the difference between them is how its Decide waits, which is the next thing to look at.

The clever part is how a click in a browser reaches a loop that is blocked deep inside a goroutine. The run executes on its own goroutine. When it hits a phase boundary and calls Decide, that call publishes the pending choice onto the run’s state and then simply blocks on a channel:

func (c *pilotCheckpoint) Decide(ctx context.Context, phase string, board *core.Blackboard, proposals []core.Move) ([]core.Move, string, bool) {
	// ... mark the run "waiting" and publish {phase, proposals, board} for the page ...
	select {
	case d := <-c.decided:        // the pilot clicked; the HTTP handler sent this
		return d.chosen, d.hint, d.proceed
	case <-ctx.Done():            // the run was cancelled out from under us
		return nil, "", false
	}
}

The HTTP handler that serves the pilot’s click does the other half: it looks up the waiting run and sends a decision into that channel. The run goroutine wakes up with the chosen moves and carries on. Assisted is the same, with one extra arm on the select: a short timer that, if the pilot does not act in time, proceeds on the model’s own top-ranked move. No polling, no shared mutable mess, just a goroutine parked on a channel and an HTTP handler that feeds it. The run’s whole state already lived in EVA’s registry, so the handler has everything it needs to find the right channel.

IV. The model proposes, the oracle still disposes

There is a trap sitting right here, and it is the one the whole GORED project is organized to avoid. At the checkpoint the model gets one extra turn: given only the blackboard, propose up to four ranked next moves. That proposal is genuinely useful, because a small model is good at reading an app and naming plausible things to try. Underneath it the engine always appends a deterministic router’s moves as a floor, so even when a small model mangles its JSON the pilot still sees real options routed from the paths recon actually found.

But a proposal is not a decision, and a decision about what to try is not a verdict about what worked. When the pilot picks some moves and types a hint, the engine does not branch on it. It writes the choice onto the blackboard as a plain labelled note, a line that says the pilot wants the next phase to focus here. The board is rendered into every later prompt, so that note is simply how a human steers the model, through the same channel every other observation travels. The loop’s control flow does not change. And at no setting of the dial does any of this touch who confirms a finding. Capture is decided by the oracle, reading the target’s real bytes, whether the run was piloted by hand or run flat out by CI. The human moves where the model looks. The human never moves the verdict.

That line is the reason I can turn the autonomy all the way up and still trust the result. The dial changes who picks the next move. It does not, cannot, change who grades the exam.

Conclusion

So the two modes I was afraid I would have to fork turned out to be one loop and one optional interface. Nil is my CI gate, untouched and unbranched. A pilotCheckpoint on a channel is a human at the controls. A short timer on a select arm is the middle setting. And through all of it the oracle, not the pilot and not the model, is the only thing that says a finding is real. If you take one pattern from this post, let it be that one: when you want an interactive mode and a headless mode, reach for a nil-able seam before you reach for a second copy.

Next I will turn the dial down, launch a piloted run, and actually watch it think, which means the live feed: how EVA streams a run’s every step to the browser without the engine ever knowing a browser is there.

← Back to the blog