Skip to content

fix(agent): bound local_loop config and stop returning dead stack storage - #1046

Closed
vernonstinebaker wants to merge 2 commits into
nullclaw:mainfrom
vernonstinebaker:fix/local-loop-bounds
Closed

vernonstinebaker wants to merge 2 commits into
nullclaw:mainfrom
vernonstinebaker:fix/local-loop-bounds

Conversation

@vernonstinebaker

Copy link
Copy Markdown
Contributor

Third and last PR splitting #987. Addresses the boundary findings. #1044 was the gating fix, #1045 the concurrency fix.

1. Slice into a dead stack frame

fn stackLowerAscii(input: []const u8) []const u8 {
    var buf: [256]u8 = undefined;
    ...
    return buf[0..cap];        // ← dead on return
}

extractErrorSignature then ran indexOf over that invalid slice. The helper is deleted and the scan is case-insensitive in place, so nothing escapes the frame. Detection behaviour is unchanged — there's a mixed-case test to prove it.

2. Truncation marker exceeded the budget

max_result_chars: 1 returned the whole 16-byte "\n… [truncated]": the code computed body_len = cap -| suffix.len, got 0, and emitted the suffix anyway. When the budget can't hold the marker, output is now a literal truncation with no marker — the cap is never exceeded. Tested across budgets 0–16.

3. Explicit max_result_chars was indistinguishable from unset

The field defaulted to 8192 — the same value the documentation's own example uses. So setting it explicitly to 8192 could not be distinguished from omitting it, and the 400-character tightening cap silently replaced it. That directly contradicts:

local_loop.enabled = true tightens the default tool-result history cap to 400 characters (unless max_result_chars is set explicitly).

0 is now the "unset" sentinel, resolved per mode in toolResultCompressOptions: the tightening cap applies when the feature is on, the general default otherwise. I resolved it there rather than in the struct default deliberately — that keeps the sentinel correct on its own, independent of whether compression is separately gated on enabled (which is #1044's change). Stacking that assumption would have made this PR secretly depend on the other.

4. Unchecked casts

All six numeric local_loop fields were @intCasted unchecked, so a positive value wider than u32 trapped in a safety build instead of reporting a configuration error. Each now returns error.InvalidLocalLoopConfig.

One boundary worth recording: a literal beyond i64 (e.g. 1e30) never becomes an integer token, so the JSON parser drops the field rather than the bounds check rejecting it. The tests use 5000000000 — wider than u32, still representable as i64. Tightening the tokenizer is a separate concern and is not attempted here.

Tests

  • budgets 0–16 never produce output longer than the budget
  • error-signature detection stays case-insensitive (mixed case, upper case, and a no-marker line)
  • all six fields reject a value wider than u32; in-range values still parse
  • explicit 8192 is honoured; unset applies the 400 cap

Validation

  • Branch: zig fmt --check src/ exit 0 · ReleaseSmall exit 0 · zig build test --summary all 13/13 steps, 7400/7409 passed, 9 skipped, 0 failures, 0 leaks
  • Merge result with current main: clean merge, fmt exit 0, ReleaseSmall exit 0, 13/13 steps, 7518/7527 passed, 9 skipped, 0 failures, 0 leaks

Docs

Both languages updated: the example now shows max_result_chars: 0 and the note explains that 0 means unset and any other value is used as-is.

#987 split — complete

Contents PR
A Gating (enabled actually gates) #1044
B Worker lifecycle (spawn/join, arena race, stack) #1045
C Boundary (stackLowerAscii, marker, sentinel, casts) this

With this open, #987 can be closed with a finding-to-PR mapping and its authorship preserved. All three build on feat/local-loop-phase0, since that is where the affected code is introduced — none of them have a target on main until the feature branch lands.

Audit Demo and others added 2 commits August 15, 2026 21:53
Add cache-friendly prompt prefix/tail split, tool-result compression,
identical-call loop guard, parallel read-only batch execution, and
agent.local_loop config defaults so local models survive longer loops
without changing the cloud provider path.

Co-authored-by: Cursor <[email protected]>
…rage

Addresses the boundary findings on nullclaw#987. Third and last PR splitting that
change.

## Slice into a dead stack frame

`stackLowerAscii` lowercased into a local `[256]u8` array and returned a
slice of it; `extractErrorSignature` then searched that invalid slice.
Deleted the helper and scan case-insensitively in place, so no stack
storage escapes. Detection behaviour is unchanged.

## Truncation marker exceeded the budget

`max_result_chars: 1` returned the whole 16-byte "\n… [truncated]"
marker, because the code built `body_len = cap -| suffix.len` and emitted
the suffix even when `body_len` was 0. When the budget cannot hold the
marker the output is now a literal truncation with no marker, so the cap
is never exceeded.

## Explicit max_result_chars was indistinguishable from unset

The field defaulted to 8192 -- the same value the documentation's example
uses -- so setting it explicitly to 8192 could not be told apart from
leaving it alone, and the 400-character tightening cap silently replaced
it. That contradicts the documented promise that an explicit
`max_result_chars` is honoured.

`0` is now the "unset" sentinel, resolved per mode in
`toolResultCompressOptions`: the tightening cap applies only when the
feature is on, otherwise the general default does. Resolving it there
rather than in the struct default keeps the sentinel correct on its own,
independent of whether history compression is separately gated on
`enabled`.

## Unchecked casts

All six numeric `local_loop` fields were `@intCast`ed unchecked, so a
positive value wider than u32 trapped in a safety build instead of
reporting a configuration error. Each now returns
`error.InvalidLocalLoopConfig`.

## Tests

- budget of 0..16 bytes never produces output longer than the budget
- error-signature detection is case-insensitive and mixed-case still matches
- all six fields reject a value wider than u32; in-range values still parse
- an explicit 8192 is honoured when the feature is on, and an unset value
  applies the 400 cap

Note: a literal beyond i64 (e.g. 1e30) is dropped by the JSON tokenizer
rather than reaching the bounds check, so the tests use 5000000000 --
wider than u32, still representable as i64.

## Scope

Builds on `feat/local-loop-phase0`, where the code this fixes is
introduced. Part of the nullclaw#987 split: nullclaw#1044 gating, nullclaw#1045 concurrency, this
the boundary fixes. nullclaw#987 closes with a finding-to-PR mapping once all
three are open.
@vernonstinebaker

Copy link
Copy Markdown
Contributor Author

Closing: structurally invalid, folded into #987. Same reason as #1044 — the diff is the whole feature plus the fix, not the ~145-line boundary fix intended. Content is commit 8834410 on feat/local-loop-phase0.

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