Skip to content

Close a tool-set escape, a nil panic, and a mutated Flow - #9

Merged
inhuman merged 1 commit into
mainfrom
tool-radius-and-flow-copy
Aug 5, 2026
Merged

Close a tool-set escape, a nil panic, and a mutated Flow#9
inhuman merged 1 commit into
mainfrom
tool-radius-and-flow-copy

Conversation

@inhuman

@inhuman inhuman commented Aug 5, 2026

Copy link
Copy Markdown
Owner

⚠️ One of these changes behaviour

A model step could widen an empty tool set, and closing that changes what
such a step receives. If a skill of yours names tools per step while the flow
declares none, those steps now get an empty set. No shipped example does this.
Details below and in the CHANGELOG.

Format 2.2.3 → 2.2.4 (engine fixes; no field added, changed or removed).

What

Three findings, reported by a reviewer, all confirmed against the code and
reproduced by a failing test before anything was fixed.

1. A model step could widen an empty tool set

With tools: [] on the flow and tools: [private-server] on the step,
toolsFor returned the step's own list — putting a server the flow
deliberately does not have in front of the model.

The half worth naming is that this was an asymmetry, not just a bug. The
call path had always answered the opposite way:

// allowServer, unchanged:
//   "An empty flow set is NOT 'everything is allowed'. Symmetric to a step,
//    where an empty list means 'hand out no tools at all'…"
return fmt.Errorf("server %q: the flow declares no servers", server)

One flow, two access policies, chosen by the kind of step. That is this repo's
own invariant — a mechanism on the model's path must exist on the call path
and back — and here the two paths disagreed about a restriction. Both now hand
out nothing, and a test asserts they agree.

Narrowing a non-empty set is untouched: that is what the field is for.

2. A missing Deps.Runner panicked

call and delegate had always reported a missing executor as an ordinary step
failure, subject to on_error. An instruction step dereferenced nil and took
the process down — a stack trace where "you did not pass Deps.Runner" belonged,
and no chance for the skill's own policy to have a say.

Now symmetric, with a test that a step declaring on_error: continue does
continue.

3. Validate rewrote the description it was given

Validation needs the steps in the shape execution reads them — profiles folded
in, a save_as beside a call moved into it — and it did that in place. Two
consequences:

  • a caller holding a parsed Flow saw the engine's working copy instead of the
    file they parsed;
  • two turns over one *Flow wrote to the same structs from two goroutines.

"Parse once, run many times" is the natural way to use a skill engine, so it has
to be the safe one. Validate now works on a deep copy of the steps and
execution runs that copy. Only the steps are copied — assets, vars and profiles
are read and never written; cloneSteps says so in a comment, so a future pass
that starts writing elsewhere knows where to add itself.

And one property found while proving the third

The concurrency test runs eight turns over one Deps — which is how an embedder
actually lives, one model client for the whole application. The race detector
promptly caught the test's own runner: so the executors are called
concurrently too
, not only the callbacks.

Documented on Deps and in both READMEs. The test double now carries the lock a
real HTTP client already has, because that is what it is standing in for.

Testing

  • go vet ./... clean
  • go test ./... -count=1 all green
  • go test ./... -race all green
  • Both example modules vet and test green
  • All three findings reproduced first — the widening test failed with
    [private-server], the nil-runner test panicked, and the concurrency test
    could not even run until the panic was fixed

Format & API compatibility

  • No field added, changed or removed; EngineVersion 2.2.3 → 2.2.4
  • CHANGELOG.md leads with the behaviour change and what to do about it
  • Go API unchanged — normalized() is unexported, Validate() keeps its
    signature and its meaning

Checklist

  • Godoc added/updated
  • No secrets / tokens in the diff

🤖 Generated with Claude Code

Three findings, all confirmed against the code and reproduced before fixing.

**A model step could widen an empty tool set.** With `tools: []` on the flow and
`tools: [something]` on the step, toolsFor returned the step's own list — so a
server the flow deliberately does not have was put in front of the model.

The half of this worth naming is that the `call` path had always answered the
opposite way: allowServer refuses with "the flow declares no servers", and its
comment says in as many words that an empty set is not "everything is allowed".
One flow, two access policies, decided by the kind of step. That is this repo's
own invariant — a mechanism on the model's path must exist on the call path and
back — and here the paths disagreed about a restriction.

Both now hand out nothing. This CHANGES BEHAVIOUR for a skill that names tools
per step while the flow declares none: those steps now get an empty set. No
shipped example does that; the CHANGELOG says so plainly.

**A missing Deps.Runner panicked.** call and delegate had always reported a
missing executor as an ordinary step failure, subject to on_error. An
instruction step dereferenced nil and took the process down — a stack trace
where "you did not pass Deps.Runner" belonged, and no chance for the skill's own
policy to have a say. Now symmetric, with a test that a step saying
`on_error: continue` still continues.

**Validate rewrote the description it was given.** It needs the steps in the
shape execution reads them — profiles folded in, a save_as beside a call moved
into it — and did that in place. So a caller holding a parsed Flow saw the
engine's working copy rather than the file they parsed, and two turns over one
*Flow wrote to the same structs from two goroutines. "Parse once, run many
times" is the natural way to use a skill engine, so it has to be the safe one.

Validation now works on a deep copy of the steps and execution runs that copy.
Only the steps are copied — assets, vars and profiles are read and never
written. cloneSteps says so in a comment, so the next pass that starts writing
somewhere else knows where to add itself.

**And one property the race detector found while proving the third.** Eight
concurrent turns share one Deps — which is exactly how an embedder lives, one
model client for the application — so the executors are called concurrently too,
not just the callbacks. Said on Deps and in both READMEs; the test double now
carries the lock a real client already has.

EngineVersion 2.2.3 -> 2.2.4. The format did not change.
@inhuman
inhuman merged commit 528b562 into main Aug 5, 2026
1 check passed
@inhuman
inhuman deleted the tool-radius-and-flow-copy branch August 5, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant