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
4 changes: 4 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ Configure Strix using environment variables or a config file.
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
</ParamField>

<ParamField path="STRIX_RESUME_RETRACT" default="false" type="string">
Enable retraction of fixed findings on a resumed scan. Off by default, so `--resume` is append-only (findings only carry forward). When set to `1`/`true`, a resumed whitebox scan gains the `retract_vulnerability_report` tool: if the agent determines a rehydrated finding has been fixed, it is dropped from the cumulative report and SARIF so the resolved finding stops re-appearing. See [Resuming scans](/usage/cli#resuming-a-scan).
</ParamField>

<ParamField path="TRACELOOP_BASE_URL" type="string">
OTLP/Traceloop base URL for remote OpenTelemetry export. If unset, Strix keeps traces local only.
</ParamField>
Expand Down
51 changes: 51 additions & 0 deletions docs/usage/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
counts.
</ParamField>

<ParamField path="--resume" type="string">
Resume a prior scan by its run name (the directory under `./strix_runs/`).
Restores the root agent plus every non-terminal subagent's full LLM history
and topology, and rehydrates prior findings so the report and SARIF are
cumulative across runs. Cannot be combined with `--target`/`--target-list`/`--mount`
(the original targets are restored from the prior run). See
[Resuming a scan](#resuming-a-scan).
</ParamField>

## Examples

```bash
Expand Down Expand Up @@ -112,6 +121,48 @@ strix --target-list ./targets.txt
strix --mount ./huge-monorepo
```

## Resuming a scan

`--resume <run_name>` continues a prior scan: it restores the full agent history
and rehydrates the findings from the previous run, so the report and SARIF are
**cumulative** across runs. This suits re-scanning a target as it changes — for
example re-running against each push of a pull request.

```bash
# First scan of the code
strix --run-name pr-42 -t ./repo

# Later, after new commits land — pick up where it left off
strix --resume pr-42
```

By default a resumed scan is **append-only**: prior findings carry forward, and
new issues are added, but nothing is removed. If the target has changed such that
a previously-reported finding is now **fixed**, it still re-appears in the
cumulative SARIF — which a code-scanning integration cannot auto-resolve.

### Retracting fixed findings

Set `STRIX_RESUME_RETRACT=1` to let a resumed **whitebox** scan drop findings it
determines are fixed. This exposes a `retract_vulnerability_report` tool: when
the agent judges a rehydrated finding resolved (the vulnerable code was fixed or
removed), it is removed from the cumulative report and SARIF, so a code-scanning
alert can auto-resolve.

```bash
STRIX_RESUME_RETRACT=1 strix --resume pr-42
```

<Note>
Retraction trusts the agent's judgement — the same judgement used to find the
vulnerability in the first place. Each retraction is re-checked against the
current source where possible and the result is recorded (`grounded` on the
tool result) for auditability, but the agent's decision is not overridden.
Because findings are re-derived on every scan, a mistaken retraction is
self-correcting: it re-appears on the next scan of the same code. The feature
is off by default; `--resume` stays append-only unless you opt in.
</Note>

## Exit Codes

| Code | Meaning |
Expand Down
16 changes: 16 additions & 0 deletions strix/agents/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ def build_strix_agent(
system_prompt_context: dict[str, Any] | None = None,
extra_tools: Sequence[Tool] | None = None,
instructions_override: str | None = None,
is_resume: bool = False,
) -> SandboxAgent[Any]:
"""Build a SandboxAgent for either root or child use.

Expand All @@ -421,6 +422,11 @@ def build_strix_agent(
registered via ``register_agent_tools``.
instructions_override: Use this verbatim as the system prompt instead
of rendering the built-in scan prompt.
is_resume: On a resumed root run, and only when opted in via
``RuntimeSettings.resume_retract_enabled`` (``STRIX_RESUME_RETRACT``),
expose ``retract_vulnerability_report`` so the agent can drop a
rehydrated finding it re-verifies as fixed. Off by default → resume
stays append-only. A fresh scan never needs it.
"""
if instructions_override is not None:
instructions = instructions_override
Expand All @@ -437,6 +443,16 @@ def build_strix_agent(
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
if is_root:
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
# retract_vulnerability_report is the inverse of create — meaningful
# only on a RESUMED root run (fresh scans have nothing to retract) and
# only when explicitly opted in. Guarded again at runtime: the tool
# refuses unless a source-tree reader is wired (whitebox resume).
from strix.config import load_settings

if is_resume and load_settings().runtime.resume_retract_enabled:
from strix.tools.reporting.retract_tool import retract_vulnerability_report

tools.append(retract_vulnerability_report)
else:
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
_ensure_unique_tool_names(tools)
Expand Down
10 changes: 10 additions & 0 deletions strix/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ class RuntimeSettings(BaseSettings):
# on large repos). Above this, the user must bind-mount via ``--mount``.
# Set to 0 (or less) to disable the pre-flight check entirely.
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
# Opt-in: on a resumed scan, expose the retract_vulnerability_report tool so
# the agent can DROP a rehydrated finding it re-verifies as fixed. Default
# OFF — resume stays append-only (today's behaviour) unless enabled. Only has
# any effect on a resumed whitebox run (the guard needs a source tree to
# re-verify against); a no-op otherwise. Useful when driving resume as a
# PR-lifecycle loop where cumulative findings must shrink as fixes land.
resume_retract_enabled: bool = Field(
default=False,
alias="STRIX_RESUME_RETRACT",
)


class TelemetrySettings(BaseSettings):
Expand Down
82 changes: 82 additions & 0 deletions strix/core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,62 @@ def _compose_root_instructions_override(
)


def _make_workspace_file_reader(
session: Any,
ws_paths: list[str],
) -> Callable[[str], Any]:
"""Build the target-file reader the retract guard uses to re-verify a fix.

Fail-honest contract (the guard scores the result, see retract_tool):
- returns the file text when a candidate path is read;
- returns ``None`` ONLY when the file is POSITIVELY confirmed absent under
EVERY workspace root (``cat`` -> "No such file"); the sink is gone;
- RAISES when a read neither returns content nor confirms absence (exec
error, or an ambiguous nonzero like permission-denied). "Couldn't read"
is scored inconclusive by the guard, never mistaken for "confirmed gone"
— so a misdirected read can't masquerade as a verified fix.
"""

async def _read_target_file(rel_path: str) -> str | None:
rel = rel_path.lstrip("/")
confirmed_absent_everywhere = True
errors: list[str] = []
for base in ws_paths:
path = f"{base}/{rel}"
try:
result = await session.exec("cat", path, timeout=15)
except Exception as exc: # noqa: BLE001 — read failure ≠ proof of absence
confirmed_absent_everywhere = False
errors.append(f"{path}: exec error {exc!r}")
continue
if getattr(result, "exit_code", 1) == 0:
# ExecResult.stdout is bytes; the guard does a str substring
# check, so decode here.
stdout = result.stdout
return (
stdout.decode("utf-8", errors="replace")
if isinstance(stdout, bytes | bytearray)
else stdout
)
stderr = getattr(result, "stderr", b"") or b""
stderr_txt = (
stderr.decode("utf-8", errors="replace")
if isinstance(stderr, bytes | bytearray)
else str(stderr)
)
# Only "No such file" is a POSITIVE absence signal. Any other nonzero
# (permission denied, is-a-directory) is NOT proof of a fix.
if "No such file" not in stderr_txt:
confirmed_absent_everywhere = False
errors.append(f"{path}: exit {result.exit_code}: {stderr_txt.strip()[:120]}")
if confirmed_absent_everywhere:
return None # genuinely absent under every root → sink is gone
msg = f"could not read {rel} for retract verification: {'; '.join(errors)}"
raise OSError(msg)

return _read_target_file


async def run_strix_scan(
*,
scan_config: dict[str, Any],
Expand Down Expand Up @@ -236,6 +292,24 @@ async def run_strix_scan(
system_prompt_context=root_context,
)

# Opt-in grounded retraction on resume: wire a sandbox-backed reader so
# the retract tool's guard can re-verify a "fixed" claim against the live
# /workspace tree before dropping a finding. Whitebox only — the guard
# checks source sinks; without a tree it stays fail-safe (refuse). Gated
# behind resume_retract_enabled so this is a no-op unless opted in.
if is_resume and is_whitebox and settings.runtime.resume_retract_enabled:
from strix.tools.reporting.retract_tool import set_target_file_reader

ws_paths = [
f"/workspace/{sub}" if sub else "/workspace"
for sub in (
(t.get("details") or {}).get("workspace_subdir")
for t in targets
if t.get("type") == "local_code"
)
] or ["/workspace"]
set_target_file_reader(_make_workspace_file_reader(bundle["session"], ws_paths))

root_agent = build_strix_agent(
name="strix",
skills=skills,
Expand All @@ -246,6 +320,7 @@ async def run_strix_scan(
chat_completions_tools=chat_completions_tools,
system_prompt_context=root_context,
instructions_override=root_instructions,
is_resume=is_resume,
)

if not is_resume:
Expand Down Expand Up @@ -397,6 +472,13 @@ async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]:
await coordinator.set_status(root_id, "failed")
raise
finally:
# Clear the retract reader so it can't bind to a torn-down session and
# verify a later scan against the wrong (or dead) sandbox. The guard
# then fails safe (no reader → refuse) until the next resume re-wires it.
with contextlib.suppress(Exception):
from strix.tools.reporting.retract_tool import set_target_file_reader

set_target_file_reader(None)
for s in sessions_to_close:
with contextlib.suppress(Exception):
s.close()
Expand Down
110 changes: 109 additions & 1 deletion strix/report/state.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
import json
import logging
import subprocess
Expand Down Expand Up @@ -205,6 +206,43 @@ def hydrate_from_run_dir(self) -> None:
len(self.vulnerability_reports),
)

@staticmethod
def _vuln_ordinal(report_id: object) -> int:
if isinstance(report_id, str) and report_id.startswith("vuln-"):
try:
return int(report_id.removeprefix("vuln-"))
except ValueError:
return 0
return 0

def _next_vuln_ordinal(self) -> int:
"""Next ``vuln-NNNN`` ordinal, derived from the max ordinal EVER seen
rather than the current list length.

List-length allocation (``len(reports) + 1``) is only correct while the
report set is append-only. Once a report can be *removed*
(:meth:`retract_vulnerability_report` on resume), length-based ids
recycle: retract ``vuln-0002`` from ``[0001,0002,0003]`` and the next
add computes ``len+1 = 3`` -> ``vuln-0003``, colliding with a live id and
overwriting its on-disk MD, and letting a retracted id later re-emit
under a different finding (SARIF fingerprint collision).

The high-water mark is persisted in ``run_record["max_vuln_ordinal"]``
and restored by :meth:`hydrate_from_run_dir`, so it survives across the
process boundary of a resume. Without that, a NEW process rebuilds
``_saved_vuln_ids`` only from the live ``vulnerabilities.json`` — the
retracted id is gone from disk, so the next allocation would reuse it.
We take the max of the persisted mark and every ordinal currently in
memory (live reports + retained ``_saved_vuln_ids``), so it stays correct
even if the record is missing/stale.
"""
candidates = [
int(self.run_record.get("max_vuln_ordinal") or 0),
*(self._vuln_ordinal(r.get("id")) for r in self.vulnerability_reports),
*(self._vuln_ordinal(rid) for rid in self._saved_vuln_ids),
]
return max(candidates) + 1

def add_vulnerability_report(
self,
title: str,
Expand Down Expand Up @@ -232,7 +270,11 @@ def add_vulnerability_report(
agent_id: str | None = None,
agent_name: str | None = None,
) -> str:
report_id = f"vuln-{len(self.vulnerability_reports) + 1:04d}"
ordinal = self._next_vuln_ordinal()
report_id = f"vuln-{ordinal:04d}"
# Persist the high-water mark so a retracted top id is never reused after
# a resume in a fresh process (run_record survives via run.json).
self.run_record["max_vuln_ordinal"] = ordinal

report: dict[str, Any] = {
"id": report_id,
Expand Down Expand Up @@ -296,6 +338,72 @@ def add_vulnerability_report(
self.save_run_data()
return report_id

def retract_vulnerability_report(self, report_id: str, reason: str) -> dict[str, Any]:
"""Remove a previously-reported finding from the cumulative set.

On resume, prior findings are rehydrated from ``vulnerabilities.json``.
When a later push FIXES one, the agent re-validates and must be able to
DROP it — otherwise rehydration is append-only and the fixed finding
re-emits into every subsequent SARIF forever, producing a scanner alert
that can never auto-resolve (fail-CLOSED). This is the inverse of
:meth:`add_vulnerability_report`.

Reverses add's side effects:
1. drop the report from the in-memory ``vulnerability_reports`` list
2. delete the stale per-finding ``{id}.md`` (the one append-only
artefact; CSV/JSON/SARIF are rewritten wholesale by
:meth:`save_run_data` below from the reduced set)

The id is retained in ``_saved_vuln_ids`` (NOT recycled): combined with
:meth:`_next_vuln_ordinal` deriving from the max ordinal ever seen, a
physical removal can never cause a later finding to reuse a retracted
id. Idempotent: retracting an unknown/already-gone id is a no-op success.

NOTE: the caller (the retract tool) applies the groundedness guard —
re-verifying against the current tree that the vulnerable sink is
actually gone — BEFORE invoking this. This method performs the state
mutation only; it does not decide whether the retraction is justified.
"""
reason = (reason or "").strip()
if not reason:
raise ValueError(
"retract_vulnerability_report requires a non-empty reason "
"(cite what was verified fixed, and where)",
)
retracted = next(
(r for r in self.vulnerability_reports if r.get("id") == report_id),
None,
)
if retracted is None:
logger.info("retract: %s not present (no-op)", report_id)
return {"success": True, "retracted": False, "reason": "id not present"}

before = len(self.vulnerability_reports)
self.vulnerability_reports = [
r for r in self.vulnerability_reports if r.get("id") != report_id
]
# Deliberately keep report_id in _saved_vuln_ids so the ordinal counter
# never re-allocates it; delete the stale MD so a future rehydration
# doesn't reload the retracted finding from disk.
with contextlib.suppress(OSError):
md_path = self.get_run_dir() / "vulnerabilities" / f"{report_id}.md"
md_path.unlink(missing_ok=True)

logger.info(
"Retracted vulnerability report %s (%s) — reason: %s",
report_id,
retracted.get("title", ""),
reason,
)
self.save_run_data()
return {
"success": True,
"retracted": True,
"report_id": report_id,
"title": retracted.get("title"),
"remaining": before - 1,
}

def get_existing_vulnerabilities(self) -> list[dict[str, Any]]:
return list(self.vulnerability_reports)

Expand Down
Loading