A final page carries no continuation hint, so it uses the whole budget and
honors any limit exactly. A non-final page's content plus hint must fit the
limit; a limit too small to hold a progressing page and its hint is now
rejected with a clear message rather than silently exceeding the documented
maximum.
The continuation hint was reserved only against the global page cap, so a
small caller-supplied limit bounded content alone and the appended hint pushed
the complete response past the requested maximum. Reserve the hint out of the
effective ceiling (min of limit and the global cap) so content plus hint always
honours the caller's limit, flooring content so pages still make progress.
An arbitrary offset landing inside a multi-byte character is now advanced to
the next character boundary before reading, instead of trimming leading
continuation bytes after the read. This keeps every page on a real boundary
(no replacement characters) and guarantees forward progress, so an offset
inside the final character can't yield an empty page with an unchanged
continuation offset.
A full non-final retrieval page consumed the entire byte budget and then
appended the pagination hint, so the returned result (which bypasses the
result-bounding wrapper) exceeded the ceiling. Reserve the hint's bytes out
of the page budget so content plus hint always fits.
read_tool_output accepts arbitrary byte offsets; one landing inside a
multi-byte character previously decoded to a replacement character. Trim the
orphaned leading continuation bytes (their lead byte is on an earlier page)
so every accepted offset returns valid UTF-8, without affecting forward
paging offsets.
The line-based pager always kept at least one whole line, so a stored line
larger than the page byte ceiling was returned in full and could overflow
history (retrieval bypasses the result-bounding wrapper). Page by byte
offset instead: every page is bounded by the byte budget even inside one
oversized line, a partial UTF-8 char at the window edge is dropped and
re-read on the next page, and paging forward reconstructs the output
byte-for-byte.
bound_and_store's notice carries the output_id and is longer than the
plain truncation notice, so reserve for it explicitly and cover that the
spilled preview stays within max_bytes.
Bounding a retrieval page with a destructive head+tail preview and then
advancing the offset past those lines could permanently lose the elided
output the store exists to preserve. Instead honour the page byte budget
by returning fewer whole lines and advancing the offset only by lines
actually returned, so paging forward reconstructs the full output.
Exempt read_tool_output from result-bounding so paging a large stored
output isn't re-spilled under a new id, byte-cap each page in place, and
stream the requested window with islice instead of loading the whole
file per page.
Truncating a large tool result to a head+tail preview loses the middle,
which may hold the one line that matters (a buried match, a stack frame, a
credential). Instead of dropping it, persist the full output and let the
agent page back to it on demand.
- output_store.py: bound_and_store() writes the complete output to a
per-scan store and embeds an output_id in the truncation notice;
read_stored_output() serves it back in validated, paginated chunks
(output_id is a 32-char hex token, guarding against path traversal).
- read_tool_output tool: lets the agent retrieve any elided output by id,
paging via offset/limit.
- factory.py: bounded tool results now spill via bound_and_store; the new
tool is registered for every scan agent.
- runner.py: point the store at the run's .state/tool-output directory so
spilled output lives beside the rest of the scan state.
Falls back to a plain head+tail preview if the spill write fails.
- Clamp the summary request's max_tokens to the model's output limit so a
large STRIX_CONTEXT_SUMMARY_TOKENS can't get the request rejected (which
left the overflowing session uncompacted). Applied consistently to the
input-budget reservation and the request itself.
- Replace the tokenizer-unavailable fallback with the UTF-8 byte length, a
guaranteed upper bound on tokens for byte-level BPE, so budget checks can
never under-count dense history.
The chars/4 fallback under-counts dense text (code, base64, CJK), which
could let a summary request be packed past the real context window and get
rejected. Use a conservative ~3-chars/token estimate instead so budget
checks never under-count.
Drop the brittle substring matching for context-overflow detection. LiteLLM
already normalises every provider's overflow error to
ContextWindowExceededError and maintains the provider-specific matching
upstream, so is_context_overflow just checks that type.
When instructions, the reserved summary output, and any prior checkpoint
already consume the model's context window, the summary-input budget is
zero. Previously a doomed summary request was still submitted (and
rejected). Skip summarization in that case and log, rather than issue a
request guaranteed to overflow.
The 1,000-token floor on summary-input room could exceed the space
actually left after instructions, the reserved summary output, and any
existing checkpoint. On a small window the summary request then overflowed
and returned nothing, leaving the oversized session uncompacted. Clamp to
the real room instead so the request always fits.
Head+tail-truncate the serialized history before the summary request so a
very large head cannot itself overflow the model context and abort the
recovery it is meant to perform.
Restore cumulative history compaction (removed in the SDK migration) so a
long scan no longer replays an ever-growing transcript until it overflows
the model's context window and the run fails.
- strix/llm/context_budget.py: resolve the model's real input/output token
limits from LiteLLM metadata (128k gpt-4o, 272k gpt-5, 1M claude-sonnet-4,
131k deepseek), with a large configurable fallback for unmapped models and
a chars/4 token-count fallback.
- strix/llm/compaction.py: provider-agnostic compaction via litellm. Keeps a
security-focused structured summary (objective, findings, credentials,
payloads, URLs/paths, work state, dead ends, next move), keeps the most
recent turns by token budget, and snaps the summary boundary so no tool
call is separated from its result. Both triggers: proactive before each
run and reactive compact-and-retry on a real context-overflow error.
- Wire both triggers into the agent run loop next to the existing image
recovery; add replace_session_items() with restore-on-failure.
Env-tunable via STRIX_CONTEXT_* (auto-compact on by default).
A configured tool_output_max_bytes smaller than the truncation notice
itself can't fit a bounded preview, so a persisted result could exceed the
ceiling. Enforce a config floor (ge=1024) so nonsensical values are
rejected at load time instead of being worked around at runtime.
The head+tail slices could each take half of max_bytes, then the
truncation notice and its separators were appended on top, so the value
persisted to history could exceed the configured maximum. Reserve an
upper bound for the notice (and separators) out of the byte budget before
slicing so the whole joined result stays within max_bytes.
Chat-completions mode converts filesystem CustomTools to FunctionTools
(which bounds their result), but the Responses-API path kept them native
and unbounded, so a large read_file could still exhaust the context
window. Always configure the Filesystem capability to head+tail bound
tool output in both modes.
Treat tool_output_max_tokens as a ceiling so an explicit model-supplied
cap can't exceed it, and derive the truncation notice's dropped-line
count from the lines actually kept after the byte-trim pass. Also cast
the pygments fallback lexer so it satisfies the resolve_lexer return
type under the pre-commit mypy hook.
Cap the size of every tool result so a single verbose command (recursive
find, noisy scanner, full page dump) can't pin the conversation near the
model's context window for the rest of a scan.
- New ContextSettings config group with env-tunable caps.
- Default the SDK shell tools' max_output_tokens so exec_command /
write_stdin truncate head+tail instead of returning unbounded output.
- Bound Strix's own FunctionTool/CustomTool results (line + UTF-8 byte
head+tail preview with a truncation notice) and cap error strings.
The Exit Codes table in the CLI reference only listed 0 and 2, but the
CLI also exits with 1 on fatal errors (missing environment variables,
Docker unavailable, invalid config file, diff-scope resolution failure,
or an unhandled exception). It also implied exit 0 means no
vulnerabilities were found, which is only true in headless mode -
interactive runs always exit 0 regardless of findings.
Document exit code 1 and clarify the two cases for exit 0.
Every `uses:` in build-release.yml was a mutable tag; the `release` job has
`contents: write` and publishes the binaries users install, so a compromised
action could tamper the release. Action tag-hijacking keeps recurring
(aquasecurity/trivy-action, 75 tags, Mar 2026 TeamPCP; tj-actions, 2025;
codfish/semantic-release-action, Jun 2026) and SHA-pinned workflows were immune
each time. Pin all six actions to the commit each @major resolves to today
(concrete version in a trailing comment; setup-uv's annotated tag dereferenced
to its commit, not the tag object, so Dependabot tracks it). Also add a
top-level `permissions: contents: read` (the release job keeps its explicit
write) and `persist-credentials: false` on the build checkout.
actionlint passes. Pairs with #860 (Dependabot github-actions keeps the pins
current).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the post-scan local viewer hosting step so the run exits directly
instead of blocking with 'Hosting the local viewer. Press Ctrl-C to stop.'
Drop the 'View in web' link (and 'Reopen' variant) from the completion
panel, which now always shows 'View strix view <run_name>'.
cryptography 49.x ships arm64-only macOS wheels (no universal2), forcing the
Intel macOS (macos-x86_64) release runner to build from sdist under
`uv sync --frozen`. Pin to 48.0.1, which still clears GHSA-537c-gmf6-5ccf
(fixed in 48.0.1) and provides a macosx_10_9_universal2 wheel.
Resolves GHSA-537c-gmf6-5ccf (vulnerable OpenSSL in cryptography wheels,
fixed in 48.0.1) and CVE-2026-59885 / CVE-2026-59886 (pyasn1 DoS via
OBJECT IDENTIFIER / REAL decoding, fixed in 0.6.4).
* feat(cli): update notifications + self-update (strix --update)
* fix(update): verify release checksum, clean up staged binary, roll back Windows rename on failure
* feat(update): 3-way pre-scan prompt (update now / not now / skip this version) + package-manager upgrade
* fix(update): never show update prompt/notice in non-interactive runs