feat(agents): let callers turn off the coverage ledger tools

This commit is contained in:
Ian 2026-10-21 17:49:35 -04:00 • committed by Ahmed Allam
parent fff22045ae
commit 5418c767a2
8 changed files with 50 additions and 13 deletions

View file

@ -619,6 +619,8 @@ def _finish_tool_use_behavior(
return ToolsToFinalOutputResult(is_final_output=False, final_output=None)
_COVERAGE_TOOLS: tuple[Tool, ...] = (record_coverage, update_coverage, list_coverage)
_BASE_TOOLS: tuple[Tool, ...] = (
think,
load_skill,
@ -726,6 +728,7 @@ def build_strix_agent(
supports_images: bool = True,
finish_tool: Tool = finish_scan,
report_tool: Tool = create_vulnerability_report,
coverage_tools: bool = True,
) -> SandboxAgent[Any]:
"""Build a SandboxAgent for either root or child use.
@ -741,6 +744,8 @@ def build_strix_agent(
finish_tool: The tool that ends the run, given to the root agent.
report_tool: The tool every agent files findings with, in place of
``create_vulnerability_report``.
coverage_tools: Give the agent the coverage ledger tools and the
prompt rules for them.
"""
if instructions_override is not None:
instructions = instructions_override
@ -756,6 +761,7 @@ def build_strix_agent(
system_prompt_context=system_prompt_context,
supports_images=supports_images,
report_tool=report_tool.name,
coverage_tools=coverage_tools,
)
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
@ -763,6 +769,8 @@ def build_strix_agent(
# Yielding to the user is only meaningful when one is attached.
agent_tools.append(wait_for_user)
base_tools = [report_tool if t is create_vulnerability_report else t for t in _BASE_TOOLS]
if not coverage_tools:
base_tools = [t for t in base_tools if t not in _COVERAGE_TOOLS]
if is_root:
tools: list[Tool] = [*base_tools, *agent_tools, finish_tool]
else:
@ -821,6 +829,7 @@ def make_child_factory(
system_prompt_context: dict[str, Any] | None = None,
supports_images: bool = True,
report_tool: Tool = create_vulnerability_report,
coverage_tools: bool = True,
) -> Any:
"""Return the runner-owned builder used by ``spawn_child_agent``.
@ -844,6 +853,7 @@ def make_child_factory(
system_prompt_context=system_prompt_context,
supports_images=supports_images,
report_tool=report_tool,
coverage_tools=coverage_tools,
)
return _factory

View file

@ -88,6 +88,7 @@ def render_system_prompt(
include_scope: bool = True,
supports_images: bool = True,
report_tool: str = "create_vulnerability_report",
coverage_tools: bool = True,
) -> str:
"""Render the system prompt. Returns empty string on template failure.
@ -137,6 +138,7 @@ def render_system_prompt(
system_prompt_context=system_prompt_context or {},
include_scope=include_scope,
report_tool=report_tool,
coverage_tools=coverage_tools,
**skill_content,
)
except Exception:

View file

@ -209,7 +209,9 @@ VALIDATION REQUIREMENTS:
- Keep going until you find something that matters
- CLOSURE DISCIPLINE: every candidate you open ends in exactly one explicit state — `confirmed` (working PoC, or a complete source→control→sink→impact trace that is reachable), `ruled_out` (you can name the SPECIFIC control, at a location, that runs on every attacker-reachable path before the sink), or `open_proof_gap` (plausible, unconfirmed, and you could NOT name such a control). "I moved on" is not a closure state. Silently dropping an uncertain candidate is mislabelling an `open_proof_gap` as `ruled_out` and is how real bugs get missed.
- Missing information is NOT proof of safety: no caller found, can't tell if deployed/exposed, couldn't stand up the service, build failed — each is an `open_proof_gap`, never a reason to mark a candidate clean. Difficulty is a reason to defer, not to suppress.
{% if coverage_tools -%}
- COVERAGE: record every surface you assess with `record_coverage` (surface + risk area + outcome + evidence), including the ones that came back clean — a report that only lists findings cannot say what was reviewed and cleared. Use the `needs_follow_up` outcome for anything left in an `open_proof_gap` state, and carry the same items up in `agent_finish(open_items=[...])`. The ledger is shared and mutable: when you resolve a surface another agent left open — or find that a closed one is not — move that entry with `update_coverage` instead of recording a second one for the same surface. Before `finish_scan`, the root agent calls `list_coverage(outcome="needs_follow_up")` and follows up every row it returns: it assigns the outstanding work to a subagent that moves the existing entry with `update_coverage` to its real outcome, and a row stays `needs_follow_up` only when it is genuinely blocked, with the blocker stated in its evidence.
{% endif -%}
- THREAT MODEL: before you start testing, call `get_threat_model` on the target you were pointed at — it is the scan's shared answer to who the attacker is, where the trust boundaries sit, and what counts as critical here. It is scoped to this scan and nothing carries over from an earlier run, so `found: false` means no agent on this run has derived one yet. Read it instead of re-deriving trust boundaries yourself; where your testing disproves it — a boundary it calls trusted turns out to be attacker-reachable, a role it did not know about, a host or endpoint it never listed — record that with `amend_threat_model` so the agents after you inherit the correction. Amending is not optional politeness: a model nobody corrects turns the first agent's guesses into everyone's assumptions.
- Before filing any report, run the counterevidence pass: argue the strongest case AGAINST the finding, record what you found in the `counterevidence` field, set `confidence` honestly (a static-only trace you couldn't execute is at best `medium`), and state what evidence would change the severity. See the counterevidence and severity-calibration knowledge above.
- A vulnerability is ONLY considered reported when a reporting agent uses {{ report_tool }} (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
@ -227,7 +229,9 @@ Every one of these tools writes to state the rest of the scan reads. Reaching fo
- TODOS — `create_todo` / `list_todos` / `update_todo` / `mark_todo_done` / `mark_todo_pending` / `delete_todo`: your own working checklist for a multi-step task. Create todos when your task has several distinct steps so nothing is dropped across a long run; mark them done as you finish. This is private working memory — use `notes` for anything another agent needs.
- NOTES — `create_note` / `list_notes` / `get_note` / `update_note` / `delete_note`: the scan's shared scratchpad, visible to every agent. Write a note for a durable cross-agent fact that is not a finding and not coverage — a working credential set, a discovered endpoint inventory, an enumerated tenant list, a rate-limit quirk the next agent needs. `update_note` to keep a living inventory current; `delete_note` only for something now wrong or superseded. Check `list_notes`/`get_note` before recon work so you build on what is already mapped instead of redoing it.
- THREAT MODEL — `get_threat_model` / `amend_threat_model` / `save_threat_model`: covered above. `save_threat_model` REPLACES the whole document and clears amendments, so it is for establishing the baseline or folding amendments in (normally root) — to correct part of an existing model, `amend_threat_model` instead.
{% if coverage_tools -%}
- COVERAGE — `record_coverage` / `update_coverage` / `list_coverage`: covered above. One row per surface+risk; correct an existing row with `update_coverage`, never a second `record_coverage`.
{% endif -%}
- RESEARCH — `web_search`: pull fresh, target-specific external knowledge — latest bypasses, WAF evasions, DB-/framework-specific syntax, CVE and advisory detail — before falling back to memorized payloads, and refresh payload corpora mid-spray.
- SPAWN WORK — `create_agent`: delegate a focused subtask to a specialist child (see the multi-agent rules below for when to spawn and how to scope it). Give it the target to model against and what is already known.
- TRACK CHILDREN — `view_agent_graph`: your live map of every agent and its status. Call it before spawning (to confirm no existing agent already covers the scope) and before finishing (to confirm no child is still running).

View file

@ -180,6 +180,7 @@ def _compose_root_instructions_override(
system_prompt_context: dict[str, Any],
supports_images: bool,
report_tool: str,
coverage_tools: bool,
) -> str | None:
if root_instructions_override is None:
return None
@ -196,6 +197,7 @@ def _compose_root_instructions_override(
include_scope=False,
supports_images=supports_images,
report_tool=report_tool,
coverage_tools=coverage_tools,
)
return (
f"{base_instructions}\n\n"
@ -229,6 +231,7 @@ async def run_strix_scan(
mcp_status_sink: McpStatusSink | None = None,
root_finish_tool: Tool = finish_scan,
report_tool: Tool = create_vulnerability_report,
coverage_tools: bool = True,
subagent_turn_limits: Mapping[str, int] | None = None,
) -> RunResultBase | None:
"""Run or resume one Strix scan against a sandbox.
@ -255,6 +258,8 @@ async def run_strix_scan(
``root_finish_tool`` is the tool the root agent ends the run with.
``report_tool`` is the tool every agent files findings with, in place of
``create_vulnerability_report``.
``coverage_tools=False`` drops the coverage ledger tools and their prompt
rules from every agent.
``subagent_turn_limits`` maps a skill name to a soft turn limit for sub-agents
spawned with that skill (the largest applies). Agents are warned 10 turns
before it and reminded past it, but never stopped.
@ -515,6 +520,7 @@ async def run_strix_scan(
system_prompt_context=root_context,
supports_images=supports_images,
report_tool=report_tool.name,
coverage_tools=coverage_tools,
)
root_agent = build_strix_agent(
@ -533,6 +539,7 @@ async def run_strix_scan(
supports_images=supports_images,
finish_tool=root_finish_tool,
report_tool=report_tool,
coverage_tools=coverage_tools,
)
if not is_resume:
@ -555,6 +562,7 @@ async def run_strix_scan(
system_prompt_context=scope_context,
supports_images=supports_images,
report_tool=report_tool,
coverage_tools=coverage_tools,
)
async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]:

View file

@ -33,8 +33,8 @@ that sentence, you are not in `ruled_out`.
**3. `open_proof_gap`** — the candidate is plausible, you could not
confirm it, and you also could not name a control that rules it out. This
is a legitimate, expected outcome. Record it with
`record_coverage(outcome="needs_follow_up")`, carry it up in
`agent_finish(open_items=[...])`, and reflect it in `counterevidence` /
`record_coverage(outcome="needs_follow_up")` if you have that tool, carry
it up in `agent_finish(open_items=[...])`, and reflect it in `counterevidence` /
`confidence_rationale` if you file a related report. Do **not** convert
it to `ruled_out` to tidy up your worklist.
@ -99,8 +99,8 @@ do not delete it.
## Recording Closure
Closure is only useful if it is written down. Every surface you assess
gets a `record_coverage` entry:
Closure is only useful if it is written down. If you have the coverage
tools, every surface you assess gets a `record_coverage` entry:
- `confirmed` → outcome `reported`, once the report is filed.
- `ruled_out` → outcome `ruled_out`, with the named control in

View file

@ -207,5 +207,5 @@ maintainability complaints, and cosmetic variants of a candidate you
already opened.
Keep reading until no distinct plausible candidate remains — then record
what you swept with `record_coverage`, including the families that came
back clean.
what you swept with `record_coverage` (if you have it), including the
families that came back clean.

View file

@ -637,13 +637,13 @@ async def agent_finish(
doing: what did you test, what did you find/confirm/rule out,
what's still open.
**Close out honestly.** Before calling this, every surface you
assessed should have a ``record_coverage`` entry, and anything you
could neither confirm nor rule out belongs in ``open_items`` — an
unresolved candidate handed up to the parent is useful, a silently
dropped one is a missed vulnerability. Reporting nothing and
listing no open items asserts the area is clean; only say that if
you mean it.
**Close out honestly.** Before calling this, every surface you assessed
should have a ``record_coverage`` entry if you have that tool.
Anything you could neither confirm nor rule out belongs in
``open_items`` — an unresolved candidate handed up to the parent is
useful, a silently dropped one is a missed vulnerability. Reporting
nothing and listing no open items asserts the area is clean; only
say that if you mean it.
Args:
result_summary: What you accomplished and discovered. Concrete

View file

@ -104,6 +104,19 @@ def test_report_tool_replaces_create_vulnerability_report() -> None:
assert "create_vulnerability_report" not in agent.instructions
def test_coverage_tools_can_be_turned_off() -> None:
coverage = {"record_coverage", "update_coverage", "list_coverage"}
for is_root in (True, False):
on = factory.build_strix_agent(is_root=is_root)
off = factory.build_strix_agent(is_root=is_root, coverage_tools=False)
assert coverage <= {t.name for t in on.tools}
assert not coverage & {t.name for t in off.tools}
assert isinstance(on.instructions, str)
assert isinstance(off.instructions, str)
assert "- COVERAGE" in on.instructions
assert "- COVERAGE" not in off.instructions
def test_instructions_override_is_used_verbatim() -> None:
custom = "You are a scan agent. Follow the provided scope."