Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f23afb4
Add verified fix preparation engine
yoni-at-strix Sep 25, 2026
49eca20
Harden fix preparation against review findings
yoni-at-strix Sep 25, 2026
2b413da
Keep prepared candidates and staleness consistent across revisions
yoni-at-strix Sep 25, 2026
be1c2e4
Withhold automatic fixes when repairs exceed the recorded draft
yoni-at-strix Sep 25, 2026
9d525ad
Add bounded fix verification feedback loop
yoni-at-strix Sep 25, 2026
d02b74c
Preserve explicit blocked repair outcomes
yoni-at-strix Sep 25, 2026
a41000d
Retry unchanged repairs when verification changes
yoni-at-strix Sep 25, 2026
c510f58
fix: retry transient verifier inconclusive results
yoni-at-strix Sep 28, 2026
df313c1
refactor fix preparation gates
yoni-at-strix Sep 28, 2026
00fcb4c
fix: classify distinct check failures correctly
yoni-at-strix Sep 28, 2026
f213a7d
Require functional fix evidence and preserve partial preparation work
Sep 29, 2026
198a254
Make preparation history factory explicit for strict type checking
Sep 29, 2026
791ef91
fix: let independent evidence resolve repair timeout
yoni-at-strix Sep 29, 2026
85b3030
Preserve partial fixes and require consistent execution evidence
Sep 29, 2026
f03fd72
Simplify fix preparation around native tests and independent review
Sep 29, 2026
0faa7b7
Make fix handoffs actionable and require customer unit tests
Sep 29, 2026
43391eb
Let repair and review agents own the fix workflow
Sep 29, 2026
d184142
Let fix reviewer own validation and completion
Sep 29, 2026
348fbf2
Support native fix-agent assignments and final reviewed patches
Sep 30, 2026
5badb2d
Remove retired fix execution and proof machinery
Sep 30, 2026
d35197b
Move complete fix workflow into OSS and add strix fix CLI
Sep 30, 2026
acf262f
Keep fix outputs private and outside the source checkout
Sep 30, 2026
1c1a899
Focus fix agents and preserve completion evidence
Sep 30, 2026
77bd5da
Require an explicit fix handoff for source-backed findings
yoni-at-strix Sep 30, 2026
868ba53
Scope fix validation and warn on repeated commands
yoni-at-strix Sep 30, 2026
e4f1fe6
Merge remote-tracking branch 'origin/main' into devin/1790308365-veri…
yoni-at-strix Sep 30, 2026
ba6bbaf
Include repair follow-ups in the readable review
yoni-at-strix Sep 30, 2026
1789400
Keep an approved fix when only the PR text changes
yoni-at-strix Sep 30, 2026
8317665
Run confirmed finding fixes as native agents in the scan sandbox
yoni-at-strix Sep 30, 2026
b71ed13
Use native child delegation for finding fixes and strengthen completi…
yoni-at-strix Sep 30, 2026
1f8295c
Launch native fixes after persistence and bound completion failures
yoni-at-strix Sep 30, 2026
f75fb5f
fix: harden fix dispatch and verification (STR-815)
yoni-at-strix Oct 1, 2026
bba4aa2
fix: require reviewed current patches and enforce fix network isolation
Oct 1, 2026
60d4ce1
feat: allow scans to skip automatic fixes
yoni-at-strix Oct 1, 2026
3763a67
fix: finalize cancelled fix agents
yoni-at-strix Oct 1, 2026
1fa7d21
feat: publish verified fixes as local branches
yoni-at-strix Oct 1, 2026
f144685
fix: complete interactive autofix scans before cleanup
yoni-at-strix Oct 1, 2026
098fa36
fix: guard resumed assessments and ignore withdrawn fix records
yoni-at-strix Oct 1, 2026
9edb2ae
fix: disable automatic fix agents for PR review scans
yoni-at-strix Oct 1, 2026
8bd7f4c
refactor: use one auto-fix setting and infer fix delivery
yoni-at-strix Oct 2, 2026
818d583
Accept stray characters in validation status and hide auto-fix guidan…
yoni-at-strix Oct 2, 2026
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
Prev Previous commit
Next Next commit
Harden fix preparation against review findings
- Resolve edit/anchor/manifest paths and require workspace containment so
  committed symlinks cannot redirect reads or writes outside the checkout.
- Treat unreadable or non-UTF-8 anchor targets as missing instead of
  raising, and never let candidate anchoring block report persistence.
- Enforce the declared command policy: subprocess env is an allowlist plus
  credentials_allowed, and commands run in a network namespace (unshare)
  when network_allowed is false, or are rejected when isolation is
  unavailable.
- Require a clean worktree in addition to a matching HEAD commit so
  pre-existing uncommitted changes are not attributed to the fix.
- Expand untracked directories into per-file manifest entries.
- Surface failed optional checks as gaps instead of silent readiness.
- SARIF fixes emit only the verified candidate (digest must match the
  recorded fix_candidate), not the stale draft locations.
  • Loading branch information
yoni-at-strix committed Sep 25, 2026
commit 49eca20a1a4fab4633d916cd6654ee089d3479cb
18 changes: 15 additions & 3 deletions strix/fix/locations.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,26 @@ def _find_blocks(content: str, block: str) -> tuple[int, ...]:
)


def _read_anchor_source(root: Path, file: str) -> str | None:
"""Read a location's source, refusing symlinks that escape ``root`` and
files that cannot be decoded as UTF-8."""
resolved_root = root.resolve()
file_path = (root / file).resolve()
if not file_path.is_relative_to(resolved_root) or not file_path.is_file():
return None
try:
return file_path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
return None


def anchor_location(
root: Path,
location: CandidateLocation | FixEdit,
) -> AnchorResult:
file_path = root / location.file
if not file_path.is_file():
content = _read_anchor_source(root, location.file)
if content is None:
return AnchorResult(AnchorStatus.MISSING, location)
content = file_path.read_text(encoding="utf-8")
block = location.before if isinstance(location, FixEdit) else location.snippet
if not block:
return AnchorResult(AnchorStatus.UNIQUE, location, (location.start_line,))
Expand Down
148 changes: 133 additions & 15 deletions strix/fix/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from __future__ import annotations

import asyncio
import functools
import hashlib
import os
import subprocess
import time
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
Expand Down Expand Up @@ -62,7 +63,57 @@ class PreparationContext:
SourceVerifier = Callable[[PreparationContext], Awaitable[bool]]


async def run_command(workspace: Path, command: CommandSpec) -> CheckResult:
_COMMAND_ENV_ALLOWLIST = frozenset(
{
"HOME",
"LANG",
"LC_ALL",
"PATH",
"PYTHONHOME",
"PYTHONPATH",
"SYSTEMROOT",
"TEMP",
"TMP",
"TMPDIR",
"VIRTUAL_ENV",
"SystemRoot",
}
)


@functools.lru_cache(maxsize=1)
def _network_isolation_prefix() -> tuple[str, ...] | None:
"""Return a working ``unshare`` prefix that creates an empty network
namespace, or None when the platform cannot isolate egress."""
for prefix in (("unshare", "-Urn"), ("unshare", "-n")):
try:
probe = subprocess.run( # noqa: S603
[*prefix, "true"],
capture_output=True,
timeout=15,
check=False,
)
except (OSError, subprocess.SubprocessError):
continue
if probe.returncode == 0:
return prefix
return None


def _command_environment(credentials_allowed: Iterable[str]) -> dict[str, str]:
allowed = _COMMAND_ENV_ALLOWLIST | set(credentials_allowed)
env = {key: value for key, value in os.environ.items() if key in allowed}
env["PYTHONDONTWRITEBYTECODE"] = "1"
return env


async def run_command(
workspace: Path,
command: CommandSpec,
*,
credentials_allowed: Iterable[str] = (),
network_allowed: bool = False,
) -> CheckResult:
started = time.monotonic()
cwd = (workspace / command.cwd).resolve()
if not cwd.is_relative_to(workspace.resolve()) or not cwd.is_dir():
Expand All @@ -74,12 +125,25 @@ async def run_command(workspace: Path, command: CommandSpec) -> CheckResult:
output="The command working directory is unavailable.",
required=command.required,
)
argv = list(command.argv)
if not network_allowed:
prefix = _network_isolation_prefix()
if prefix is None:
return CheckResult(
name=command.name,
argv=command.argv,
status=CheckStatus.UNAVAILABLE,
duration_seconds=time.monotonic() - started,
output="Network isolation is unavailable, so the command was not run.",
required=command.required,
)
argv = [*prefix, *argv]
process: asyncio.subprocess.Process | None = None
try:
process = await asyncio.create_subprocess_exec(
*command.argv,
*argv,
cwd=cwd,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
env=_command_environment(credentials_allowed),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
Expand Down Expand Up @@ -123,8 +187,11 @@ def _apply_edits(workspace: Path, candidate: FixCandidateV1) -> None:
(edit.start_line, edit.end_line, edit.before, edit.after)
)

workspace_resolved = workspace.resolve()
for file_path, edits in by_file.items():
path = workspace / file_path
path = (workspace / file_path).resolve()
if not path.is_relative_to(workspace_resolved):
raise ValueError(f"Draft edit path escapes the workspace: {file_path}")
content = path.read_text(encoding="utf-8")
lines = content.splitlines(keepends=True)
newline = "\r\n" if "\r\n" in content else "\n"
Expand All @@ -148,6 +215,7 @@ async def build_git_manifest(
"git",
"status",
"--porcelain=v1",
"--untracked-files=all",
"-z",
cwd=workspace,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
stdout=asyncio.subprocess.PIPE,
Expand All @@ -160,6 +228,7 @@ async def build_git_manifest(
entries: list[FileManifestEntry] = []
changed_files: list[str] = []
records = [record for record in output.decode(errors="replace").split("\0") if record]
workspace_resolved = workspace.resolve()
index = 0
while index < len(records):
record = records[index]
Expand All @@ -176,7 +245,31 @@ async def build_git_manifest(
operation = "delete"
else:
operation = "modify"
resulting = hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
resolved = path.resolve()
contained = resolved.is_relative_to(workspace_resolved)
if operation == "add" and contained and resolved.is_dir():
for child in sorted(resolved.rglob("*")):
child_resolved = child.resolve()
if (
not child_resolved.is_file()
or not child_resolved.is_relative_to(workspace_resolved)
or ".git" in child.relative_to(resolved).parts
):
continue
entries.append(
FileManifestEntry(
path=child_resolved.relative_to(workspace_resolved).as_posix(),
operation="add",
resulting_sha256=hashlib.sha256(child_resolved.read_bytes()).hexdigest(),
)
)
index += 1
continue
resulting = (
hashlib.sha256(resolved.read_bytes()).hexdigest()
if contained and resolved.is_file()
else None
)
original: str | None = None
if operation != "add":
original_process = await asyncio.create_subprocess_exec(
Expand Down Expand Up @@ -215,18 +308,31 @@ async def build_git_manifest(

async def _verify_source(context: PreparationContext) -> bool:
identity = context.candidate.source_identity
if identity is None or identity.kind != "commit":
return identity is not None
process = await asyncio.create_subprocess_exec(
if identity is None:
return False
if identity.kind == "commit":
process = await asyncio.create_subprocess_exec(
"git",
"rev-parse",
"HEAD",
cwd=context.workspace,
stdout=asyncio.subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
output, _ = await process.communicate()
if process.returncode != 0 or output.decode().strip().lower() != identity.value:
return False
status_process = await asyncio.create_subprocess_exec(
"git",
"rev-parse",
"HEAD",
"status",
"--porcelain=v1",
"-z",
cwd=context.workspace,
stdout=asyncio.subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
output, _ = await process.communicate()
return process.returncode == 0 and output.decode().strip().lower() == identity.value
status_output, _ = await status_process.communicate()
return status_process.returncode == 0 and not status_output.strip(b"\x00")


def _result(
Expand Down Expand Up @@ -279,6 +385,13 @@ async def prepare_fix(
timeout_seconds=request.timeout_seconds,
)
context = PreparationContext(request=request, workspace=workspace, candidate=request.candidate)
runner: CommandRunner = command_runner
if runner is run_command:
runner = functools.partial(
run_command,
credentials_allowed=request.credentials_allowed,
network_allowed=request.network_allowed,
)

async def execute() -> FixPreparationResultV1: # noqa: PLR0911, PLR0912
if cancelled():
Expand Down Expand Up @@ -323,9 +436,9 @@ async def execute() -> FixPreparationResultV1: # noqa: PLR0911, PLR0912
if cancelled():
raise PreparationCancelledError
await repair(context, checks)
checks = [await command_runner(workspace, check) for check in request.checks]
checks = [await runner(workspace, check) for check in request.checks]
if context.candidate.reproduction and context.candidate.reproduction.command:
reproduction = await command_runner(
reproduction = await runner(
workspace,
context.candidate.reproduction.command,
)
Expand Down Expand Up @@ -355,6 +468,11 @@ async def execute() -> FixPreparationResultV1: # noqa: PLR0911, PLR0912
for result in checks
if result.required and result.status is CheckStatus.UNAVAILABLE
]
gaps.extend(
f"{result.name}: optional check {result.status}"
for result in checks
if not result.required and result.status is not CheckStatus.PASSED
)
if reproduction is None and not verifier.reproduction_executed:
gaps.append("No executable security reproduction was available.")
elif reproduction is not None and reproduction.status is CheckStatus.UNAVAILABLE:
Expand Down
53 changes: 27 additions & 26 deletions strix/report/sarif.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@
* File locations must be repo-relative POSIX paths. Paths that look
like URIs, absolute paths, or traversal patterns are rejected rather
than emitted as invalid code-scanning alerts.
* Findings with a fix suggestion (``code_locations[].fix_before`` +
``fix_after``) are emitted as SARIF ``fixes`` so code-scanning can
render a one-click suggested change.
* Findings whose fix candidate completed verified preparation
(``fix_preparation.state == "ready"`` with a matching
``candidate_digest``) are emitted as SARIF ``fixes`` so code-scanning
can render a one-click suggested change.
* Endpoint / target-only findings (typical of DAST) carry a SARIF
``logicalLocations`` entry so the finding keeps a meaningful anchor
even without a source file + line.
Expand All @@ -56,6 +57,10 @@
from pathlib import Path, PurePosixPath
from typing import Any, cast

from pydantic import ValidationError

from strix.fix.contracts import FixCandidateV1


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -583,46 +588,42 @@ def _result_properties(


def _build_fixes(report: dict[str, Any]) -> list[dict[str, Any]] | None:
"""Build SARIF ``fixes`` from a prepared finding.
"""Build SARIF ``fixes`` from a verified prepared finding.

SARIF consumers can apply ``fixes`` automatically. Strix emits them only
after the preparation stage records a ``ready`` result.
when preparation recorded a ``ready`` result whose ``candidate_digest``
still matches the stored fix candidate — so the emitted replacements are
exactly what preparation verified, never a stale or diverged draft.
"""
preparation = report.get("fix_preparation")
if not isinstance(preparation, dict) or preparation.get("state") != "ready":
return None
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
raw_locations = report.get("code_locations")
if not isinstance(raw_locations, list):
raw_candidate = report.get("fix_candidate")
if not isinstance(raw_candidate, dict):
return None
try:
candidate = FixCandidateV1.model_validate(raw_candidate)
except ValidationError:
return None
if preparation.get("candidate_digest") != candidate.digest():
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
return None

artifact_changes: list[dict[str, Any]] = []
for location in raw_locations:
if not isinstance(location, dict):
continue
file_path = _string_value(location.get("file"))
fix_before = _string_value(location.get("fix_before"))
fix_after = _string_value(location.get("fix_after"))
start_line = location.get("start_line")
if not (file_path and fix_before and fix_after):
continue
if type(start_line) is not int or start_line < 1:
continue
uri = _sarif_uri(file_path)
for edit in candidate.draft_edits:
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
uri = _sarif_uri(edit.file)
if uri is None:
continue

deleted_region: dict[str, Any] = {"startLine": start_line}
end_line = location.get("end_line")
if type(end_line) is int and end_line >= start_line:
deleted_region["endLine"] = end_line

deleted_region: dict[str, Any] = {
"startLine": edit.start_line,
"endLine": edit.end_line,
}
artifact_changes.append(
{
"artifactLocation": {"uri": uri},
"replacements": [
{
"deletedRegion": deleted_region,
"insertedContent": {"text": fix_after},
"insertedContent": {"text": edit.after},
}
],
}
Expand Down
27 changes: 18 additions & 9 deletions strix/tools/reporting/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,15 +413,24 @@ def _build_fix_candidate(
if candidate is None:
return None
if repo_path is not None:
anchored, results = anchor_candidate(repo_path, candidate)
candidate = anchored
gaps = [
f"{result.location.file}: {result.status}"
for result in results
if result.status is not AnchorStatus.UNIQUE
]
if gaps:
candidate = candidate.model_copy(update={"known_gaps": [*candidate.known_gaps, *gaps]})
try:
anchored, results = anchor_candidate(repo_path, candidate)
except Exception as exc: # noqa: BLE001
# Anchoring must never block the finding from being stored.
candidate = candidate.model_copy(
update={"known_gaps": [*candidate.known_gaps, f"Candidate anchoring failed: {exc}"]}
)
else:
candidate = anchored
gaps = [
f"{result.location.file}: {result.status}"
for result in results
if result.status is not AnchorStatus.UNIQUE
]
if gaps:
candidate = candidate.model_copy(
update={"known_gaps": [*candidate.known_gaps, *gaps]}
)
return cast("dict[str, object]", candidate.model_dump(mode="json"))


Expand Down
Loading