send_request was a thin wrapper over the Caido Replay API that the model
could replicate with a one-liner `curl` via exec_command. The sandbox's
HTTP_PROXY env captures all such traffic for free, so the tool was
adding bugs (duplicate dispatch, dropped responses) without adding
capability. Removed across factory, tools module, sandbox-importable
caido_api helper, TUI renderer, prompt template, skill doc, and public
docs. repeat_request stays — it operates on captured request IDs with
structured modifications, which curl can't replicate cleanly.
Three caido-sdk-client workarounds that were hitting us through both
send_request and repeat_request:
- replay_send_raw used to pass CreateReplaySessionFromRaw to
sessions.create(), which seeds a stored entry server-side, then
called send() — producing two history rows per call. Empty-create +
send produces one dispatched request.
- The same helper read result.entry.response_raw, an attribute that
doesn't exist on ReplayEntry, so response bytes were silently
dropped. Fixed to walk result.entry.response.raw with proper None
guards.
- get_request_with_client passed include_request_raw / include_response_raw
based on the requested part, but the SDK's generated pydantic models
declare raw as required even though the GraphQL fragment makes it
conditional via @include. Passing False crashed view_request with a
pydantic validation error. Always request both raw bodies; the caller
picks which to surface.
Also wrapped replay.send() in asyncio.wait_for(30s) so a stalled Caido
dispatch (notably loopback targets that don't route cleanly through the
sandbox proxy) fails fast with a model-readable error instead of
hanging the agent until the function_tool 120s budget expires.
Finally, list_requests now omits the roundtrip_ms field when Caido
reports 0 — proxy-captured unscoped traffic consistently reports 0
while scoped/replay traffic carries real measurements, so the absence
of the field is now informative ("Caido didn't measure this") rather
than misleading ("this request took 0ms").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notes generated 5-char IDs via a 20-try collision loop while todos
generated 6-char IDs in one shot. Mixed widths across the agent's
view made the two tools look unrelated. Match todo's shape — same
length, same one-shot generation. Collision retry is unnecessary at
scan-scale (a few hundred items vs 16^6 keys).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reject ambiguous calls in todo tools that previously combined the
single-target params and the bulk-array param (e.g. create_todo with
both `title` and `todos` would silently create N+1 items). Each tool
now errors with a mode-specific hint pointing the model at the
appropriate form. Also drop the meaningless char-count from `think`'s
success message — the model already knows what it wrote.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes that surfaced from a single broken run.
(1) Source mounting was double-broken:
- ``session_manager.create_or_reuse`` mounted the *parent* of the first
local source under a hardcoded ``"sources"`` key, so the host's
unrelated content leaked in at ``/workspace/sources/...`` while the
agent's task prompt advertised ``/workspace/<workspace_subdir>``
(from ``_build_root_task``). Result: the agent looked at
``/workspace/empty/`` (per the prompt), found nothing, and bailed.
- ``backends._docker_backend`` never called ``await session.start()``
after ``client.create()`` — the SDK's manifest application
(``LocalDir`` materialization, mount setup) only runs inside
``start()`` (or ``async with session:``). So even with the right
``entries`` the workspace would have been empty anyway.
Fix: thread ``args.local_sources`` (already populated by
``collect_local_sources``) all the way through to the session manager,
build ``Manifest.entries`` keyed by each source's ``workspace_subdir``,
and call ``session.start()`` in the docker backend so the SDK actually
materializes the entries. Drop the now-unused ``_resolve_sources_path``
helpers from ``cli.py`` and ``tui.py``.
(2) Scan-failure visibility was nonexistent in TUI mode:
- The SDK's ``on_agent_end`` hook only fires after the agent reaches its
first turn. A failure earlier (model routing, sandbox bring-up, …)
left the root agent stuck at ``status=running`` in the bus and
tracer, so the TUI animated "Initializing" forever.
- ``scan_target`` in ``tui.py`` caught the exception and called
``logging.exception`` but never propagated it. ``run_tui`` returned
cleanly when the user finally ctrl-q'd, so ``main.py`` happily
printed the success-completion banner over a dead scan.
Fix: in ``run_strix_scan``'s ``except BaseException`` block, finalize
the root agent as ``"failed"`` in both the bus and the tracer (with the
error message attached). Capture the exception on
``StrixTUIApp._scan_error`` from the scan thread; ``run_tui`` re-raises
it after ``app.run_async()`` returns so ``main.py``'s existing handler
prints the traceback. Add a ``"failed"`` branch to
``_get_status_display_content`` that shows the error message in red,
mirroring the existing ``llm_failed`` branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``MultiProvider`` was constructed with no openai kwargs, so the inner
``OpenAIProvider`` defaulted to reading ``OPENAI_API_KEY`` from the
environment. Strix's contract is that ``LLM_API_KEY`` works for every
provider, so users with ``STRIX_LLM=openai/<model>`` + ``LLM_API_KEY``
hit ``openai.OpenAIError`` at the first turn — the warm-up call worked
because that path goes through ``litellm.completion`` directly with
explicit creds, but the actual scan went through the SDK's MultiProvider
where the key was never plumbed.
Pass ``Settings.llm.api_key`` and ``Settings.llm.api_base`` through to
the underlying ``OpenAIProvider`` via the ``openai_api_key`` /
``openai_base_url`` ctor kwargs. ``openai_use_responses`` flips to
``False`` when ``LLM_API_BASE`` is set — non-default base URLs are the
reliable signal that the user is on an OpenAI-compatible endpoint
that doesn't speak the Responses API. Genuine OpenAI usage keeps the
Responses API as the default transport.
The ``anthropic/`` prefix continues to route through
``AnthropicCachingLitellmModel`` for prompt caching; ``litellm/`` and
other prefixes still fall through to the SDK's stock routing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SDK's ``DockerSandboxClient._create_container`` overrode both
``entrypoint`` and ``command`` (``tail`` + ``-f /dev/null``), which kept
the container alive but bypassed the image's ``docker-entrypoint.sh``.
That script is what launches ``caido-cli`` and sets up the browser CA
trust. With it skipped, every scan since the harness migration sat in
``bootstrap_caido`` retrying ``loginAsGuest`` for 30 s against a dead
port and then aborted before any agent work happened.
Drop the ``entrypoint`` override and pass ``[tail, -f, /dev/null]`` as
``command``. The image's ENTRYPOINT runs setup, then ``exec \"\$@\"``
swaps PID 1 to ``tail`` for the keep-alive — same long-running
no-op the SDK was after, but with the manifest/init work done first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the ``agent-browser install --with-deps`` step (Chrome for
Testing has no ARM64 build and ships several automation tells)
and uses the apt-installed Chromium across both arches.
``agent-browser`` is wired via three env vars baked into the image:
* ``AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium`` — every
browser launch picks up the apt binary; no per-call flag needed.
* ``AGENT_BROWSER_USER_AGENT`` — recent stable Chrome 131 Linux UA.
* ``AGENT_BROWSER_ARGS`` — minimal stealth flag set:
``--disable-blink-features=AutomationControlled`` (the most-
checked tell), ``--exclude-switches=enable-automation``,
``--disable-features=IsolateOrigins,site-per-process,Translate,
BlinkGenPropertyTrees``, sane window-size + lang, infobars +
save-password + session-crashed bubbles off.
The ``agent-browser doctor --offline --quick`` step at build time
verifies the binary launches; subsequent runtime calls inherit
the env automatically.
Net: smaller image (no ~150 MB Chrome-for-Testing download),
ARM64-clean, env-driven config so future flag tweaks land without
touching the agent-browser install.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the recent in-image deps (``pip install caido-sdk-client``
for ``python_action`` + Caido CLI bumped to v0.56.0). 0.2.0 is the
new minor since this is the first SDK-migration-era image; users
pulling the new strix should pull the matching new image.
Updated:
- ``strix/config/settings.py:64`` — ``RuntimeSettings.image`` default
- ``strix/runtime/session_manager.py`` + ``strix/orchestration/scan.py`` — docstring example
- ``HARNESS_WIKI.md`` — three references in the runtime + config docs
- ``MIGRATION_EVALUATION.md`` — the SDK-bridging note
The historical changelog row (``HARNESS_WIKI.md:744`` — "bump to
0.1.13") stays untouched on purpose; it records what commit
``640bd67`` did, not the current pin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When ``_respawn_subagents`` skipped an agent because it was in
``bus.stopping`` (the user clicked stop before the crash), the bus
state was left untouched — status stayed ``running`` forever, so
``view_agent_graph`` and the TUI tree showed phantom agents that
would never make progress.
Now the skip path collects those agent ids and finalizes each as
``stopped`` outside the lock, which transitions status correctly,
clears the ``stopping`` entry (``finalize`` already discards it),
moves the live stats to ``stats_completed``, and triggers the
post-finalize snapshot. A subsequent ``view_agent_graph`` shows the
truth: the agent is stopped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups from the post-fix audit:
**#1 critical**: ``orchestration/scan.py`` injects the user's new
``--instruction`` into the root's bus inbox via ``bus.send`` on resume,
but ``send`` is one of the deliberately-not-snapshotted high-frequency
mutations. A SIGKILL between that send and the model's first turn
would silently drop the user's new directive. Force a snapshot
immediately after the inject — that's the one specific message we
can't afford to lose, while leaving general ``send`` traffic
unsnapshotted as designed.
**Notes persistence**: ``strix/tools/notes/tools.py`` now mirrors the
todo pattern. ``_notes_storage`` writes through to
``{run_dir}/notes.json`` after every create/update/delete via the
same atomic-tempfile + ``Path.replace`` flow. New
``hydrate_notes_from_disk(run_dir)`` is wired in ``run_strix_scan``
alongside ``hydrate_todos_from_disk`` so a resumed scan recovers the
exact note set the prior process saw, including ``wiki``-category
notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three critical correctness fixes + six TUI/audit/UX fixes from the
parallel-agent audit. All changes verified by an end-to-end smoke
that builds, persists, and re-hydrates state across two simulated
process boundaries.
Critical (resume integrity):
1. ``bus.cancel_descendants_graceful`` now calls ``_maybe_snapshot``
after mutating the ``stopping`` set. Previously, a process crash
between user-initiated graceful-stop and the next finalize lost
the stop signal — respawned agents would run forever instead of
exiting. ``_respawn_subagents`` also gains a guard that skips
agents in ``stopping`` so a previously-cancelled agent is not
resurrected on resume.
2. ``Tracer.hydrate_from_run_dir`` now **raises** on corrupt
``vulnerabilities.json`` instead of swallowing the exception. The
prior behaviour silently reset ``vulnerability_reports`` to empty,
so the next ``add_vulnerability_report`` would allocate ``vuln-0001``
and overwrite the prior MD on disk — silent data loss.
3. ``--instruction`` passed on resume now reaches the model. The CLI
captures whether the user explicitly passed an instruction
(``args.user_explicit_instruction``) before ``_load_resume_state``
loads the persisted one. ``run_strix_scan`` reads
``scan_config["resume_instruction"]`` and, on resume, sends the
new instruction to root's bus inbox before calling
``run_with_continuation`` (which uses ``initial_input=[]`` for SDK
replay). The inject filter surfaces it on the next turn.
4. ``--resume X`` errors loudly when ``scan_state.json`` exists but
``bus.json`` doesn't. Previously this silently fresh-started in
the same dir, confusing the user who explicitly asked to resume.
TUI / audit / UX:
5. ``Tracer.hydrate_from_run_dir`` now reads ``bus.json`` too and
pre-populates ``tracer.agents`` from the snapshot's ``statuses`` /
``names`` / ``parent_of``. Before this, the TUI tree on resume
showed only currently-running agents; completed/crashed children
from the prior run were invisible.
6. ``Tracer.hydrate_from_run_dir`` also seeds ``self._llm_stats`` from
``bus.stats_live + bus.stats_completed`` so the resume's footer
shows cumulative tokens / requests across the prior run plus the
resume segment, instead of resetting to zero.
7. ``Tracer.save_run_data`` now also writes ``run_metadata.json``
(start_time, run_id, run_name, targets, status), and
``hydrate_from_run_dir`` restores ``start_time`` from it. Prior
behaviour reset start_time to ``now()`` on every Tracer init,
breaking the final report's duration calc on resumed scans.
8. Per-agent todos persist to ``{run_dir}/todos.json`` (atomic write
on every CRUD). ``hydrate_todos_from_disk`` (called from
``run_strix_scan``) reloads them so respawned subagents find
their lists intact. Previously, the module-level
``_todos_storage`` was lost on every process restart.
9. ``_load_resume_state`` validates each ``cloned_repo_path`` from
the persisted ``scan_state.json`` still exists on disk. Previously
a deleted clone dir would let the resume proceed with an empty
source tree, with agents silently scanning nothing.
Bonus: ``bus.finalize`` no longer pops ``parent_of`` and ``names``
for finalized agents. Routing protection (don't accept ``send`` to
finalized agents) comes from the ``statuses[id]`` terminal-state
check in ``send`` itself, so dropping those keys was overzealous and
made completed children invisible in ``view_agent_graph`` and the
TUI tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an explicit ``--resume RUN_NAME`` flag that loads the prior
run's persisted scan state from ``strix_runs/<run_name>/scan_state.json``
and replays it (targets, scan_mode, instruction, local_sources,
diff_scope, scope_mode, diff_base) so the user never has to retype
their original args.
The exit panel now suggests ``strix --resume <run_name>`` instead of
``--run-name``. Same single-line, same dim-label / coloured-value
styling as ``Target`` / ``Output`` rows, gated on
``not scan_completed``.
CLI contract:
* ``--resume X`` cannot be combined with ``--target`` (parser error).
* ``--resume X`` errors with a clear message if
``strix_runs/X/scan_state.json`` is missing.
* Fresh runs persist scan_state.json once at the end of setup —
after target normalization, repo cloning, local-source
collection, diff-scope resolution, and final instruction
composition. So whatever the agent saw on first run is exactly
what the resumed run sees.
Internally the resume path stays implicit (presence of bus.json
triggers it inside ``run_strix_scan``); ``--resume`` is a UX layer
that:
1. Sets ``args.run_name = args.resume``.
2. Pre-populates ``args.targets_info`` and friends from disk.
3. Skips the fresh-only steps (target re-parse, repo clone,
diff-scope re-resolution) — the persisted values were already
finalized on the first run.
HARNESS_WIKI.md: drop the "delete the run dir to force fresh"
instruction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a scan ends without calling ``finish_scan`` (Ctrl+C, TUI quit,
crash), ``display_completion_message`` now appends one extra line
inside the existing completion panel:
Resume strix --run-name <run_name>
Same ``dim``-label / coloured-value styling as the panel's ``Target``
and ``Output`` rows. Only rendered when ``scan_completed`` is False —
a finished scan doesn't need a resume nudge.
Triggers ``orchestration/scan.py``'s implicit-resume path on the next
invocation (presence of ``{run_dir}/bus.json`` is the trigger), so
the user gets back exactly where they left off — root + every
non-terminal subagent's full LLM history, bus topology, prior
findings.
Covers both ``run_cli`` and ``run_tui`` paths since
``display_completion_message`` is called from ``main()`` regardless
of which front-end ran.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the user shuts down a run (Ctrl+C in CLI, Ctrl+Q / quit dialog
in TUI, or an uncaught exception during the scan), print a Rich
panel telling them the exact command to pick up where they left off:
strix --run-name <run_name>
The panel only appears when ``strix_runs/<run_name>/bus.json``
exists — i.e. the scan registered at least the root agent and has
snapshot state worth resuming from. Suppressed when:
* No run-name was assigned (Ctrl+C before sandbox bring-up).
* The run dir doesn't exist or has no bus.json yet.
Implementation:
* ``strix/interface/utils.py`` gains ``format_resume_hint(run_name)
-> Panel | None``.
* ``cli.py`` calls it in the SIGINT/SIGTERM/SIGHUP handler before
``sys.exit(1)``, and in the ``except Exception`` arm before the
re-raise.
* ``tui.py:run_tui`` calls it in a ``finally`` after
``app.run_async()`` so the hint lands on the real terminal once
Textual has restored it (whether the user pressed Ctrl+Q,
confirmed the quit dialog, or the run completed naturally).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A scan that crashes or is stopped can now be resumed by re-invoking
``strix`` with the same ``--run-name``. Resume is implicit — presence
of ``{run_dir}/bus.json`` triggers it. To force a fresh start, delete
the run dir.
What survives a process restart with the same scan_id:
* Root agent's LLM history — already worked (root SDK SQLiteSession).
* Every non-terminal subagent's LLM history — new. ``create_agent``
now opens SQLiteSession(session_id=child_id,
db_path={run_dir}/sessions/{child_id}.db) per child and passes it
to ``run_with_continuation``.
* Bus topology — new. ``AgentMessageBus`` gains snapshot/restore/
_maybe_snapshot async methods plus a ``metadata`` field that holds
per-agent {task, skills, is_whitebox, scan_mode, diff_scope}.
``register``, ``finalize``, ``park``, and ``mark_llm_failed`` each
call ``_maybe_snapshot`` to atomically persist the bus to
{run_dir}/bus.json (tempfile + Path.replace).
* Vulnerability reports — new. ``ScanArtifactWriter._write_
vulnerabilities`` now also writes ``vulnerabilities.json``
(atomic). ``Tracer.hydrate_from_run_dir`` reads it on resume so
new vuln-NNNN ids don't collide with prior on-disk files.
What does not survive: the sandbox container itself (fresh per
process), so ``/workspace/scratch`` and Caido state are lost.
``/workspace/sources`` re-mounts from the host so source code is
unchanged.
``orchestration/scan.py:run_strix_scan`` does the actual resume:
1. Resolve run_dir up front; if bus.json exists it's a resume.
2. Acquire {run_dir}/.lock (fcntl.flock) so a second strix process
can't run concurrently on the same scan_id.
3. ``bus.set_snapshot_path(...)``, ``tracer.hydrate_from_run_dir()``.
4. On resume: load + bus.restore, find root_id from snapshot (the
agent with parent_of[id] is None), spawn the sandbox, skip the
root's bus.register (already in snapshot).
5. ``_respawn_subagents`` walks every agent with status in
running/waiting/llm_failed: reopens its SQLiteSession, rebuilds
the child agent via the captured factory, builds run config /
context, asyncio.create_task the run with initial_input=[] so
the SDK replays from session. Per-child failure (missing/corrupt
DB, factory raises) finalizes that child as crashed and continues.
6. Open root SQLiteSession at the same path, run the root with
initial_input=[] on resume (or the formatted root task on a
fresh run), and let SDK replay drive the next turn.
7. ``finally``: close every per-agent session, take a final
snapshot, tear down sandbox, release the lock.
HARNESS_WIKI.md updated with the new run-dir layout (sessions/,
bus.json, vulnerabilities.json, .lock) and the resume contract.
Net: +500 LoC across 7 files. No new deps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The notes tool no longer touches disk. ``_notes_storage`` lives in
memory for the lifetime of one scan process, shared across every
agent in that process via the existing RLock. Process exit clears
the lot — no notes.jsonl event log, no wiki/<slug>.md Markdown
rendering, no replay-on-startup hydration.
Removed ~10 internal helpers (``_get_run_dir``,
``_get_notes_jsonl_path``, ``_append_note_event``,
``_load_notes_from_jsonl``, ``_ensure_notes_loaded``,
``_persist_wiki_note``, ``_remove_wiki_note``,
``_get_wiki_directory``, ``_get_wiki_note_path``,
``_sanitize_wiki_title``) plus the ``_loaded_notes_run_dir`` module
state, ``wiki_filename`` per-note field, and the ``OSError`` branches
that only existed for the wiki write path.
The ``wiki`` category is preserved as a free-form long-form bucket;
it just no longer has any special persistence behaviour.
Skill prompts scrubbed of every "shared wiki memory" / "repo wiki" /
"append a delta before agent_finish" instruction:
``coordination/source_aware_whitebox.md``,
``custom/source_aware_sast.md``,
``scan_modes/{quick,standard,deep}.md``, plus the WHITE-BOX TESTING
block in ``agents/prompts/system_prompt.jinja``.
HARNESS_WIKI.md updated to drop the wiki-as-shared-knowledge-base
description, the per-run output-tree references to ``notes/notes.jsonl``
and ``wiki/{note_id}-{slug}.md``, and the ``is_whitebox`` toggle prose.
Net: -178 LoC in notes/tools.py, -45 LoC across skills/system_prompt
and the wiki doc. The notes tool surface (5 ``@function_tool``s) is
unchanged for the agent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five gaps from the post-implementation audit, closed:
1. **SDK logger captured.** The openai-agents SDK uses
``logging.getLogger("openai.agents")`` for its own lifecycle events
(Runner.run starts, tool dispatch, model retries, exceptions).
Previous setup only attached handlers to the ``strix`` root, so
SDK-internal events were dropped. Tracked-roots tuple now covers
both, with the same FileHandler/StreamHandler/Filter chain.
2. **Proxy tool exception tracebacks.** Every ``@function_tool`` in
``strix/tools/proxy/tools.py`` returns a JSON error to the LLM via
the ``_err(name, exc)`` helper. The tracebacks were silently
formatted away — the LLM saw the message, the human reading the
log saw nothing. ``_err`` now emits ``logger.exception(...)``
covering all five tools at once.
3. **CLI bootstrap.** ``strix/interface/main.py`` had its module
``logger`` removed by the previous commit and was emitting nothing.
Restored, plus log lines for env validation, docker check, LLM
warm-up, and image pull (debug for already-present, info for
pull, exception for failures).
4. **Docker client.** ``strix/runtime/docker_client.py`` had no
logger. Container creation now logs caps + exposed ports at DEBUG
and the resulting container id at INFO.
5. **PostHog telemetry.** ``strix/telemetry/posthog.py`` had no
logger. Now logs send success/failure at DEBUG, version-detection
failures at DEBUG, and disabled-skip at DEBUG (so the log shows
when telemetry is off, instead of being silent about it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every scan now writes a complete log file at ``{run_dir}/strix.log``
captured from the moment ``run_dir`` is resolved through teardown.
Stdlib ``logging`` only — no parallel framework.
New ``strix/telemetry/logging.py``:
* ``setup_scan_logging(run_dir, debug=)`` attaches a ``FileHandler``
(DEBUG, all ``strix.*``) plus a ``StreamHandler`` (ERROR by
default; DEBUG via ``STRIX_DEBUG=1``).
* ``ContextVar``-backed ``scan_id`` and ``agent_id`` injected by a
``Filter`` so every line is auto-tagged across asyncio tasks
without callers passing them explicitly.
* Third-party noise (``httpx``, ``litellm``, ``openai``,
``anthropic``, ``urllib3``, ``httpcore``) capped at WARNING.
* Returns a teardown handle for ``finally`` cleanup.
Wiring:
* ``orchestration/scan.py`` calls ``setup_scan_logging`` once per
scan after ``run_dir`` resolves; sets scan_id; tears down in
``finally``. Adds INFO logs for sandbox bring-up + scan
start/end.
* ``orchestration/hooks.py`` sets/clears ``agent_id`` ContextVar in
``on_agent_start`` / ``on_agent_end`` and emits INFO for agent
lifecycle, DEBUG for every tool start/end and LLM call.
* ``interface/main.py`` drops the ``setLevel(ERROR)`` silencer.
Coverage expanded across ~20 files (orchestration, agents, runtime,
llm, tools, interface, config, skills) with INFO for lifecycle and
DEBUG for verbose detail. Per the system instructions in
``logger.warning(f"…{e}")`` were converted to module logger calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores the legacy persistent-IPython tool's *ergonomics* (proxy
helpers pre-bound, structured stdout/stderr/error returns) without the
in-container daemon: each call ships ``strix.tools.proxy._calls`` source
into ``/tmp`` alongside a per-call driver, runs ``python3 -u`` against
it, and parses a sentinel-delimited JSON payload back from stdout. The
driver fetches its own guest token from Caido at ``localhost:48080``
and binds ``list_requests`` / ``view_request`` / ``send_request`` /
``repeat_request`` / ``scope_rules`` to that client; user code runs
inside an ``async def`` wrapper so top-level ``await`` works.
The proxy SDK call sequences live in one file —
``strix/tools/proxy/_calls.py`` — and are reused by both the host-side
``@function_tool`` wrappers (which add JSON serialization for the LLM)
and the in-container kernel (which exposes the bare async functions).
No code duplication; the helper logic itself is host-shipped, so
tweaking the proxy helpers does not require an image rebuild.
Image: a single ``pip install caido-sdk-client`` line so the driver's
``import caido_sdk_client`` resolves. Skill ``tooling/python`` is
always-loaded alongside ``tooling/agent_browser``.
Trade-off accepted: state does not persist across calls (no kernel).
For multi-step workflows the agent combines into one ``code`` block or
writes a script to ``/workspace/scratch/`` and runs via
``exec_command``. If a workflow surfaces that genuinely needs
persistence, the same tool surface migrates to a kernel-backed
executor without changing the LLM contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pinned URL pattern (https://caido.download/releases/v<X>/caido-cli-v<X>-linux-<arch>.tar.gz)
is canonical — it's published by api.caido.io/releases/latest. HEAD requests
return 404 because the upstream R2 bucket only honors GET-with-redirect, but
the wget call in the Dockerfile uses GET so the original URL was never
actually broken — it was just stale.
Switch to an ARG so future bumps are a single --build-arg override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three top-level files that didn't earn their place:
- ``strix/io/scan_artifacts.py`` had a single consumer (the Tracer);
collapsing it into ``strix/telemetry/`` puts it next to that consumer.
``strix/io/`` is gone.
- ``strix/run_config_factory.py`` held two helpers that didn't earn the
factoring. ``make_agent_context`` was a 17-line dict-spelling function
whose argument names were identical to its dict keys — replaced with
inline dict literals at the two call sites. ``make_run_config`` had
enough RunConfig assembly logic to justify a helper, but with only
two callers (root scan + ``create_agent``) inlining is cleaner than
keeping a top-level file. ``DEFAULT_RETRY`` moves to
``strix/llm/retry.py`` next to its other LLM-policy peers; the dead
``STRIX_DEFAULT_MAX_TURNS`` constant is dropped.
- ``strix/entry.py`` is a misnomer — it isn't *the* entry point (that's
``strix/interface/main.py`` for the CLI), it's the per-scan bring-up
driver: build the bus, bring up the sandbox, build the root agent +
child factory, format the scope-context block, register root in bus,
open SQLiteSession, hand off to ``run_with_continuation``. That all
lives next to its peers in ``strix/orchestration/`` now, renamed to
``scan.py`` so the role is obvious.
No behavior change. Net -125 LoC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 19 tool renderers under strix/interface/tool_components/ all read
tool_data.get("args", {}) to render meaningful previews (URLs, methods,
note titles, vuln severities, etc.). After the SDK migration,
tracer.log_tool_start was only recording tool_name — every renderer
silently fell back to its empty-args path and the TUI lost its
per-call context.
Pull args from the SDK-native ToolContext (tool_input when parsed,
otherwise json-decode tool_arguments) and stash them on the
tool_executions entry. log_tool_start now takes an optional args dict;
existing callers pass nothing and get the empty-dict default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit flagged that legacy ``finish_scan`` had a code-level guard
(``_check_active_agents``) that refused completion if any subagent was
still running or stopping. Restoring it as code would be defensive
mid-stream cancellation we don't actually want — the agent should
choose whether to wait, message, or stop each child.
Lift the responsibility to the prompt instead: docstring now opens
with a numbered pre-flight checklist that requires the agent to
``view_agent_graph`` first and refuses self-permission to call
``finish_scan`` while any peer is in ``running`` / ``waiting`` /
``llm_failed``. The model sees this as part of the tool's schema and
treats it as a hard rule (matches our pattern for similar
constraints).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final pass after re-audit. Three sub-specs landed:
**XML simplification** — the legacy XML envelopes were prompt-engineering
ceremony, not parser primitives (the SDK uses native tool-calling). Drop
the verbose wrappers in favor of one-liner labeled headers. Side benefit:
fixes the unescaped-content XML-injection bug the audit caught (peer
content containing ``</content>`` no longer breaks the wrapper).
- ``_format_inter_agent_message``: ``<inter_agent_message><sender>...
<content>...`` 9-line XML → ``[Message from {name} ({id}) | type=... |
priority=...]\n{content}``.
- ``_render_completion_report``: ``<agent_completion_report><agent_info>
...<results>...`` XML → human-readable structured text with section
headers and bulleted lists.
- ``inherited_context``: ``<inherited_context_from_parent>...`` →
``== Inherited context from parent (background only) ==``.
**MG1: TUI stop-agent uses graceful cancel.** ``tui.py`` was calling
``bus.cancel_descendants`` (hard, ``task.cancel()`` mid-stream) for the
stop-agent button. Switched to ``bus.cancel_descendants_graceful``, which
uses ``RunResultStreaming.cancel(mode="after_turn")`` to let each agent
finish its current turn (and save to session) before honoring the cancel.
The hard path remains in ``entry.py`` for KeyboardInterrupt where
graceful isn't possible.
**MG2: Document hook lock-free stats mutation.** Added a comment in
``hooks.on_llm_start`` explaining why ``warned_85`` / ``warned_final``
are mutated lock-free: SDK serializes ``on_llm_start`` per agent, so this
hook is the sole writer to those keys; ``record_usage`` only writes
disjoint keys (in/out/cached/calls).
**AG3: Auto-load ``coordination/root_agent`` skill for the root.**
Legacy auto-loaded the orchestration-guidance skill for root agents
only. Threaded ``is_root`` through ``render_system_prompt`` →
``_resolve_skills``; root agents now get the skill, children don't.
Skipped (per user direction): whitebox-wiki integration (CG2-4) — the
auto-injection / auto-update of the shared repo wiki was a pre-migration
feature; user opted not to restore it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit found 8 behavioral gaps between post-migration and the legacy
``BaseAgent.agent_loop``. All 8 are now closed using SDK-native
primitives — no custom workarounds, no shadow state machines.
What was broken / different:
- G1: ``inherit_context`` was dead code; children always started fresh.
- G2: TUI user message couldn't interrupt an in-flight LLM/tool turn.
- G3: ``llm_failed`` state never set; hard failures propagated as crashes.
- G4: No graceful ``stop_agent`` tool.
- G5: Parked subagents waited forever (no auto-resume timeout).
- G6: Inter-agent messages used a plain header instead of legacy XML.
- G7: Completion reports used JSON instead of legacy XML.
- G11/G12: Turn counter reset per cycle; budget warnings could re-fire.
What we did:
Bus extensions (``orchestration/bus.py``):
- ``streams`` registry + ``attach_stream`` ctx manager + ``request_interrupt``
for SDK-native ``RunResultStreaming.cancel(mode="after_turn")``.
- ``mark_llm_failed`` + ``wait_for_user_message`` (filtered: only ``from="user"``
satisfies; peer messages don't unstick a stuck model).
- ``stopping: set[str]`` for graceful programmatic exit.
- ``cancel_descendants_graceful`` — leaves-first via ``request_interrupt``.
- ``record_usage`` increments ``calls`` unconditionally so it doubles as the
per-agent-lifetime turn counter (legacy ``state.iteration`` parity).
- ``warned_85`` / ``warned_final`` flags on ``stats_live`` for once-fire
budget warnings.
Run loop rewrite (``orchestration/run_loop.py``):
- ``Runner.run`` → ``Runner.run_streamed`` with ``bus.attach_stream`` so
cancel has a target. Catch ``(AgentsException, APIError)`` after retries
exhaust; in interactive mode call ``mark_llm_failed`` + wait for user.
- ``UserError`` / ``MaxTurnsExceeded`` / ``CancelledError`` propagate.
- Outer loop: ``asyncio.wait_for(bus.wait_for_message, timeout=300)`` for
interactive subagents (root waits forever). ``TimeoutError`` injects
``"Waiting timeout reached. Resuming execution."``.
- Honors ``bus.stopping`` at top of each iteration.
Hooks (``orchestration/hooks.py``):
- Counter source moved from per-cycle ``ctx["turn_count"]`` to
per-lifetime ``bus.stats_live[agent_id]["calls"]``.
- Warnings guarded by once-flags — exactly-once across all cycles.
Filter (``orchestration/filter.py``):
- Restored legacy ``<inter_agent_message>`` XML envelope with the
``<delivery_notice>DO NOT echo back</delivery_notice>`` instruction.
Agents-graph (``tools/agents_graph/tools.py``):
- G1: ``create_agent`` reads ``ctx.turn_input`` (SDK populates it before
tool execution at ``run_internal/turn_resolution.py:806``). Wraps as
one ``<inherited_context_from_parent>`` block.
- G7: ``agent_finish`` emits the legacy ``<agent_completion_report>``
XML. ``child_ctx["task"] = task`` threaded so the report echoes the
original task.
- G4: New ``stop_agent`` tool — refuses self-stop, refuses already-
finalized targets, ``cascade=True`` uses ``cancel_descendants_graceful``.
TUI (``interface/tui.py``):
- ``_send_user_message`` schedules ``bus.send`` AND
``bus.request_interrupt(target, mode="after_turn")`` — SDK finishes
current turn cleanly, next cycle picks up the user's message.
Factory (``agents/factory.py``):
- Registered ``stop_agent`` in ``_BASE_TOOLS``.
Out of scope:
- G8 (``[ABORTED BY USER]`` marker) is auto-resolved by G2 — the SDK
saves the full assistant message before honoring
``cancel(mode="after_turn")``, so partial content is preserved in the
session.
Verified all bus behaviors with a smoke test. Lint at baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Top-level ``strix/run_loop.py`` was an orphan — it owns the multi-agent
continuation loop, which is exactly the orchestration layer's job.
Moves it into ``strix/orchestration/run_loop.py`` next to the bus,
hooks, and filter — they all glue ``Runner.run`` to bus state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit only kept the root agent alive across cycles. But
``interactive`` propagates to children via ``make_child_factory``, and
the legacy harness's continuation loop applied to every interactive
agent in the tree — children also stayed alive after ``agent_finish``,
ready to receive follow-up messages from the parent or siblings.
Lift the demo-loop pattern out of ``entry.run_strix_scan`` into a
shared helper :func:`strix.run_loop.run_with_continuation` and use it
at both call sites:
- ``entry.run_strix_scan`` for the root agent.
- ``tools.agents_graph.tools.create_agent`` for child agents — the
``asyncio.create_task(Runner.run(...))`` becomes
``asyncio.create_task(run_with_continuation(...))``.
``StrixOrchestrationHooks.on_agent_end`` drops the ``parent_id is None``
constraint — any interactive agent parks instead of finalizing.
Children that crash still finalize so parents stop waiting on them.
Cancellation propagates correctly: ``bus.cancel_descendants`` cancels
the task; ``run_with_continuation``'s ``await bus.wait_for_message``
catches ``CancelledError`` and returns the last result.
Lint at baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-migration ``BaseAgent.agent_loop`` ran forever in interactive mode,
re-entering a "waiting state" after each finish-tool call so user
follow-ups could keep the conversation going. Post-migration our
``Runner.run`` returned on ``StopAtTools(finish_scan)`` and the user's
next chat message had no listener — silent dead-end.
Restore the legacy "agent never dies" semantics using the SDK's
canonical demo-loop pattern (``agents/repl.py:run_demo_loop``):
- Add ``AgentMessageBus.wait_for_message(agent_id)`` — blocks until
an inbox is non-empty. Backed by a per-agent ``asyncio.Event``
fired from ``send``.
- Add ``AgentMessageBus.park(agent_id)`` — sets status to ``waiting``
without finalizing (inbox + tree edges + name preserved). Lets
``send`` keep accepting messages between cycles.
- Plumb ``interactive`` through ``make_agent_context`` and the
``create_agent`` graph tool (children inherit).
- ``StrixOrchestrationHooks.on_agent_end`` parks the root agent
instead of finalizing when ``interactive=True`` and the run
completed cleanly. Resets ``agent_finish_called`` /
``turn_count`` for the next cycle.
- ``entry.run_strix_scan`` adds an outer loop in interactive mode:
after ``Runner.run`` returns, ``await bus.wait_for_message(root_id)``,
drain pending user messages, and re-invoke ``Runner.run``. SQLite
session preserves prior conversation across cycles.
For non-interactive (CLI) mode: unchanged — single ``Runner.run``,
return.
Verified bus behaviors: wait returns immediately on pre-existing
message, blocks then wakes on send, ``park`` keeps agent send-able,
``finalize`` evicts. Lint at baseline (3 ruff / 69 mypy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``agent_finish`` was setting ``inner[\"agent_finish_called\"] = True``
at the top of its body, but ``StrixOrchestrationHooks.on_tool_end``
already does this for ``agent_finish`` and ``finish_scan`` after the
tool returns. Doing it twice was harmless but suggested the flag's
ownership was ambiguous; the hook is the single source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 150-line ``Tracer.save_run_data`` mashed three concerns together:
opening file handles, formatting Markdown for vulnerabilities, and
writing the executive penetration-test report. None of that is
telemetry — it's pure on-disk artifact emission.
Extract to :class:`ScanArtifactWriter` in ``strix/io/scan_artifacts.py``:
- One writer per ``run_dir``, owns its own ``_saved_vuln_ids`` dedupe
set so re-saves only emit new files.
- ``writer.save(vulnerability_reports=, final_scan_result=)`` is the
only public entry point.
- ``_render_vulnerability_md`` is module-private and unit-testable in
isolation.
``Tracer`` now lazily creates a single ``ScanArtifactWriter`` per
``run_dir`` and delegates ``save_run_data`` to it (~150 LoC body
collapses to ~10).
Net: tracer.py 422 → 327 LoC; new scan_artifacts.py 196 LoC. About
−95 LoC of mixed concerns, plus telemetry no longer carries file-I/O
responsibilities.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit found ``hooks.on_tool_start`` / ``on_tool_end`` were calling
``tracer.log_tool_start`` / ``log_tool_end`` via ``hasattr()`` checks —
but those methods didn't exist on ``Tracer``. The ``hasattr()`` always
returned False, so the calls were silently no-ops, leaving
``tracer.tool_executions`` permanently empty.
Four TUI render paths consume that dict and were therefore broken:
- ``_get_agent_name_for_vulnerability`` always returned ``None`` (vuln
panel couldn't show which agent reported the finding).
- ``_agent_has_real_activity`` always returned ``False`` (animation
logic stopped immediately).
- ``_agent_vulnerability_count`` always returned ``0``.
- ``_gather_agent_events`` only showed chat events, never tool events.
Fix: add ``Tracer.log_tool_start(agent_id, tool_name) → exec_id`` and
``Tracer.log_tool_end(agent_id, tool_name, result)``. Hook bodies now
call them directly (no ``hasattr`` guard). The exec-id counter ensures
nested / overlapping tool calls within an agent don't clobber each
other.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``"anthropic/claude-sonnet-4-6"`` was duplicated as a kwarg default in
5 places (``run_strix_scan``, ``make_run_config``, ``make_agent_context``,
and twice in ``agents_graph.create_agent``'s ``inner.get(..., default)``
calls). The default was actually dead code: ``validate_environment``
requires ``STRIX_LLM`` to be set before any scan starts, and the CLI/TUI
callers don't pass ``model=`` themselves.
Replaced with a single resolution in ``run_strix_scan``:
resolved_model = model or load_settings().llm.model
if not resolved_model:
raise RuntimeError("No LLM model configured. ...")
then propagated explicitly to ``make_agent_context`` and
``make_run_config``. Both lose their string defaults — ``model`` is now
a required kwarg. The graph tool's ``inner.get("model", "...")`` is
``inner["model"]``: the parent context guarantees it's set.
Drive-by: ``run_strix_scan`` docstring still listed ``is_whitebox`` as
a ``scan_config`` key — stale since ``1e641e5`` derived it from
``targets`` instead. Updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces 200+ lines of bespoke env-loader / persist / change-detection
machinery with ``pydantic_settings.BaseSettings`` (already a transitive
of ``openai-agents → mcp``, no new direct dep).
What was wrong with ``Config``:
- 14 knobs flat in one namespace, weak grouping by comment-block.
- ``Config._applied_from_default`` and ``Config._config_file_override``
were externally mutated from ``interface/main.py:532-534``. Private
members were part of the public contract.
- Stringly-typed values: every caller had to coerce
(``int(Config.get("llm_timeout") or "300")``,
``... not in {"0", "false", "no", "off"}``).
- Dead knob: ``strix_llm_max_retries`` declared, persisted, listed in
``_LLM_CANONICAL_NAMES`` — zero readers (``DEFAULT_RETRY``
hardcodes ``max_retries=5``). Dropped.
- ``_LLM_CANONICAL_NAMES`` tuple maintained alongside class vars —
duplicate source of truth.
- ``_tracked_names()`` introspected ``vars(cls).items()`` filtered on
``(v is None or isinstance(v, str))`` — fragile.
- Awkward path: ``strix/config/config.py`` inside ``strix/config/``
with ``__init__.py`` just re-exporting.
- Dual access for the same fact: ``web_search`` read
``os.getenv("PERPLEXITY_API_KEY")`` while ``main.py`` read
``Config.get("perplexity_api_key")``.
New shape:
- ``strix/config/settings.py`` — typed dataclass tree:
``Settings.{llm,runtime,telemetry,integrations}``. Each sub-model is
its own ``BaseSettings`` so it reads env independently. Field-level
``alias=`` and ``validation_alias=AliasChoices(...)`` mirror the
existing flat env-var names — user-facing env contract is unchanged.
Bool fields auto-parse ``"0"``/``"false"``/``"no"``/``"off"``;
int fields auto-coerce.
- ``strix/config/loader.py`` — thin ``load_settings()``,
``apply_config_override(path)``, ``persist_current()`` with module
cache. JSON file reader walks aliases to populate sub-models, dropping
entries already covered by env (so env still wins).
- 13 callsites migrated from ``Config.get("...")`` to
``load_settings().<group>.<field>``.
- ``posthog._is_enabled()`` collapses to one line.
- ``--config <path>`` flow simplified: one
``apply_config_override(...)`` call replaces three lines of
class-private mutation.
Drive-by — drop ``is_whitebox`` from ``scan_config`` dict:
- It was being derived as ``bool(args.local_sources)`` in three places
(``cli.py``, ``tui.py``, ``main.py``) and stuffed into the dict for
``entry.py`` to read back. The fact is fully derivable from
``scan_config["targets"]`` — any target with ``type == "local_code"``.
- New helper ``is_whitebox_scan(targets)`` in ``interface/utils.py``
alongside the other target-classification utilities.
- ``entry.py`` computes once; ``main.py``'s posthog start uses the same
helper. Triplicate derivation gone.
Verified: ruff at baseline (3), mypy at baseline (69). Six smoke tests
pass — defaults / JSON-only / env-wins-over-JSON / alias-chain
fallback / bool parsing / ``is_whitebox_scan``.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dockerfile carried forward three pieces of dead state from the
pre-migration era:
- ``/app/runtime`` and ``/app/tools`` mkdir entries — the FastAPI
sidecar + in-container tool registry that those dirs hosted are
gone.
- ``/home/pentester/{configs,wordlists,output,scripts}`` — empty
placeholders never populated by anything; greps for them in the
whole repo come back empty.
- ~20 explicit Chrome/Playwright runtime libs (``libnss3``,
``libnspr4``, ``libatk*``, ``libxcomposite1``, …) plus emoji /
freefont packages. These were Playwright deps; the migration to
``agent-browser`` runs ``agent-browser install --with-deps`` which
owns this list authoritatively. Keep ``libnss3-tools`` for
``certutil`` in the entrypoint's CA-trust step.
Drive-by bug fix: ``NO_PROXY=localhost,127.0.0.1`` was set in the
entrypoint (``/etc/profile.d/proxy.sh`` + ``/etc/environment``) but
NOT in the SDK manifest's environment. ``docker exec``-spawned
processes (which ``session.exec`` and the Shell capability use)
inherit only manifest env, so ``agent-browser``'s CDP-localhost
traffic was being looped back through Caido. Add it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>