Skill: anshumanbh/securevibes-scanner (version 0.5.3)

--- name: securevibes-scanner description: Run AI-powered application security scans on codebases. Use when asked to scan code for security vulnerabilities, generate threat models, review code for security issues, run incremental security scans, or set up continuous security monitoring via cron. Supports full scans (one-shot) and incremental scans (cron-driven, only new commits). --- # SecureVibes Scanner AI-native security platform that detects vulnerabilities using Claude AI. Multi-subagent pipeline: assessment → threat modeling → code review → report generation → optional DAST. Supports incremental scanning for continuous monitoring. ## Prerequisites 1. Install the CLI: `pipx install securevibes` (preferred) or `uv tool install securevibes`. Avoid `pip install` — it can create stale shims if you have multiple Python environments. 2. Authenticate with Anthropic (one of): - **Max/Pro subscription (recommended):** If you're authenticated via Claude Code or Claude CLI OAuth, no API key is needed. The Claude Agent SDK picks up your OAuth session automatically. When running inside OpenClaw, leave `ANTHROPIC_API_KEY` unset or blank — the SDK handles auth. - **API key:** `export ANTHROPIC_API_KEY=your-key-here` (from console.anthropic.com) ## Security Notes - Always use the `scripts/scan.sh` wrapper for full scans — it validates paths and rejects shell metacharacters before invoking `securevibes`. - **Never interpolate unsanitized user input into shell commands.** - The wrapper uses `realpath` to resolve paths safely and rejects any path containing `;`, `|`, `&`, `$`, backticks, or other metacharacters. - **Scan targets must be local directories.** Clone remote repos to a known safe location first, then pass the resolved path to the wrapper. - **DAST scans make network requests** to the `--target-url` you provide. Only use against apps you own or have permission to test. ## Execution Model **Full scans take 10-30 minutes across 4 phases.** Run them as background jobs (cron or subagent), not inline. **Incremental scans take 2-10 minutes** — they only scan commits since the last run. ## Full Scan (One-Shot) ### Running a Scan 1. Clone the target repo to a local directory 2. Run the wrapper script: `bash scripts/scan.sh /path/to/repo --force --debug` 3. Results appear in `/path/to/repo/.securevibes/` ### Background Execution (Recommended) For OpenClaw users, schedule scans as cron jobs: - Use `sessionTarget: "isolated"` with `payload.kind: "agentTurn"` - Set `payload.timeoutSeconds: 2700` (45 minutes) to allow all phases to complete - Use `delivery.mode: "announce"` to get notified when done The agentTurn message should instruct the subagent to: 1. `cd` into the repo and `git pull` for latest code 2. Clean previous `.securevibes/` artifacts 3. Run `securevibes scan . --force` via the wrapper script 4. Read and summarize the results from `.securevibes/scan_report.md` ## Incremental Scan (Continuous Monitoring) The incremental scanner (`ops/incremental_scan.py`) tracks the last-scanned commit and only scans new commits. Designed for cron-driven continuous security monitoring. ### How It Works 1. Tracks an anchor commit in `.securevibes/incremental_state.json` 2. On each run: fetches remote, compares HEAD to anchor 3. If new commits exist: runs `securevibes pr-review` on the diff 4. Updates anchor to new HEAD after successful scan 5. If no new commits: exits cleanly (no scan, no cost) ### Setup #### Step 1: Run an initial full scan (if not already done) The incremental scanner requires `.securevibes/SECURITY.md` and `.securevibes/THREAT_MODEL.json` to exist. These come from an initial full scan: ```bash securevibes scan <repo-path> --model sonnet ``` Skip this step if the repo already has a `.securevibes/` directory with these files. #### Step 2: Bootstrap incremental state Run the wrapper once to seed the anchor commit (no scan runs, just records current HEAD): ```bash python3 ops/incremental_scan.py --repo <repo-path> --remote origin --branch main ``` This creates `.securevibes/incremental_state.json` with `status: "bootstrap"`. #### Step 3: Configure the cron For OpenClaw users, create a cron job: ```bash openclaw cron create \ --name "securevibes-incremental" \ --cron "*/30 * * * *" \ --tz "America/Los_Angeles" \ --agent main \ --session isolated \ --timeout-seconds 900 \ --announce \ --message "Run incremental security scan: python3 <skill-path>/ops/incremental_scan.py --repo <repo-path> --remote origin --branch main --model sonnet --severity medium --scan-timeout-seconds 600. Read .securevibes/incremental_scan.log for results. If new findings, summarize them." ``` Replace `<skill-path>` with the installed skill path and `<repo-path>` with the target repo. #### Step 4: Verify ```bash # Check state cat <repo-path>/.securevibes/incremental_state.json # After first scheduled run, check logs tail -10 <repo-path>/.securevibes/incremental_scan.log # Check findings cat <repo-path>/.securevibes/PR_VULNERABILITIES.json ``` ### Incremental Scanner Options ``` python3 ops/incremental_scan.py [options] ``` | Option | Description | |--------|-------------| | `--repo` | Repository path (default: `.`) | | `--branch` | Branch to track (default: `main`) | | `--remote` | Git remote (default: `origin`) | | `--model` | Claude model: `sonnet`, `haiku` (default: `sonnet`) | | `--severity` | Minimum severity: `critical`, `high`, `medium`, `low` | | `--scan-timeout-seconds` | Timeout per scan command (default: `900`) | | `--git-timeout-seconds` | Timeout for git operations (default: `60`) | | `--rewrite-policy` | History rewrite handling: `reset_warn`, `strict_fail`, `since_date` | | `--since` | Override: scan commits since this date (ISO or YYYY-MM-DD) | ### Operational Guarantees - **File lock** at `.securevibes/.incremental_scan.lock` prevents overlapping runs - **Atomic state writes** (`fsync` + `os.replace`) prevent corruption - **Structured logging** at `.securevibes/incremental_scan.log` - **Run records** saved to `.securevibes/incremental_runs/` (one JSON per run) ### Rewrite Policy When `last_seen_sha` is not an ancestor of the new remote HEAD (e.g., force push): | Policy | Behavior | |--------|----------| | `reset_warn` | Reset anchor to new HEAD, continue | | `strict_fail` | Fail and keep current anchor | | `since_date` | Run a `--since <today>` scan for visibility, keep previous anchor | ## Full Scan Commands Reference ### Scan `securevibes scan <path> [options]` | Option | Description | |--------|-------------| | `-f, --format` | `markdown` (default), `json`, `text`, `table` | | `-o, --output` | Custom output path | | `-s, --severity` | Filter: `critical`, `high`, `medium`, `low` | | `-m, --model` | Claude model (e.g., `sonnet`, `haiku`) | | `--subagent` | Run one phase: `assessment`, `threat-modeling`, `code-review`, `report-generator`, `dast` | | `--resume-from` | Resume from a specific phase onwards | | `--dast` | Enable dynamic testing (requires `--target-url`) | | `--target-url` | URL for DAST (e.g., `http://localhost:3000`) | | `--force` | Skip prompts, overwrite existing artifacts | | `--quiet` | Minimal output | | `--debug` | Verbose diagnostics | ### Report `securevibes report <path>` — Display a previously saved scan report. ## Mapping Requests to Actions | User Says | Action | |-----------|--------| | "Scan this for security issues" | Full scan: `bash scripts/scan.sh <path> --force` | | "Quick security check" | Full scan: `bash scripts/scan.sh <path> -m haiku --force` | | "Threat model this project" | `bash scripts/scan.sh <path> --subagent threat-modeling --force` | | "Just review the code" | `bash scripts/scan.sh <path> --subagent code-review --force` | | "Show only critical/high findings" | `bash scripts/scan.sh <path> -s high --force` | | "Full audit with DAST" | `bash scripts/scan.sh <path> --dast --target-url <url> --force` | | "Set up continuous scanning" | Incremental setup: Steps 1-4 above | | "Monitor this repo for security issues" | Incremental setup: Steps 1-4 above | | "Show last scan results" | `securevibes report <path>` | ## Subagent Pipeline Runs sequentially. Each phase builds on the previous: 1. **assessment** → Architecture & attack surface → `.securevibes/SECURITY.md` 2. **threat-modeling** → STRIDE-based analysis → `.securevibes/THREAT_MODEL.json` 3. **code-review** → Vulnerability detection → `.securevibes/VULNERABILITIES.json` 4. **report-generator** → Consolidated report → `.securevibes/scan_report.md` 5. **dast** (optional) → Dynamic validation against running app ## Presenting Results After a scan completes: 1. Read `.securevibes/scan_report.md` (or `.securevibes/scan_results.json` for structured data) 2. Summarize: total findings by severity (Critical > High > Medium > Low) 3. Highlight top 3 most critical with file locations and remediation 4. Offer next steps: run DAST, fix specific issues, re-scan after changes ## Links - **Website**: [https://securevibes.ai](https://securevibes.ai) - **PyPI**: [https://pypi.org/project/securevibes/](https://pypi.org/project/securevibes/) - **GitHub**: [https://github.com/anshumanbh/securevibes](https://github.com/anshumanbh/securevibes)

path: _meta.json
content: ownerId: kn73wn68dt1shtabc1h6yt3bdd801geg
slug: securevibes-scanner
version: 0.5.2
publishedAt: 1771923154032

path: ops/incremental_scan.py
content: #!/usr/bin/env python3
"""Cron-friendly incremental SecureVibes PR review wrapper."""

from __future__ import annotations

import argparse
import json
import os
import secrets
import shutil
import subprocess
import sys
import tempfile
import time
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator

try:
    import fcntl
except ImportError:  # pragma: no cover
    fcntl = None

try:
    from zoneinfo import ZoneInfo
except ImportError:  # pragma: no cover
    ZoneInfo = None  # type: ignore[assignment]

REQUIRED_REPORT_FIELDS = (
    "repository_path",
    "files_scanned",
    "scan_time_seconds",
    "issues",
)
COMPLETED = "completed"
INFRA_FAILURE = "infra_failure"
DEFAULT_GIT_TIMEOUT_SECONDS = 60
DEFAULT_SCAN_TIMEOUT_SECONDS = 900


@dataclass
class ScanCommandResult:
    """Result of one wrapper-invoked SecureVibes command."""

    command: list[str]
    exit_code: int
    classification: str
    stderr_tail: str
    output_path: Path


@dataclass
class RewriteOutcome:
    """Decision object for history-rewrite handling."""

    action: str
    reason: str
    since_date: str | None = None


def utc_timestamp() -> str:
    """Return UTC timestamp in ISO-8601 with Z suffix."""
    return (
        datetime.now(timezone.utc)
        .replace(microsecond=0)
        .isoformat()
        .replace("+00:00", "Z")
    )


def generate_run_id() -> str:
    """Create stable run id used for logs and output artifacts."""
    return (
        datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        + f"-{secrets.token_hex(3)}"
    )


def positive_int(value: str) -> int:
    """Parse positive integer argparse values."""
    try:
        parsed = int(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("must be a positive integer") from exc
    if parsed <= 0:
        raise argparse.ArgumentTypeError("must be a positive integer")
    return parsed


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    """Parse CLI arguments for incremental wrapper."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo", default=".", help="Repository path")
    parser.add_argument("--branch", default="main", help="Branch to track")
    parser.add_argument("--remote", default="origin", help="Git remote")
    parser.add_argument("--model", default="sonnet", help="SecureVibes model")
    parser.add_argument(
        "--severity",
        default="medium",
        choices=("critical", "high", "medium", "low"),
        help="Minimum severity for pr-review",
    )
    parser.add_argument(
        "--state-file",
        default=".securevibes/incremental_state.json",
        help="State file path",
    )
    parser.add_argument(
        "--log-file",
        default=".securevibes/incremental_scan.log",
        help="Log file path",
    )
    parser.add_argument("--chunk-small-max", type=int, default=8)
    parser.add_argument("--chunk-medium-max", type=int, default=25)
    parser.add_argument("--chunk-medium-size", type=int, default=5)
    parser.add_argument("--retry-network", type=int, default=1)
    parser.add_argument(
        "--rewrite-policy",
        default="reset_warn",
        choices=("reset_warn", "since_date", "strict_fail"),
    )
    parser.add_argument(
        "--git-timeout-seconds",
        type=positive_int,
        default=DEFAULT_GIT_TIMEOUT_SECONDS,
        help="Timeout for each git command invocation",
    )
    parser.add_argument(
        "--scan-timeout-seconds",
        type=positive_int,
        default=DEFAULT_SCAN_TIMEOUT_SECONDS,
        help="Timeout for each securevibes pr-review invocation",
    )
    parser.add_argument("--strict", action="store_true")
    parser.add_argument("--debug", action="store_true")
    return parser.parse_args(argv)


def resolve_repo_path(repo: Path, candidate: str) -> Path:
    """Resolve candidate path relative to repo root when needed."""
    path = Path(candidate)
    if path.is_absolute():
        return path
    return repo / path


def load_state(state_path: Path) -> dict[str, Any] | None:
    """Load state file from disk and return dict when valid."""
    if not state_path.exists():
        return None
    try:
        data = json.loads(state_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    if not isinstance(data, dict):
        return None
    return data


def save_state(state_path: Path, state: dict[str, Any]) -> None:
    """Persist state atomically to avoid partial writes."""
    state_path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_name = tempfile.mkstemp(
        dir=str(state_path.parent), prefix=f".{state_path.name}.", suffix=".tmp"
    )
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(state, handle, indent=2)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(tmp_name, state_path)
    except BaseException:
        try:
            os.unlink(tmp_name)
        except OSError:
            pass
        raise


def save_json(path: Path, payload: dict[str, Any]) -> None:
    """Write JSON payload to disk with parent mkdir and deterministic encoding."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def append_log(log_file: Path, message: str) -> None:
    """Append one line to text log with UTC prefix."""
    log_file.parent.mkdir(parents=True, exist_ok=True)
    timestamp = utc_timestamp()
    with log_file.open("a", encoding="utf-8") as handle:
        handle.write(f"[{timestamp}] {message}\n")


def ensure_dependencies() -> None:
    """Verify required binaries are available on PATH."""
    for binary in ("git", "securevibes"):
        if shutil.which(binary):
            continue
        raise RuntimeError(f"Missing required dependency: {binary}")


def ensure_repo(repo: Path, timeout_seconds: int = DEFAULT_GIT_TIMEOUT_SECONDS) -> None:
    """Ensure repository exists and is a git worktree."""
    if not repo.exists() or not repo.is_dir():
        raise RuntimeError(f"Repository path does not exist: {repo}")

    result = subprocess.run(
        ["git", "rev-parse", "--is-inside-work-tree"],
        cwd=repo,
        capture_output=True,
        text=True,
        timeout=timeout_seconds,
        check=False,
    )
    if result.returncode != 0 or result.stdout.strip() != "true":
        stderr = result.stderr.strip() or "not a git repository"
        raise RuntimeError(f"Invalid repository: {stderr}")


def ensure_baseline_artifacts(repo: Path) -> None:
    """Ensure baseline artifacts required by securevibes pr-review exist."""
    securevibes_dir = repo / ".securevibes"
    required = [
        securevibes_dir / "SECURITY.md",
        securevibes_dir / "THREAT_MODEL.json",
    ]
    missing = [str(path) for path in required if not path.exists()]
    if missing:
        joined = ", ".join(missing)
        raise RuntimeError(f"Missing baseline artifacts for pr-review: {joined}")


def git_fetch(
    repo: Path,
    remote: str,
    branch: str,
    retries: int,
    timeout_seconds: int = DEFAULT_GIT_TIMEOUT_SECONDS,
) -> None:
    """Fetch remote branch with retry on transient failure."""
    attempts = max(0, retries) + 1
    last_error = ""
    for attempt in range(1, attempts + 1):
        try:
            result = subprocess.run(
                ["git", "fetch", remote, branch],
                cwd=repo,
                capture_output=True,
                text=True,
                timeout=timeout_seconds,
                check=False,
            )
        except subprocess.TimeoutExpired:
            last_error = f"git fetch timed out after {timeout_seconds}s"
            if attempt < attempts:
                time.sleep(1)
            continue
        if result.returncode == 0:
            return
        last_error = result.stderr.strip() or "git fetch failed"
        if attempt < attempts:
            time.sleep(1)
    raise RuntimeError(last_error)


def resolve_head(
    repo: Path,
    remote: str,
    branch: str,
    timeout_seconds: int = DEFAULT_GIT_TIMEOUT_SECONDS,
) -> str:
    """Resolve the latest commit SHA for remote branch."""
    try:
        result = subprocess.run(
            ["git", "rev-parse", f"{remote}/{branch}"],
            cwd=repo,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
            check=False,
        )
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(f"git rev-parse timed out after {timeout_seconds}s") from exc
    if result.returncode != 0:
        stderr = result.stderr.strip() or "unable to resolve head"
        raise RuntimeError(stderr)
    head = result.stdout.strip()
    if not head:
        raise RuntimeError("Empty git head SHA")
    return head


def is_ancestor(
    repo: Path,
    ancestor: str,
    descendant: str,
    timeout_seconds: int = DEFAULT_GIT_TIMEOUT_SECONDS,
) -> bool:
    """Return True when ancestor is reachable from descendant."""
    try:
        result = subprocess.run(
            ["git", "merge-base", "--is-ancestor", ancestor, descendant],
            cwd=repo,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
            check=False,
        )
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(
            f"git merge-base timed out after {timeout_seconds}s"
        ) from exc
    if result.returncode == 0:
        return True
    if result.returncode == 1:
        return False
    stderr = result.stderr.strip() or "merge-base check failed"
    raise RuntimeError(stderr)


def get_commit_list(
    repo: Path,
    base: str,
    head: str,
    timeout_seconds: int = DEFAULT_GIT_TIMEOUT_SECONDS,
) -> list[str]:
    """Collect commits in base..head ordered oldest-to-newest."""
    try:
        result = subprocess.run(
            ["git", "rev-list", "--reverse", f"{base}..{head}"],
            cwd=repo,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
            check=False,
        )
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(f"git rev-list timed out after {timeout_seconds}s") from exc
    if result.returncode != 0:
        stderr = result.stderr.strip() or "rev-list failed"
        raise RuntimeError(stderr)
    return [line for line in result.stdout.splitlines() if line.strip()]


def compute_chunks(
    commits: list[str],
    base_sha: str,
    small_max: int,
    medium_max: int,
    medium_size: int,
) -> list[tuple[str, str]]:
    """Compute adaptive pr-review ranges as (base, head) tuples."""
    if not commits:
        return []

    total = len(commits)
    if total <= small_max:
        return [(base_sha, commits[-1])]

    if total <= medium_max:
        chunks: list[tuple[str, str]] = []
        start = 0
        size = max(1, medium_size)
        while start < total:
            end = min(start + size, total)
            range_base = base_sha if start == 0 else commits[start - 1]
            range_head = commits[end - 1]
            chunks.append((range_base, range_head))
            start = end
        return chunks

    return [
        (base_sha if idx == 0 else commits[idx - 1], commit)
        for idx, commit in enumerate(commits)
    ]


def determine_chunk_strategy(
    total_commits: int, small_max: int, medium_max: int
) -> str:
    """Describe selected adaptive chunk strategy for run summaries."""
    if total_commits <= small_max:
        return "single_range"
    if total_commits <= medium_max:
        return "chunked"
    return "per_commit"


def _is_valid_report_json(path: Path) -> bool:
    """Check that a securevibes JSON output file is present and minimally valid."""
    if not path.exists() or not path.is_file():
        return False
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return False
    if not isinstance(payload, dict):
        return False
    return all(field in payload for field in REQUIRED_REPORT_FIELDS)


def classify_scan_result(exit_code: int, output_path: Path | None) -> str:
    """Classify command outcome into completed or infrastructure failure."""
    if exit_code not in {0, 1, 2}:
        return INFRA_FAILURE
    if output_path is None or not _is_valid_report_json(output_path):
        return INFRA_FAILURE
    return COMPLETED


def _stderr_tail(stderr: str, max_lines: int = 8) -> str:
    """Trim stderr to a short diagnostic snippet."""
    lines = [line for line in stderr.splitlines() if line.strip()]
    if not lines:
        return ""
    return "\n".join(lines[-max_lines:])


def run_scan(
    repo: Path,
    base: str,
    head: str,
    model: str,
    severity: str,
    debug: bool,
    output_path: Path,
    timeout_seconds: int = DEFAULT_SCAN_TIMEOUT_SECONDS,
) -> ScanCommandResult:
    """Run securevibes pr-review for an explicit commit range."""
    output_path.parent.mkdir(parents=True, exist_ok=True)
    command = [
        "securevibes",
        "pr-review",
        str(repo),
        "--range",
        f"{base}..{head}",
        "--model",
        model,
        "--severity",
        severity,
        "--format",
        "json",
        "--output",
        str(output_path),
        "--update-artifacts",
        "--clean-pr-artifacts",
    ]
    if debug:
        command.append("--debug")

    try:
        result = subprocess.run(
            command,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return ScanCommandResult(
            command=command,
            exit_code=124,
            classification=INFRA_FAILURE,
            stderr_tail=f"scan timed out after {timeout_seconds}s",
            output_path=output_path,
        )
    return ScanCommandResult(
        command=command,
        exit_code=result.returncode,
        classification=classify_scan_result(result.returncode, output_path),
        stderr_tail=_stderr_tail(result.stderr),
        output_path=output_path,
    )


def run_since_date_scan(
    repo: Path,
    since_date: str,
    model: str,
    severity: str,
    debug: bool,
    output_path: Path,
    timeout_seconds: int = DEFAULT_SCAN_TIMEOUT_SECONDS,
) -> ScanCommandResult:
    """Run securevibes pr-review using --since date fallback mode."""
    output_path.parent.mkdir(parents=True, exist_ok=True)
    command = [
        "securevibes",
        "pr-review",
        str(repo),
        "--since",
        since_date,
        "--model",
        model,
        "--severity",
        severity,
        "--format",
        "json",
        "--output",
        str(output_path),
        "--update-artifacts",
        "--clean-pr-artifacts",
    ]
    if debug:
        command.append("--debug")

    try:
        result = subprocess.run(
            command,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return ScanCommandResult(
            command=command,
            exit_code=124,
            classification=INFRA_FAILURE,
            stderr_tail=f"scan timed out after {timeout_seconds}s",
            output_path=output_path,
        )
    return ScanCommandResult(
        command=command,
        exit_code=result.returncode,
        classification=classify_scan_result(result.returncode, output_path),
        stderr_tail=_stderr_tail(result.stderr),
        output_path=output_path,
    )


def pacific_today() -> str:
    """Return current date in America/Los_Angeles timezone for --since fallback."""
    if ZoneInfo is None:
        return datetime.now().strftime("%Y-%m-%d")
    return datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d")


def handle_rewrite(policy: str) -> RewriteOutcome:
    """Decide behavior when tracked anchor is not ancestor of current head."""
    if policy == "reset_warn":
        return RewriteOutcome(
            action="reset", reason="last_seen_sha is not ancestor of new head"
        )
    if policy == "since_date":
        return RewriteOutcome(
            action="since_date",
            reason="history rewrite detected; scanning current Pacific date window",
            since_date=pacific_today(),
        )
    return RewriteOutcome(action="strict_fail", reason="history rewrite detected")


def _build_state(
    *,
    repo: Path,
    branch: str,
    remote: str,
    anchor: str,
    run_id: str,
    status: str,
    failure: dict[str, Any] | None = None,
    previous_success_utc: str | None = None,
) -> dict[str, Any]:
    """Create persisted state payload for current run."""
    now = utc_timestamp()
    payload: dict[str, Any] = {
        "repo": str(repo),
        "branch": branch,
        "remote": remote,
        "last_seen_sha": anchor,
        "last_run_utc": now,
        "last_status": status,
        "last_run_id": run_id,
    }
    if status in {"success", "rewrite_reset", "bootstrap", "no_change"}:
        payload["last_success_utc"] = now
    elif previous_success_utc:
        payload["last_success_utc"] = previous_success_utc

    if failure:
        payload["last_failure"] = failure
    return payload


@contextmanager
def file_lock(lock_path: Path) -> Iterator[bool]:
    """Acquire non-blocking lock, yielding whether lock was acquired."""
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
    acquired = False
    try:
        if fcntl is None:  # pragma: no cover
            acquired = True
            yield True
            return

        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            acquired = True
            yield True
        except BlockingIOError:
            yield False
    finally:
        if acquired and fcntl is not None:
            fcntl.flock(fd, fcntl.LOCK_UN)
        os.close(fd)


def _write_run_record(run_record_path: Path, run_record: dict[str, Any]) -> None:
    """Persist structured run record."""
    save_json(run_record_path, run_record)


def _strict_exit(status: str, strict: bool) -> int:
    """Return process exit code based on wrapper strictness."""
    if strict and status in {"partial", "failed"}:
        return 1
    return 0


def run(args: argparse.Namespace) -> int:
    """Run incremental scan wrapper end-to-end."""
    repo = Path(args.repo).resolve()
    git_timeout = max(
        1, int(getattr(args, "git_timeout_seconds", DEFAULT_GIT_TIMEOUT_SECONDS))
    )
    scan_timeout = max(
        1, int(getattr(args, "scan_timeout_seconds", DEFAULT_SCAN_TIMEOUT_SECONDS))
    )

    ensure_dependencies()
    ensure_repo(repo, git_timeout)
    ensure_baseline_artifacts(repo)

    securevibes_dir = repo / ".securevibes"
    securevibes_dir.mkdir(parents=True, exist_ok=True)

    state_path = resolve_repo_path(repo, args.state_file)
    log_file = resolve_repo_path(repo, args.log_file)
    lock_path = securevibes_dir / ".incremental_scan.lock"
    runs_dir = securevibes_dir / "incremental_runs"
    runs_dir.mkdir(parents=True, exist_ok=True)

    with file_lock(lock_path) as locked:
        if not locked:
            append_log(
                log_file, "overlap prevented: another incremental scan is in progress"
            )
            return 0

        run_id = generate_run_id()
        run_record_path = runs_dir / f"{run_id}.json"
        state = load_state(state_path)
        previous_success_utc = (
            state.get("last_success_utc") if isinstance(state, dict) else None
        )

        run_record: dict[str, Any] = {
            "run_id": run_id,
            "repo": str(repo),
            "branch": args.branch,
            "remote": args.remote,
            "started_utc": utc_timestamp(),
            "status": "failed",
            "old_anchor": (
                state.get("last_seen_sha") if isinstance(state, dict) else None
            ),
            "new_head": None,
            "strategy": None,
            "total_new_commits": 0,
            "chunks": [],
            "failure": None,
        }

        try:
            git_fetch(repo, args.remote, args.branch, args.retry_network, git_timeout)
            new_head = resolve_head(repo, args.remote, args.branch, git_timeout)
            run_record["new_head"] = new_head

            if not state or not isinstance(state.get("last_seen_sha"), str):
                new_state = _build_state(
                    repo=repo,
                    branch=args.branch,
                    remote=args.remote,
                    anchor=new_head,
                    run_id=run_id,
                    status="bootstrap",
                    previous_success_utc=previous_success_utc,
                )
                save_state(state_path, new_state)
                run_record["status"] = "bootstrap"
                run_record["finished_utc"] = utc_timestamp()
                _write_run_record(run_record_path, run_record)
                append_log(
                    log_file, f"run_id={run_id} status=bootstrap anchor={new_head}"
                )
                return 0

            current_anchor = state["last_seen_sha"]
            if current_anchor == new_head:
                new_state = _build_state(
                    repo=repo,
                    branch=args.branch,
                    remote=args.remote,
                    anchor=current_anchor,
                    run_id=run_id,
                    status="no_change",
                    previous_success_utc=previous_success_utc,
                )
                save_state(state_path, new_state)
                run_record["status"] = "no_change"
                run_record["finished_utc"] = utc_timestamp()
                _write_run_record(run_record_path, run_record)
                append_log(
                    log_file,
                    f"run_id={run_id} status=no_change anchor={current_anchor}",
                )
                return 0

            if not is_ancestor(repo, current_anchor, new_head, git_timeout):
                rewrite = handle_rewrite(args.rewrite_policy)
                if rewrite.action == "reset":
                    new_state = _build_state(
                        repo=repo,
                        branch=args.branch,
                        remote=args.remote,
                        anchor=new_head,
                        run_id=run_id,
                        status="rewrite_reset",
                        previous_success_utc=previous_success_utc,
                        failure={
                            "phase": "history_validation",
                            "reason": rewrite.reason,
                        },
                    )
                    save_state(state_path, new_state)
                    run_record["status"] = "rewrite_reset"
                    run_record["failure"] = {
                        "phase": "history_validation",
                        "reason": rewrite.reason,
                    }
                    run_record["finished_utc"] = utc_timestamp()
                    _write_run_record(run_record_path, run_record)
                    append_log(
                        log_file,
                        f"run_id={run_id} status=rewrite_reset old={current_anchor} new={new_head}",
                    )
                    return 0

                if rewrite.action == "strict_fail":
                    failure = {"phase": "history_validation", "reason": rewrite.reason}
                    new_state = _build_state(
                        repo=repo,
                        branch=args.branch,
                        remote=args.remote,
                        anchor=current_anchor,
                        run_id=run_id,
                        status="failed",
                        previous_success_utc=previous_success_utc,
                        failure=failure,
                    )
                    save_state(state_path, new_state)
                    run_record["status"] = "failed"
                    run_record["failure"] = failure
                    run_record["finished_utc"] = utc_timestamp()
                    _write_run_record(run_record_path, run_record)
                    append_log(
                        log_file,
                        f"run_id={run_id} status=failed reason={rewrite.reason}",
                    )
                    return 1

                since_date = rewrite.since_date or pacific_today()
                since_output = runs_dir / f"{run_id}-rewrite-since.json"
                since_result = run_since_date_scan(
                    repo,
                    since_date,
                    args.model,
                    args.severity,
                    args.debug,
                    since_output,
                    scan_timeout,
                )
                run_record["chunks"].append(
                    {
                        "index": 1,
                        "mode": "since_date",
                        "since_date": since_date,
                        "exit_code": since_result.exit_code,
                        "classification": since_result.classification,
                        "output_path": str(since_output),
                        "stderr_tail": since_result.stderr_tail,
                        "command": since_result.command,
                    }
                )
                if since_result.classification == COMPLETED:
                    failure = {
                        "phase": "history_validation",
                        "reason": (
                            "history rewrite detected; completed since-date scan but "
                            "anchor preserved to avoid skipping commits"
                        ),
                    }
                    new_state = _build_state(
                        repo=repo,
                        branch=args.branch,
                        remote=args.remote,
                        anchor=current_anchor,
                        run_id=run_id,
                        status="partial",
                        previous_success_utc=previous_success_utc,
                        failure=failure,
                    )
                    save_state(state_path, new_state)
                    run_record["status"] = "partial"
                    run_record["strategy"] = "rewrite_since_date"
                    run_record["final_anchor"] = current_anchor
                    run_record["failure"] = failure
                    run_record["finished_utc"] = utc_timestamp()
                    _write_run_record(run_record_path, run_record)
                    append_log(
                        log_file,
                        (
                            f"run_id={run_id} status=partial strategy=rewrite_since_date "
                            f"anchor={current_anchor}"
                        ),
                    )
                    return _strict_exit("partial", args.strict)

                failure = {
                    "phase": "chunk_run",
                    "reason": "rewrite since-date fallback failed",
                    "failed_chunk_index": 1,
                }
                new_state = _build_state(
                    repo=repo,
                    branch=args.branch,
                    remote=args.remote,
                    anchor=current_anchor,
                    run_id=run_id,
                    status="failed",
                    previous_success_utc=previous_success_utc,
                    failure=failure,
                )
                save_state(state_path, new_state)
                run_record["status"] = "failed"
                run_record["failure"] = failure
                run_record["finished_utc"] = utc_timestamp()
                _write_run_record(run_record_path, run_record)
                append_log(
                    log_file,
                    f"run_id={run_id} status=failed reason=rewrite since fallback",
                )
                return _strict_exit("failed", args.strict)

            commits = get_commit_list(repo, current_anchor, new_head, git_timeout)
            run_record["total_new_commits"] = len(commits)
            if not commits:
                new_state = _build_state(
                    repo=repo,
                    branch=args.branch,
                    remote=args.remote,
                    anchor=current_anchor,
                    run_id=run_id,
                    status="no_change",
                    previous_success_utc=previous_success_utc,
                )
                save_state(state_path, new_state)
                run_record["status"] = "no_change"
                run_record["finished_utc"] = utc_timestamp()
                _write_run_record(run_record_path, run_record)
                append_log(
                    log_file,
                    f"run_id={run_id} status=no_change anchor={current_anchor}",
                )
                return 0

            strategy = determine_chunk_strategy(
                len(commits),
                args.chunk_small_max,
                args.chunk_medium_max,
            )
            run_record["strategy"] = strategy
            chunks = compute_chunks(
                commits,
                current_anchor,
                args.chunk_small_max,
                args.chunk_medium_max,
                args.chunk_medium_size,
            )

            last_successful_anchor = current_anchor
            failed_chunk_index: int | None = None
            for idx, (base, head) in enumerate(chunks, start=1):
                chunk_output = runs_dir / f"{run_id}-chunk-{idx}.json"
                result = run_scan(
                    repo,
                    base,
                    head,
                    args.model,
                    args.severity,
                    args.debug,
                    chunk_output,
                    scan_timeout,
                )
                run_record["chunks"].append(
                    {
                        "index": idx,
                        "base": base,
                        "head": head,
                        "exit_code": result.exit_code,
                        "classification": result.classification,
                        "output_path": str(chunk_output),
                        "stderr_tail": result.stderr_tail,
                        "command": result.command,
                    }
                )
                if result.classification == COMPLETED:
                    last_successful_anchor = head
                    continue
                failed_chunk_index = idx
                break

            all_completed = failed_chunk_index is None
            if all_completed:
                status = "success"
                anchor = new_head
                failure: dict[str, Any] | None = None
            elif last_successful_anchor != current_anchor:
                status = "partial"
                anchor = last_successful_anchor
                failure = {
                    "phase": "chunk_run",
                    "reason": "chunk scan failed",
                    "failed_chunk_index": failed_chunk_index,
                }
            else:
                status = "failed"
                anchor = current_anchor
                failure = {
                    "phase": "chunk_run",
                    "reason": "first chunk scan failed",
                    "failed_chunk_index": failed_chunk_index,
                }

            new_state = _build_state(
                repo=repo,
                branch=args.branch,
                remote=args.remote,
                anchor=anchor,
                run_id=run_id,
                status=status,
                previous_success_utc=previous_success_utc,
                failure=failure,
            )
            if all_completed:
                new_state["last_success_utc"] = utc_timestamp()
            save_state(state_path, new_state)

            run_record["status"] = status
            run_record["final_anchor"] = anchor
            run_record["failure"] = failure
            run_record["finished_utc"] = utc_timestamp()
            _write_run_record(run_record_path, run_record)
            append_log(
                log_file,
                (
                    f"run_id={run_id} status={status} commits={len(commits)} strategy={strategy} "
                    f"chunks={len(chunks)} failed_chunk={failed_chunk_index or 0} "
                    f"anchor={anchor}"
                ),
            )
            return _strict_exit(status, args.strict)

        except Exception as exc:
            failure = {"phase": "orchestration", "reason": str(exc)}
            fallback_anchor = (
                state.get("last_seen_sha")
                if isinstance(state, dict)
                else run_record.get("new_head")
            )
            if isinstance(fallback_anchor, str):
                error_state = _build_state(
                    repo=repo,
                    branch=args.branch,
                    remote=args.remote,
                    anchor=fallback_anchor,
                    run_id=run_id,
                    status="failed",
                    previous_success_utc=previous_success_utc,
                    failure=failure,
                )
                save_state(state_path, error_state)
            run_record["status"] = "failed"
            run_record["failure"] = failure
            run_record["finished_utc"] = utc_timestamp()
            _write_run_record(run_record_path, run_record)
            append_log(log_file, f"run_id={run_id} status=failed reason={exc}")
            raise


def main(argv: list[str] | None = None) -> int:
    """CLI entrypoint."""
    args = parse_args(argv)
    try:
        return run(args)
    except Exception as exc:  # pragma: no cover - wrapper for user-facing CLI failures
        print(f"[incremental-scan] ERROR: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: ops/incremental_scan.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

cd "$REPO_DIR"
exec python3 "$SCRIPT_DIR/incremental_scan.py" --repo "$REPO_DIR" "$@"


path: scripts/scan.sh
content: #!/bin/bash
# SecureVibes Scanner — wrapper script with input validation
# Usage: scan.sh <project-path> [extra args...]
set -euo pipefail

PROJECT_PATH="${1:?Usage: scan.sh <project-path> [--severity high] [--format json] [--subagent threat-modeling] ...}"
shift

# Input validation: reject paths with shell metacharacters
if [[ "$PROJECT_PATH" =~ [\;\|\&\$\`\(\)\{\}\<\>\!\#] ]]; then
    echo "❌ Invalid path: contains shell metacharacters"
    exit 1
fi

# Resolve to absolute path and verify it exists
PROJECT_PATH="$(realpath -- "$PROJECT_PATH" 2>/dev/null)" || {
    echo "❌ Path does not exist: $1"
    exit 1
}

if [ ! -d "$PROJECT_PATH" ]; then
    echo "❌ Not a directory: $PROJECT_PATH"
    exit 1
fi

# Check securevibes is installed
if ! command -v securevibes &>/dev/null; then
    echo "❌ securevibes not found. Install with: pipx install securevibes"
    echo "  https://pypi.org/project/securevibes/"
    exit 1
fi

# Verify minimum version (protects against stale shims from dual installs)
MIN_VERSION="0.4.0"
INSTALLED_VERSION="$(securevibes --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "0.0.0")"
version_gte() {
    [ "$(printf '%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]
}
if ! version_gte "$INSTALLED_VERSION" "$MIN_VERSION"; then
    echo "❌ securevibes $INSTALLED_VERSION found, but >= $MIN_VERSION required."
    echo "   The binary at $(which securevibes) may be stale from a previous install."
    echo "   Fix: pipx install securevibes>=$MIN_VERSION (or pipx upgrade securevibes)"
    exit 1
fi

# Check API key (optional if authenticated via Anthropic Max/Pro OAuth)
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
    echo "ℹ️  ANTHROPIC_API_KEY not set — using Anthropic OAuth (Max/Pro subscription)."
    echo "   If auth fails, set ANTHROPIC_API_KEY or run: claude login"
fi

echo "🛡️  SecureVibes Scanner"
echo "📂 Target: ${PROJECT_PATH}"
echo "⏳ Starting scan..."
echo ""

securevibes scan "$PROJECT_PATH" "$@"


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)