Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,13 @@ strix --target api.your-app.com --instruction-file ./instruction.md

# Force PR diff-scope against a specific base branch
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main

# Treat the first valid report as a draft and continue testing underexplored areas
strix --completion-nudge --target https://your-app.com
```

`--completion-nudge` gives the root agent one more pass before finalizing the scan. The first valid report becomes a draft and the interface shows `Completion nudged`. The same agent follows up on leads it found and checks remaining untested areas, using tools or subordinate agents when useful. Its next valid report completes the scan. This can be useful for smaller or weaker models, but it can increase model/tool cost and runtime.

### Headless Mode

Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag - perfect for servers and automated jobs. The CLI prints real-time vulnerability findings and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
Expand Down
9 changes: 9 additions & 0 deletions docs/usage/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
Run in headless mode without TUI. Ideal for CI/CD.
</ParamField>

<ParamField path="--completion-nudge" type="boolean">
Before accepting the final report, continue the same root agent once to investigate promising leads it encountered but did not pursue deeply, then sweep plausible untested scope. The first valid report is treated as a draft and the interface shows `Completion nudged`; only the revised comprehensive report completes the scan.

This may increase model/tool cost and runtime. It is not a blind duplicate scan or a fixed extra model turn: the existing agent session continues and may use tools or focused subordinate agents.
</ParamField>

<ParamField path="--config" type="string">
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
</ParamField>
Expand Down Expand Up @@ -102,6 +108,9 @@ strix -n --target ./ --scan-mode quick
# Force diff-scope against a specific base ref
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main

# Continue the same agent past its first valid report to test underexplored areas
strix -n --completion-nudge --target ./app-directory

# Multi-target white-box testing
strix -t https://github.com/org/app -t https://staging.example.com

Expand Down
10 changes: 10 additions & 0 deletions strix/core/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ async def set_status(self, agent_id: str, status: Status | str) -> None:
runtime.wake.set()
logger.info("agent.status %s=%s", agent_id, status)
await self._maybe_snapshot()
async def claim_completion_nudge(self, agent_id: str) -> bool:
async with self._lock:
if agent_id not in self.metadata:
return False
if self.metadata[agent_id].get("completion_nudge_started"):
return False
self.metadata[agent_id]["completion_nudge_started"] = True
await self._maybe_snapshot()
return True


async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
"""Deliver a user/peer message by appending it to the target SDK session."""
Expand Down
1 change: 1 addition & 0 deletions strix/core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]:
"agent_id": root_id,
"parent_id": None,
"interactive": interactive,
"completion_nudge": bool(scan_config.get("completion_nudge", False)),
"spawn_child_agent": spawn_child_agent,
}

Expand Down
21 changes: 21 additions & 0 deletions strix/interface/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scan_mode": scan_mode,
"non_interactive": bool(getattr(args, "non_interactive", False)),
"completion_nudge": bool(getattr(args, "completion_nudge", False)),
"local_sources": getattr(args, "local_sources", None) or [],
"scope_mode": getattr(args, "scope_mode", "auto"),
"diff_base": getattr(args, "diff_base", None),
Expand Down Expand Up @@ -118,6 +119,26 @@ def display_vulnerability(report: dict[str, Any]) -> None:
console.print()

report_state.vulnerability_found_callback = display_vulnerability
def display_completion_nudge() -> None:
nudge_text = Text()
nudge_text.append("Completion nudged", style="bold #eab308")
nudge_text.append("\n\n")
nudge_text.append(
"The agent is investigating underexplored areas before finalizing the report."
)
console.print(
Panel(
nudge_text,
title="[bold white]STRIX",
title_align="left",
border_style="#eab308",
padding=(1, 2),
)
)
console.print()

report_state.completion_nudge_callback = display_completion_nudge


def cleanup_on_exit() -> None:
report_state.cleanup()
Expand Down
13 changes: 13 additions & 0 deletions strix/interface/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,15 @@ def parse_arguments() -> argparse.Namespace:
"Default is interactive mode with TUI."
),
)
parser.add_argument(
"--completion-nudge",
action="store_true",
help=(
"Before accepting the final report, nudge the agent once to investigate "
"promising leads and other underexplored areas."
),
)


parser.add_argument(
"-m",
Expand Down Expand Up @@ -639,6 +648,7 @@ def _persist_run_record(args: argparse.Namespace) -> None:
"scan_mode": args.scan_mode,
"instruction": args.instruction,
"non_interactive": args.non_interactive,
"completion_nudge": bool(getattr(args, "completion_nudge", False)),
"local_sources": getattr(args, "local_sources", []),
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scope_mode": args.scope_mode,
Expand Down Expand Up @@ -683,6 +693,9 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser

if args.instruction is None:
args.instruction = state.get("instruction")
args.completion_nudge = bool(
args.completion_nudge or state.get("completion_nudge", False)
)
if state.get("local_sources"):
args.local_sources = state.get("local_sources")
if state.get("diff_scope"):
Expand Down
1 change: 1 addition & 0 deletions strix/interface/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scan_mode": getattr(args, "scan_mode", "deep"),
"non_interactive": bool(getattr(args, "non_interactive", False)),
"completion_nudge": bool(getattr(args, "completion_nudge", False)),
"local_sources": getattr(args, "local_sources", None) or [],
"scope_mode": getattr(args, "scope_mode", "auto"),
"diff_base": getattr(args, "diff_base", None),
Expand Down
84 changes: 46 additions & 38 deletions strix/interface/tui/renderers/finish_renderer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, ClassVar
from typing import Any, ClassVar, cast

from rich.text import Text
from textual.widgets import Static
Expand All @@ -17,49 +17,57 @@ class FinishScanRenderer(BaseToolRenderer):

@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})

executive_summary = args.get("executive_summary", "")
methodology = args.get("methodology", "")
technical_analysis = args.get("technical_analysis", "")
recommendations = args.get("recommendations", "")

raw_result = tool_data.get("result")
result = cast("dict[str, Any]", raw_result) if isinstance(raw_result, dict) else None
text = Text()
text.append("◆ ", style="#22c55e")
text.append("Penetration test completed", style="bold #22c55e")

if executive_summary:
text.append("\n\n")
text.append("Executive Summary", style=FIELD_STYLE)
if result is None:
text.append("◆ ", style="#eab308")
text.append("Generating final report...", style="bold #eab308")
render_status = "running"
elif result.get("completion_nudge") is True:
text.append("◆ ", style="#eab308")
text.append("Completion nudged", style="bold #eab308")
text.append("\n")
text.append(executive_summary)

if methodology:
text.append("\n\n")
text.append("Methodology", style=FIELD_STYLE)
text.append("\n")
text.append(methodology)

if technical_analysis:
text.append("\n\n")
text.append("Technical Analysis", style=FIELD_STYLE)
text.append("\n")
text.append(technical_analysis)

if recommendations:
text.append("\n\n")
text.append("Recommendations", style=FIELD_STYLE)
text.append("\n")
text.append(recommendations)

if not (executive_summary or methodology or technical_analysis or recommendations):
text.append("\n ")
text.append("Generating final report...", style="dim")
text.append(
"Testing continues while the agent investigates underexplored areas."
)
render_status = "completed"
elif result.get("success") is False:
text.append("◆ ", style="#ef4444")
text.append("Final report not accepted", style="bold #ef4444")
error = result.get("error")
if error:
text.append("\n")
text.append(str(error))
render_status = "error"
elif result.get("scan_completed") is True:
raw_args = tool_data.get("args", {})
args = cast("dict[str, Any]", raw_args) if isinstance(raw_args, dict) else {}
text.append("◆ ", style="#22c55e")
text.append("Penetration test completed", style="bold #22c55e")

fields = (
("Executive Summary", args.get("executive_summary", "")),
("Methodology", args.get("methodology", "")),
("Technical Analysis", args.get("technical_analysis", "")),
("Recommendations", args.get("recommendations", "")),
)
for heading, value in fields:
if value:
text.append("\n\n")
text.append(heading, style=FIELD_STYLE)
text.append("\n")
text.append(str(value))
render_status = "completed"
else:
text.append("◆ ", style="#eab308")
text.append("Generating final report...", style="bold #eab308")
render_status = "running"

padded = Text()
padded.append("\n\n")
padded.append_text(text)
padded.append("\n\n")

css_classes = cls.get_css_classes("completed")
return Static(padded, classes=css_classes)
return Static(padded, classes=cls.get_css_classes(render_status))
6 changes: 6 additions & 0 deletions strix/report/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ def __init__(self, run_name: str | None = None):

self.caido_url: str | None = None
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
self.completion_nudge_callback: Callable[[], None] | None = None

self._sarif_repo_ctx: dict[str, Any] | None = None
self._sarif_repo_ctx_ready: bool = False
Expand Down Expand Up @@ -329,6 +330,10 @@ def update_scan_final_fields(
posthog.end(self, exit_reason="finished_by_tool")
scarf.end(self, exit_reason="finished_by_tool")

def notify_completion_nudge(self) -> None:
if self.completion_nudge_callback:
self.completion_nudge_callback()

def set_scan_config(self, config: dict[str, Any]) -> None:
self.scan_config = config
self.run_record["status"] = "running"
Expand All @@ -344,6 +349,7 @@ def set_scan_config(self, config: dict[str, Any]) -> None:
"scan_mode": config.get("scan_mode", "deep"),
"diff_scope": config.get("diff_scope", {"active": False}),
"non_interactive": bool(config.get("non_interactive", False)),
"completion_nudge": bool(config.get("completion_nudge", False)),
"local_sources": config.get("local_sources", []),
"scope_mode": config.get("scope_mode", "auto"),
"diff_base": config.get("diff_base"),
Expand Down
90 changes: 77 additions & 13 deletions strix/tools/finish/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import asyncio
import json
import logging
from typing import Any
from typing import Any, cast

from agents import RunContextWrapper, function_tool

Expand All @@ -15,6 +15,36 @@
logger = logging.getLogger(__name__)


_COMPLETION_NUDGE_INSTRUCTION = (
"The report is a strong draft, but do not finish yet. First, identify concrete leads, "
"endpoints, behaviors, trust boundaries, or anomalies you already encountered but did "
"not investigate deeply enough, and actively test the most promising ones. Then review "
"the remaining scope for plausible areas you have not examined. Use tools and create "
"focused subordinate agents when useful, validate findings with evidence, avoid duplicate "
"vulnerability reports, and file every newly validated vulnerability with "
"create_vulnerability_report. When no worthwhile avenue remains, call finish_scan again "
"with a revised comprehensive report covering all prior and new findings."
)


def _finish_validation_errors(
executive_summary: str,
methodology: str,
technical_analysis: str,
recommendations: str,
) -> list[str]:
errors: list[str] = []
if not executive_summary.strip():
errors.append("Executive summary cannot be empty")
if not methodology.strip():
errors.append("Methodology cannot be empty")
if not technical_analysis.strip():
errors.append("Technical analysis cannot be empty")
if not recommendations.strip():
errors.append("Recommendations cannot be empty")
return errors


def _do_finish(
*,
parent_id: str | None,
Expand All @@ -32,15 +62,12 @@ def _do_finish(
),
}

errors: list[str] = []
if not executive_summary.strip():
errors.append("Executive summary cannot be empty")
if not methodology.strip():
errors.append("Methodology cannot be empty")
if not technical_analysis.strip():
errors.append("Technical analysis cannot be empty")
if not recommendations.strip():
errors.append("Recommendations cannot be empty")
errors = _finish_validation_errors(
executive_summary,
methodology,
technical_analysis,
recommendations,
)
if errors:
return {"success": False, "error": "Validation failed", "errors": errors}

Expand Down Expand Up @@ -146,10 +173,13 @@ async def finish_scan(
technical_analysis: Consolidated findings + systemic themes.
recommendations: Prioritized, actionable remediation.
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
raw_context: Any = ctx.context
inner = cast("dict[str, Any]", raw_context) if isinstance(raw_context, dict) else {}
coordinator = coordinator_from_context(inner)
me = inner.get("agent_id")
parent_id = inner.get("parent_id")
raw_agent_id = inner.get("agent_id")
me = raw_agent_id if isinstance(raw_agent_id, str) else None
raw_parent_id = inner.get("parent_id")
parent_id = raw_parent_id if isinstance(raw_parent_id, str) else None
if coordinator is not None and parent_id is None and me is not None:
active_agents = await coordinator.active_agents_except(me)
else:
Expand All @@ -170,6 +200,40 @@ async def finish_scan(
default=str,
)

validation_errors = _finish_validation_errors(
executive_summary,
methodology,
technical_analysis,
recommendations,
)
if parent_id is None and bool(inner.get("completion_nudge", False)) and not validation_errors:
claimed = False
if coordinator is not None and isinstance(me, str):
claimed = await coordinator.claim_completion_nudge(me)
elif not inner.get("completion_nudge_started"):
inner["completion_nudge_started"] = True
claimed = True

if claimed:
try:
from strix.report.state import get_global_report_state

report_state = get_global_report_state()
if report_state is not None:
report_state.notify_completion_nudge()
except Exception:
logger.exception("finish_scan completion nudge notification failed")
return json.dumps(
{
"success": True,
"scan_completed": False,
"completion_nudge": True,
"message": _COMPLETION_NUDGE_INSTRUCTION,
},
ensure_ascii=False,
default=str,
)

result = await asyncio.to_thread(
_do_finish,
parent_id=parent_id,
Expand Down
Loading