From 08913ff6b9373f41781ea1a2725eee3f1eb65031 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 02:29:28 +0000 Subject: [PATCH] feat(monitor): incremental sweeps + per-skill resilience The registry monitor re-scanned every skill on every run, so a daily sweep cost the same as the first (~25 min) and one flaky network fetch (an unwrapped httpx ConnectTimeout) could abort the whole run. Make the sweep incremental and resilient: - build_snapshot() now takes the previous snapshot and only re-fetches + re-scans skills whose version/updated_at changed; unchanged skills are carried forward. The version/updated_at metadata comes free from the list endpoint, so change detection needs no per-skill fetch. First run (or --full) still scans everything. - Add a --full flag / force_rescan to re-scan every skill on a periodic cadence, catching silent same-version content swaps that metadata-based detection would miss (the ClawHavoc trojanization pattern). - Harden _scan_slug and the worker to catch any per-skill exception (timeouts included) so one bad skill can't kill the sweep. - Record scanned_count/reused_count on each snapshot for visibility. - Workflow: daily incremental + weekly (--full) cron, dispatch input to force full, timeout raised to 60m for the weekly full run. - Tests for reuse, version/updated_at change, new skill, --full, errored- record retry, and non-ClawHubError containment. Docs updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DNoTXU8k3pfSBzR7aJubqL --- .github/workflows/registry-monitor.yml | 45 ++++++++--- data/registry-snapshots/README.md | 17 ++-- docs/crawl.md | 11 ++- src/malwar/cli/commands/crawl.py | 33 ++++++-- src/malwar/monitor/models.py | 4 + src/malwar/monitor/snapshot.py | 105 +++++++++++++++++++----- tests/unit/test_monitor.py | 107 ++++++++++++++++++++++++- 7 files changed, 276 insertions(+), 46 deletions(-) diff --git a/.github/workflows/registry-monitor.yml b/.github/workflows/registry-monitor.yml index 38eaeaf..331a5ff 100644 --- a/.github/workflows/registry-monitor.yml +++ b/.github/workflows/registry-monitor.yml @@ -1,10 +1,17 @@ -# Daily ClawHub registry sweep. +# ClawHub registry monitor. # -# Runs `malwar crawl monitor` once a day: crawls the whole registry, fast-scans -# every skill (escalating flagged ones to the LLM), diffs against the previous -# snapshot, and commits the new snapshot + diff to the `registry-snapshots` -# branch so `git log -p registry-snapshots` is a permanent, auditable record of -# what changed day over day. +# Runs `malwar crawl monitor` on a schedule and commits the snapshot + diff to +# the `registry-snapshots` branch so `git log -p registry-snapshots` is a +# permanent, auditable record of what changed day over day. +# +# Two cadences: +# * Daily (06:00 UTC) — INCREMENTAL. Only skills whose version/updated_at +# changed since the last snapshot are re-fetched, re-scanned, and (if +# flagged) escalated to the LLM. The first run scans everything; every run +# after is cheap and fast. +# * Weekly (Sunday 07:00 UTC) — FULL (`--full`). Re-scans every skill to catch +# silent same-version content swaps (trojanized updates that keep the same +# version number), which incremental detection would otherwise miss. # # Why a dedicated branch and not `main`? `main` is protected (PR + required # checks), so a scheduled bot cannot push to it. The snapshot history therefore @@ -20,8 +27,14 @@ name: Registry Monitor on: schedule: - - cron: "0 6 * * *" # 06:00 UTC daily + - cron: "0 6 * * *" # daily 06:00 UTC — incremental + - cron: "0 7 * * 0" # Sunday 07:00 UTC — full re-scan workflow_dispatch: # manual "run now" from the Actions tab + inputs: + full: + description: "Force a full re-scan of every skill" + type: boolean + default: false # Never let two sweeps race on the snapshot history / the push. concurrency: @@ -35,7 +48,9 @@ jobs: monitor: name: Sweep registry and commit report runs-on: ubuntu-latest - timeout-minutes: 30 + # The weekly full re-scan is the long pole; incremental daily runs finish + # far sooner once the baseline exists. + timeout-minutes: 60 steps: - name: Checkout repository @@ -70,11 +85,23 @@ jobs: - name: Sweep registry and diff against baseline env: MALWAR_ANTHROPIC_API_KEY: ${{ secrets.MALWAR_ANTHROPIC_API_KEY }} + # Full re-scan on the weekly cron or when manually requested; + # incremental otherwise. + IS_WEEKLY: ${{ github.event.schedule == '0 7 * * 0' }} + DISPATCH_FULL: ${{ github.event.inputs.full }} run: | + FULL="" + if [ "$IS_WEEKLY" = "true" ] || [ "$DISPATCH_FULL" = "true" ]; then + FULL="--full" + echo "Mode: FULL re-scan (every skill)." + else + echo "Mode: incremental (only changed skills)." + fi malwar crawl monitor \ --snapshot-dir data/registry-snapshots \ --format json \ - --output data/registry-snapshots/latest-diff.json + --output data/registry-snapshots/latest-diff.json \ + $FULL - name: Publish snapshot to data branch run: | diff --git a/data/registry-snapshots/README.md b/data/registry-snapshots/README.md index 8301ba9..4ab07dc 100644 --- a/data/registry-snapshots/README.md +++ b/data/registry-snapshots/README.md @@ -8,14 +8,21 @@ This directory is the on-disk history for `malwar crawl monitor`. Each snapshot records, per skill: the published version, a SHA-256 of the `SKILL.md`, the scan verdict and risk score, matched rule IDs, install count, -and ClawHub moderation flags. Because the files are committed to the repo, -`git diff` (or `git log -p`) is a permanent, auditable record of exactly what -changed in the registry day over day — new skills, removed skills, trojanized -updates, and verdict regressions. +and ClawHub moderation flags. It also records `scanned_count` vs `reused_count` +for the run. Because the files are committed to the repo, `git diff` (or +`git log -p`) is a permanent, auditable record of exactly what changed in the +registry day over day — new skills, removed skills, trojanized updates, and +verdict regressions. + +The sweep is **incremental**: only skills whose version/updated_at changed since +`latest.json` are re-scanned; the rest are carried forward. The first run scans +everything. A periodic `--full` run re-scans every skill to catch silent +same-version content swaps. Run the monitor: ```bash -malwar crawl monitor # full sweep, diff against latest.json, save +malwar crawl monitor # incremental sweep, diff against latest.json, save +malwar crawl monitor --full # full re-scan (weekly cadence recommended) malwar crawl monitor --fail-on-malicious # non-zero exit on newly-flagged skills ``` diff --git a/docs/crawl.md b/docs/crawl.md index 3f4a7c0..4d2c004 100644 --- a/docs/crawl.md +++ b/docs/crawl.md @@ -97,17 +97,22 @@ malwar crawl url https://raw.githubusercontent.com/user/repo/main/SKILL.md --for ### crawl monitor -Sweep the **entire** registry, fast-scan every skill (rule engine + threat intel, escalating flagged ones to the LLM), and diff the result against the previous snapshot to surface what changed -- newly published skills, removed skills, trojanized updates (content changed under the same version), and verdict regressions, with a headline list of skills that newly turned malicious. Snapshots persist under `data/registry-snapshots/` (`latest.json` is the diff baseline, plus a dated archive per run), so committing that directory makes `git diff` a permanent day-over-day record. Designed to run on a schedule. +Sweep the registry, fast-scan skills (rule engine + threat intel, escalating flagged ones to the LLM), and diff the result against the previous snapshot to surface what changed -- newly published skills, removed skills, trojanized updates (content changed under the same version), and verdict regressions, with a headline list of skills that newly turned malicious. Snapshots persist under `data/registry-snapshots/` (`latest.json` is the diff baseline, plus a dated archive per run), so committing that directory makes `git diff` a permanent day-over-day record. Designed to run on a schedule. + +**Incremental by default.** The monitor only re-fetches and re-scans skills whose `version`/`updated_at` changed since the last snapshot; unchanged skills are carried forward untouched. The **first run scans everything**; every run after only pays for what actually changed — so a daily sweep is fast and cheap. Because incremental detection trusts the registry's version metadata, run a periodic **`--full`** sweep (e.g. weekly) to also catch *silent same-version content swaps* — trojanized updates that keep the same version number. Each snapshot records `scanned_count` vs `reused_count` so you can see how much a run actually did. ```bash -malwar crawl monitor # full sweep -> snapshot -> diff +malwar crawl monitor # incremental sweep -> snapshot -> diff +malwar crawl monitor --full # re-scan every skill (catches same-version tampering) malwar crawl monitor --digest # also print a shareable digest + draft post malwar crawl monitor --publish # post the digest to X when skills newly turn malicious malwar crawl monitor --fail-on-malicious # exit non-zero when newly-flagged skills are found (CI/cron) malwar crawl monitor --max 100 --no-escalate # quick partial run, rules only ``` -**Options:** `--snapshot-dir`, `--max`, `--no-escalate`, `--concurrency`, `--format`/`-f` (`console`|`json`), `--output`/`-o`, `--no-save`, `--digest`, `--publish`, `--fail-on-malicious`. Publishing to X requires the `MALWAR_X_*` credentials (see [Configuration](deployment/configuration.md#x-twitter-publishing)). +**Options:** `--snapshot-dir`, `--full`, `--max`, `--no-escalate`, `--concurrency`, `--format`/`-f` (`console`|`json`), `--output`/`-o`, `--no-save`, `--digest`, `--publish`, `--fail-on-malicious`. Publishing to X requires the `MALWAR_X_*` credentials (see [Configuration](deployment/configuration.md#x-twitter-publishing)). + +The bundled GitHub Actions workflow (`.github/workflows/registry-monitor.yml`) runs this on two cadences: **daily incremental** and a **weekly `--full`** re-scan, committing each snapshot to the `registry-snapshots` branch. --- diff --git a/src/malwar/cli/commands/crawl.py b/src/malwar/cli/commands/crawl.py index a725889..508ea8c 100644 --- a/src/malwar/cli/commands/crawl.py +++ b/src/malwar/cli/commands/crawl.py @@ -515,13 +515,24 @@ def crawl_monitor( help="Exit non-zero when newly-flagged skills are found", ), ] = False, + full: Annotated[ + bool, + typer.Option( + "--full", + help="Re-scan every skill instead of only changed ones " + "(defends against silent same-version content swaps)", + ), + ] = False, ) -> None: - """Scan every skill in the registry and diff against the last snapshot. + """Scan the registry incrementally and diff against the last snapshot. - Designed to run on a schedule (e.g. daily): it crawls the whole registry, - fast-scans each skill (escalating flagged ones to the LLM), diffs the - result against the previous snapshot, and reports what changed — with a - headline list of skills that newly turned malicious. + Designed to run on a schedule (e.g. daily): it crawls the whole registry + but only re-fetches and re-scans skills whose version changed since the last + snapshot (escalating flagged ones to the LLM), diffs the result, and reports + what changed — with a headline list of skills that newly turned malicious. + + The first run scans everything. Pass ``--full`` on a periodic cadence (e.g. + weekly) to re-scan every skill and catch silent same-version tampering. """ exit_code = asyncio.run( _async_monitor( @@ -535,6 +546,7 @@ def crawl_monitor( digest=digest, publish=publish, fail_on_malicious=fail_on_malicious, + full=full, ) ) raise typer.Exit(exit_code) @@ -552,6 +564,7 @@ async def _async_monitor( digest: bool, publish: bool, fail_on_malicious: bool, + full: bool = False, ) -> int: from rich.progress import BarColumn, Progress, TextColumn @@ -560,8 +573,9 @@ async def _async_monitor( store = SnapshotStore(snapshot_dir) previous = store.load_latest() + mode = "full re-scan" if full else ("incremental" if previous else "first run") scope = f" (max {max_skills})" if max_skills else "" - console.print(f"Crawling ClawHub registry{scope}...", style="dim") + console.print(f"Crawling ClawHub registry{scope} [{mode}]...", style="dim") with Progress( TextColumn("[progress.description]{task.description}"), @@ -578,6 +592,8 @@ def _on_progress(done: int, total: int, slug: str) -> None: ) snapshot = await build_snapshot( + previous=previous, + force_rescan=full, max_skills=max_skills, escalate=escalate, concurrency=concurrency, @@ -588,6 +604,11 @@ def _on_progress(done: int, total: int, slug: str) -> None: console.print("[yellow]No skills found in the registry.[/yellow]") return 0 + console.print( + f"[dim]Scanned {snapshot.scanned_count} changed/new, " + f"reused {snapshot.reused_count} unchanged.[/dim]" + ) + diff = diff_snapshots(previous, snapshot) if save: diff --git a/src/malwar/monitor/models.py b/src/malwar/monitor/models.py index 2271080..ebb448b 100644 --- a/src/malwar/monitor/models.py +++ b/src/malwar/monitor/models.py @@ -64,6 +64,10 @@ class RegistrySnapshot(BaseModel): skills: dict[str, SkillRecord] = Field(default_factory=dict) # Slugs the crawl knew about but failed to fetch/scan, for transparency. errors: dict[str, str] = Field(default_factory=dict) + # How this snapshot was built: skills actually fetched + scanned this run + # vs. carried forward unchanged from the previous snapshot (incremental). + scanned_count: int = 0 + reused_count: int = 0 @property def skill_count(self) -> int: diff --git a/src/malwar/monitor/snapshot.py b/src/malwar/monitor/snapshot.py index 3637c52..8cdd851 100644 --- a/src/malwar/monitor/snapshot.py +++ b/src/malwar/monitor/snapshot.py @@ -5,6 +5,16 @@ escalated to the (paid, slower) LLM analyzer when the fast pass already flags it — so cost scales with the number of *suspicious* skills, not the size of the registry. + +It is also **incremental**: given the previous snapshot, a skill is only +re-fetched and re-scanned when the registry reports a new ``version`` or +``updated_at`` for it; unchanged skills are carried forward untouched. The +first run (or an explicit ``force_rescan``) scans everything; day-to-day runs +only pay for what actually changed. + +Incremental detection trusts the registry's version/updated_at metadata, so a +*silent* same-version content swap would be missed — run a periodic full sweep +(``force_rescan=True``) to defend against that. """ from __future__ import annotations @@ -29,20 +39,26 @@ ProgressCallback = Callable[[int, int, str], None] +# A lightweight (slug, version, updated_at) reference gathered from the cheap +# list/search endpoints — enough to decide whether a skill needs re-scanning +# without fetching its file. +SkillRef = tuple[str, str | None, int | None] + -async def _enumerate_slugs( +async def _enumerate_skills( client: ClawHubClient, *, max_skills: int | None, page_size: int, -) -> list[str]: - """Return the full set of skill slugs known to the registry. +) -> list[SkillRef]: + """Return ``(slug, version, updated_at)`` for every skill in the registry. Pages through the list endpoint; if that yields nothing (the endpoint is known to return empty at times), falls back to fanning search queries out - across seed terms and de-duplicating. + across seed terms and de-duplicating. The version/updated_at metadata comes + free with the listing, so change detection needs no per-skill fetch. """ - slugs: list[str] = [] + refs: list[SkillRef] = [] seen: set[str] = set() cursor: str | None = None @@ -55,14 +71,15 @@ async def _enumerate_slugs( for item in items: if item.slug not in seen: seen.add(item.slug) - slugs.append(item.slug) - if max_skills is not None and len(slugs) >= max_skills: - return slugs[:max_skills] + version = item.latest_version.version if item.latest_version else None + refs.append((item.slug, version, item.updated_at)) + if max_skills is not None and len(refs) >= max_skills: + return refs[:max_skills] if not cursor: break - if slugs: - return slugs + if refs: + return refs # Fallback: broad search fan-out. for term in _SEED_TERMS: @@ -74,18 +91,30 @@ async def _enumerate_slugs( for r in results: if r.slug not in seen: seen.add(r.slug) - slugs.append(r.slug) - if max_skills is not None and len(slugs) >= max_skills: - return slugs[:max_skills] + refs.append((r.slug, r.version, r.updated_at)) + if max_skills is not None and len(refs) >= max_skills: + return refs[:max_skills] - return slugs + return refs + + +def _is_unchanged(prev: SkillRecord | None, version: str | None, updated_at: int | None) -> bool: + """True when a prior, cleanly-scanned record still matches the registry. + + A previously errored/unknown record is treated as changed so it gets + retried; otherwise we skip re-scanning when both version and updated_at + match what the registry now reports. + """ + if prev is None or prev.error is not None or prev.verdict == "UNKNOWN": + return False + return prev.version == version and prev.updated_at == updated_at async def _scan_slug(client: ClawHubClient, slug: str, *, escalate: bool) -> SkillRecord: """Fetch and scan a single skill, escalating to the LLM only if flagged.""" try: detail = await client.get_skill(slug) - except ClawHubError as exc: + except Exception as exc: # ClawHubError, httpx timeouts, etc. return SkillRecord(slug=slug, verdict="UNKNOWN", error=f"detail: {exc}") record = SkillRecord( @@ -101,7 +130,7 @@ async def _scan_slug(client: ClawHubClient, slug: str, *, escalate: bool) -> Ski try: content = await client.get_skill_file(slug) - except ClawHubError as exc: + except Exception as exc: # ClawHubError, httpx timeouts, etc. record.error = f"file: {exc}" return record @@ -126,18 +155,27 @@ async def _scan_slug(client: ClawHubClient, slug: str, *, escalate: bool) -> Ski async def build_snapshot( client: ClawHubClient | None = None, *, + previous: RegistrySnapshot | None = None, + force_rescan: bool = False, max_skills: int | None = None, escalate: bool = True, concurrency: int = 8, page_size: int = 50, on_progress: ProgressCallback | None = None, ) -> RegistrySnapshot: - """Crawl and scan the entire registry into a :class:`RegistrySnapshot`. + """Crawl and scan the registry into a :class:`RegistrySnapshot`. Parameters ---------- client: A :class:`ClawHubClient`; a default one is created if omitted. + previous: + The last snapshot. When supplied (and ``force_rescan`` is False), skills + whose version/updated_at are unchanged are carried forward without being + re-fetched or re-scanned — so a daily run only pays for what changed. + force_rescan: + Ignore ``previous`` and re-scan every skill (a full sweep). Use this on + a periodic cadence to catch silent same-version content swaps. max_skills: Cap the number of skills scanned (for testing / partial runs). escalate: @@ -148,21 +186,44 @@ async def build_snapshot( Optional callback ``(done, total, slug)`` for progress reporting. """ client = client or ClawHubClient() - slugs = await _enumerate_slugs(client, max_skills=max_skills, page_size=page_size) + refs = await _enumerate_skills(client, max_skills=max_skills, page_size=page_size) snapshot = RegistrySnapshot(registry=client.base_url) - total = len(slugs) + total = len(refs) if total == 0: return snapshot + prev_skills = previous.skills if (previous is not None and not force_rescan) else {} + + # Split the registry into "carry forward unchanged" and "must re-scan". + to_scan: list[str] = [] + for slug, version, updated_at in refs: + prev = prev_skills.get(slug) + if _is_unchanged(prev, version, updated_at): + snapshot.skills[slug] = prev.model_copy(deep=True) # type: ignore[union-attr] + else: + to_scan.append(slug) + + snapshot.reused_count = len(snapshot.skills) + snapshot.scanned_count = len(to_scan) + logger.info( + "incremental sweep: %d skills total, %d changed/new to scan, %d reused unchanged", + total, + snapshot.scanned_count, + snapshot.reused_count, + ) + semaphore = asyncio.Semaphore(max(1, concurrency)) - done = 0 + done = len(snapshot.skills) lock = asyncio.Lock() async def _worker(slug: str) -> None: nonlocal done async with semaphore: - record = await _scan_slug(client, slug, escalate=escalate) + try: + record = await _scan_slug(client, slug, escalate=escalate) + except Exception as exc: # last-resort guard; never abort the sweep + record = SkillRecord(slug=slug, verdict="UNKNOWN", error=f"worker: {exc}") async with lock: snapshot.skills[slug] = record if record.error: @@ -171,7 +232,7 @@ async def _worker(slug: str) -> None: if on_progress is not None: on_progress(done, total, slug) - await asyncio.gather(*(_worker(slug) for slug in slugs)) + await asyncio.gather(*(_worker(slug) for slug in to_scan)) return snapshot diff --git a/tests/unit/test_monitor.py b/tests/unit/test_monitor.py index 8a5f2d6..574c400 100644 --- a/tests/unit/test_monitor.py +++ b/tests/unit/test_monitor.py @@ -40,10 +40,19 @@ class FakeClawHubClient: base_url = "https://clawhub.test" - def __init__(self, skills: dict[str, str], *, versions: dict[str, str] | None = None): + def __init__( + self, + skills: dict[str, str], + *, + versions: dict[str, str] | None = None, + updated_ats: dict[str, int] | None = None, + ): # slug -> SKILL.md content self._skills = skills self._versions = versions or dict.fromkeys(skills, "1.0.0") + self._updated_ats = updated_ats or dict.fromkeys(skills, 1000) + # Slugs whose file was actually fetched — lets tests prove reuse. + self.file_fetches: list[str] = [] async def list_skills(self, limit: int = 20, cursor: str | None = None): items = [ @@ -51,6 +60,7 @@ async def list_skills(self, limit: int = 20, cursor: str | None = None): slug=slug, displayName=slug.replace("-", " ").title(), latestVersion=VersionInfo(version=self._versions[slug]), + updatedAt=self._updated_ats.get(slug), ) for slug in self._skills ] @@ -67,6 +77,7 @@ async def get_skill(self, slug: str) -> SkillDetail: displayName=slug.replace("-", " ").title(), stats=SkillStats(installsAllTime=1234), latestVersion=VersionInfo(version=self._versions[slug]), + updatedAt=self._updated_ats.get(slug), owner=OwnerInfo(username="publisher"), moderation=ModerationInfo(), ) @@ -74,6 +85,7 @@ async def get_skill(self, slug: str) -> SkillDetail: async def get_skill_file(self, slug: str, path: str = "SKILL.md", version=None) -> str: if slug not in self._skills: raise ClawHubError(f"not found: {slug}", status_code=404) + self.file_fetches.append(slug) return self._skills[slug] @@ -115,6 +127,99 @@ async def _boom(slug, path="SKILL.md", version=None): assert snap.skills["good"].error is not None assert "good" in snap.errors + async def test_non_clawhub_exception_does_not_abort_sweep(self): + # A raw (non-ClawHubError) exception on one skill must be contained. + client = FakeClawHubClient({"good": BENIGN_BODY, "bad": BENIGN_BODY}) + + async def _boom(slug, path="SKILL.md", version=None): + if slug == "bad": + raise TimeoutError("connect timeout") + return BENIGN_BODY + + client.get_skill_file = _boom # type: ignore[assignment] + snap = await build_snapshot(client, escalate=False) + assert snap.skill_count == 2 + assert snap.skills["good"].verdict == "CLEAN" + assert snap.skills["bad"].error is not None + + +class TestIncrementalSweep: + async def test_unchanged_skills_are_not_refetched(self): + client1 = FakeClawHubClient({"a": BENIGN_BODY, "b": BENIGN_BODY}) + day1 = await build_snapshot(client1, escalate=False) + assert day1.scanned_count == 2 + assert day1.reused_count == 0 + + client2 = FakeClawHubClient({"a": BENIGN_BODY, "b": BENIGN_BODY}) + day2 = await build_snapshot(client2, previous=day1, escalate=False) + # Nothing changed → nothing re-fetched, everything carried forward. + assert client2.file_fetches == [] + assert day2.scanned_count == 0 + assert day2.reused_count == 2 + assert day2.skill_count == 2 + + async def test_version_bump_triggers_rescan(self): + client1 = FakeClawHubClient({"a": BENIGN_BODY, "b": BENIGN_BODY}) + day1 = await build_snapshot(client1, escalate=False) + + client2 = FakeClawHubClient( + {"a": BENIGN_BODY, "b": BENIGN_BODY}, + versions={"a": "2.0.0", "b": "1.0.0"}, + ) + day2 = await build_snapshot(client2, previous=day1, escalate=False) + # Only the version-bumped skill is re-fetched. + assert client2.file_fetches == ["a"] + assert day2.scanned_count == 1 + assert day2.reused_count == 1 + + async def test_updated_at_change_triggers_rescan(self): + client1 = FakeClawHubClient({"a": BENIGN_BODY}, updated_ats={"a": 1000}) + day1 = await build_snapshot(client1, escalate=False) + + client2 = FakeClawHubClient({"a": BENIGN_BODY}, updated_ats={"a": 2000}) + day2 = await build_snapshot(client2, previous=day1, escalate=False) + assert client2.file_fetches == ["a"] + assert day2.scanned_count == 1 + + async def test_new_skill_is_scanned_others_reused(self): + client1 = FakeClawHubClient({"a": BENIGN_BODY}) + day1 = await build_snapshot(client1, escalate=False) + + client2 = FakeClawHubClient({"a": BENIGN_BODY, "new": MALICIOUS_BODY}) + day2 = await build_snapshot(client2, previous=day1, escalate=False) + assert client2.file_fetches == ["new"] + assert day2.scanned_count == 1 + assert day2.reused_count == 1 + assert day2.skills["new"].is_flagged + + async def test_force_rescan_scans_everything(self): + client1 = FakeClawHubClient({"a": BENIGN_BODY, "b": BENIGN_BODY}) + day1 = await build_snapshot(client1, escalate=False) + + client2 = FakeClawHubClient({"a": BENIGN_BODY, "b": BENIGN_BODY}) + day2 = await build_snapshot(client2, previous=day1, force_rescan=True, escalate=False) + assert sorted(client2.file_fetches) == ["a", "b"] + assert day2.scanned_count == 2 + assert day2.reused_count == 0 + + async def test_previously_errored_skill_is_retried(self): + # Day 1: the file fetch fails, so the record carries an error. + client1 = FakeClawHubClient({"a": BENIGN_BODY}) + + async def _boom(slug, path="SKILL.md", version=None): + raise ClawHubError("boom") + + client1.get_skill_file = _boom # type: ignore[assignment] + day1 = await build_snapshot(client1, escalate=False) + assert day1.skills["a"].error is not None + + # Day 2: same version, but the errored record must be retried (not reused). + client2 = FakeClawHubClient({"a": BENIGN_BODY}) + day2 = await build_snapshot(client2, previous=day1, escalate=False) + assert client2.file_fetches == ["a"] + assert day2.skills["a"].error is None + assert day2.skills["a"].verdict == "CLEAN" + # --------------------------------------------------------------------------- # diff_snapshots