diff --git a/strix/agents/factory.py b/strix/agents/factory.py index bc664a5d5..39a52255e 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -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 diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py index c20b75a85..c5c71eb7d 100644 --- a/strix/agents/prompt.py +++ b/strix/agents/prompt.py @@ -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: diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 24ef7a08e..fd09d0774 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -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). diff --git a/strix/core/runner.py b/strix/core/runner.py index 106be7551..5810f84f5 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -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]: diff --git a/strix/skills/analysis/counterevidence.md b/strix/skills/analysis/counterevidence.md index 0f40aaee1..e120493ab 100644 --- a/strix/skills/analysis/counterevidence.md +++ b/strix/skills/analysis/counterevidence.md @@ -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 diff --git a/strix/skills/analysis/source_aware_discovery.md b/strix/skills/analysis/source_aware_discovery.md index ef13cde80..05bbe08f9 100644 --- a/strix/skills/analysis/source_aware_discovery.md +++ b/strix/skills/analysis/source_aware_discovery.md @@ -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. diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index a26c1dc44..ca7b5a0a4 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -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 diff --git a/tests/test_agent_tool_registration.py b/tests/test_agent_tool_registration.py index 9af888689..bbc4fadfb 100644 --- a/tests/test_agent_tool_registration.py +++ b/tests/test_agent_tool_registration.py @@ -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."