skillguard-core 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
skillguard_core/cli.py ADDED
@@ -0,0 +1,170 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from collections import Counter
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ import tqdm
10
+ import typer
11
+
12
+ from skillguard_core.config import get_settings
13
+ from skillguard_core.engines.cisco import CiscoScannerEngine
14
+ from skillguard_core.engines.skillspector import SkillspectorEngine
15
+ from skillguard_core.ingest.fetcher import discover_skills
16
+ from skillguard_core.pipeline.scan import ScanReport, ScanService
17
+ from skillguard_core.sarif import to_sarif, to_sarif_batch
18
+
19
+ app = typer.Typer(no_args_is_help=True)
20
+
21
+ EXIT_CODES = {"safe": 0, "caution": 1, "dangerous": 2, "inconclusive": 3}
22
+
23
+
24
+ @app.command(hidden=True)
25
+ def _version():
26
+ """Show version."""
27
+ from skillguard_core import __version__
28
+
29
+ typer.echo(f"skillguard-core {__version__}")
30
+
31
+
32
+ def _engines() -> list:
33
+ settings = get_settings()
34
+ return [
35
+ SkillspectorEngine(binary=settings.skillspector_bin, timeout_s=settings.scan_timeout_s),
36
+ CiscoScannerEngine(binary=settings.cisco_bin, policy=settings.cisco_policy, timeout_s=settings.scan_timeout_s),
37
+ ]
38
+
39
+
40
+ def _is_skills_dir(target: str) -> bool:
41
+ try:
42
+ path = Path(target).expanduser().resolve()
43
+ return path.is_dir() and not (path / "SKILL.md").exists() and any(
44
+ d.is_dir() and (d / "SKILL.md").exists() for d in path.iterdir()
45
+ )
46
+ except OSError:
47
+ return False
48
+
49
+
50
+ def _format_report(report, json_output: bool, sarif: bool):
51
+ if sarif:
52
+ return json.dumps(to_sarif(report), indent=2)
53
+ if json_output:
54
+ return json.dumps(asdict(report), indent=2)
55
+ lines = [f"{report.skill_name}: {report.verdict.upper()} (score {report.fused_score})"]
56
+ if report.llm_reviewed:
57
+ lines.append(f" [llm] verdict '{report.llm_verdict}', confidence {report.llm_confidence:.0%}: {report.llm_rationale}")
58
+ elif report.llm_skipped_reason:
59
+ lines.append(f" [info] --use-llm skipped: {report.llm_skipped_reason}")
60
+ for f in report.findings:
61
+ lines.append(f" [{f.severity}] {f.engine}/{f.rule_id}: {f.title} ({f.file_path})")
62
+ return "\n".join(lines)
63
+
64
+
65
+ @app.command()
66
+ def scan(
67
+ target: str = typer.Argument(...),
68
+ use_llm: bool = False,
69
+ json_output: bool = typer.Option(False, "--json"),
70
+ sarif: bool = typer.Option(False, "--sarif"),
71
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Stream results as each skill is scanned"),
72
+ workers: int = typer.Option(os.cpu_count() or 1, "--workers", "-w", help="Parallel scan workers (default: CPU count)"),
73
+ ) -> None:
74
+ """Scan a skill directory, git URL, or zip URL."""
75
+ if json_output and sarif:
76
+ typer.echo("error: --json and --sarif are mutually exclusive", err=True)
77
+ raise typer.Exit(3)
78
+ service = ScanService(engines=_engines(), reviewer=_try_get_reviewer(use_llm))
79
+
80
+ if _is_skills_dir(target):
81
+ if json_output or sarif:
82
+ try:
83
+ reports = service.scan_directory(target, use_llm=use_llm, max_workers=workers)
84
+ except Exception as exc: # noqa: BLE001
85
+ typer.echo(f"error: {exc}", err=True)
86
+ raise typer.Exit(3)
87
+ if json_output:
88
+ typer.echo(json.dumps([asdict(r) for r in reports], indent=2))
89
+ else:
90
+ typer.echo(json.dumps(to_sarif_batch(reports), indent=2))
91
+ raise typer.Exit(max(EXIT_CODES.get(r.verdict, 0) for r in reports))
92
+
93
+ skill_dirs = discover_skills(Path(target))
94
+ results: dict[int, ScanReport] = {}
95
+ with tqdm.tqdm(total=len(skill_dirs), unit="skill", file=sys.stderr) as pbar, \
96
+ ThreadPoolExecutor(max_workers=workers) as pool:
97
+ futures = {pool.submit(service.scan_target, str(d.path), use_llm=use_llm): i for i, d in enumerate(skill_dirs)}
98
+ for future in as_completed(futures):
99
+ idx = futures[future]
100
+ report = _safe_scan(future, skill_dirs[idx])
101
+ results[idx] = report
102
+ tag = _verdict_tag(report.verdict)
103
+ pbar.set_description(f"{tag} {report.skill_name}")
104
+ if verbose:
105
+ tqdm.tqdm.write(_format_report(report, False, False), file=sys.stderr)
106
+ tqdm.tqdm.write("", file=sys.stderr)
107
+ pbar.update(1)
108
+ reports = [results[i] for i in range(len(skill_dirs))]
109
+ _print_summary(reports)
110
+ raise typer.Exit(max(EXIT_CODES.get(r.verdict, 0) for r in reports))
111
+
112
+ try:
113
+ report = service.scan_target(target, use_llm=use_llm)
114
+ except Exception as exc: # noqa: BLE001
115
+ typer.echo(f"error: {exc}", err=True)
116
+ raise typer.Exit(3)
117
+ typer.echo(_format_report(report, json_output, sarif))
118
+ raise typer.Exit(EXIT_CODES.get(report.verdict, 3))
119
+
120
+
121
+ def _safe_scan(future, d) -> ScanReport:
122
+ try:
123
+ return future.result()
124
+ except Exception as exc: # noqa: BLE001
125
+ return ScanReport(
126
+ skill_name=d.name, origin="local", source_url="", version_ref="",
127
+ content_hash="", engines=[], fused_score=0, severity="unknown",
128
+ verdict="inconclusive", llm_skipped_reason=str(exc),
129
+ )
130
+
131
+
132
+ def _verdict_tag(verdict: str) -> str:
133
+ tags = {"dangerous": "⚠ ", "caution": "⚡", "safe": "✓ ", "inconclusive": "? "}
134
+ return tags.get(verdict, " ")
135
+
136
+
137
+ def _get_reviewer():
138
+ from skillguard_core.semantic.reviewer import build_reviewer
139
+
140
+ return build_reviewer()
141
+
142
+
143
+ def _try_get_reviewer(use_llm: bool):
144
+ if not use_llm:
145
+ return None
146
+ try:
147
+ return _get_reviewer()
148
+ except ImportError:
149
+ typer.echo(
150
+ "error: --use-llm requires the 'ai' extra. Install with: pipx install skillguard-core[ai]",
151
+ err=True,
152
+ )
153
+ raise typer.Exit(3)
154
+
155
+
156
+ def _print_summary(reports: list[ScanReport]):
157
+ verdict_colors = {"dangerous": typer.colors.RED, "caution": typer.colors.YELLOW, "safe": typer.colors.GREEN}
158
+ verdicts = Counter(r.verdict for r in reports)
159
+ total = len(reports)
160
+ typer.echo()
161
+ for verdict in ("dangerous", "caution", "safe"):
162
+ count = verdicts.get(verdict, 0)
163
+ if count:
164
+ color = verdict_colors.get(verdict)
165
+ typer.secho(f" {verdict.upper():<12} {count:>3}/{total}", fg=color)
166
+ typer.echo(f" {'total':<12} {total:>3}")
167
+ typer.echo()
168
+ for r in sorted(reports, key=lambda r: (EXIT_CODES.get(r.verdict, 99), r.skill_name)):
169
+ tag = _verdict_tag(r.verdict)
170
+ typer.secho(f" {tag} {r.skill_name} (score {r.fused_score})", fg=verdict_colors.get(r.verdict))
@@ -0,0 +1,28 @@
1
+ from functools import lru_cache
2
+
3
+ from dotenv import load_dotenv
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+ load_dotenv()
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ model_config = SettingsConfigDict(env_prefix="SKILLGUARD_", env_file=".env", extra="ignore")
11
+
12
+ ingest_max_bytes: int = 100 * 1024 * 1024
13
+ ingest_max_zip_members: int = 10_000
14
+ scan_timeout_s: int = 300
15
+ skillspector_bin: str = "skillspector"
16
+ cisco_bin: str = "skill-scanner"
17
+ cisco_policy: str = "balanced"
18
+ semantic_model: str = "claude-sonnet-4-5"
19
+ anthropic_api_key: str = ""
20
+ llm_api_key: str = ""
21
+ llm_base_url: str = ""
22
+ danger_min: int = 70
23
+ caution_min: int = 30
24
+
25
+
26
+ @lru_cache
27
+ def get_settings() -> Settings:
28
+ return Settings()
File without changes
@@ -0,0 +1,54 @@
1
+ import hashlib
2
+ import subprocess
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+
6
+ SEVERITIES = ("low", "medium", "high", "critical")
7
+ SEVERITY_ALIASES = {
8
+ "info": "low",
9
+ "minor": "low",
10
+ "warning": "medium",
11
+ "severe": "high",
12
+ "crit": "critical",
13
+ }
14
+
15
+ Runner = Callable[..., subprocess.CompletedProcess]
16
+
17
+
18
+ def normalize_severity(value: str | None) -> str:
19
+ v = (value or "").strip().lower()
20
+ v = SEVERITY_ALIASES.get(v, v)
21
+ return v if v in SEVERITIES else "medium"
22
+
23
+
24
+ def truncate_evidence(text: str | None, limit: int = 400) -> str:
25
+ if not text:
26
+ return ""
27
+ text = text.strip()
28
+ return text[:limit] + ("…" if len(text) > limit else "")
29
+
30
+
31
+ def make_fingerprint(engine: str, rule_id: str, file_path: str, title: str) -> str:
32
+ raw = f"{engine}|{rule_id}|{file_path}|{title}"
33
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class EngineFinding:
38
+ engine: str
39
+ rule_id: str
40
+ category: str
41
+ title: str
42
+ severity: str
43
+ file_path: str = ""
44
+ evidence: str = ""
45
+ fingerprint: str = ""
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class EngineResult:
50
+ engine: str
51
+ score: int | None = None
52
+ findings: list[EngineFinding] = field(default_factory=list)
53
+ error: str | None = None
54
+ duration_ms: int = 0
@@ -0,0 +1,68 @@
1
+ import json
2
+ import subprocess
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from skillguard_core.engines.base import (
7
+ EngineFinding,
8
+ EngineResult,
9
+ Runner,
10
+ make_fingerprint,
11
+ normalize_severity,
12
+ truncate_evidence,
13
+ )
14
+
15
+
16
+ class CiscoScannerEngine:
17
+ name = "cisco"
18
+
19
+ def __init__(
20
+ self,
21
+ binary: str = "skill-scanner",
22
+ policy: str = "balanced",
23
+ timeout_s: int = 300,
24
+ runner: Runner = subprocess.run,
25
+ ):
26
+ self.binary = binary
27
+ self.policy = policy
28
+ self.timeout_s = timeout_s
29
+ self.runner = runner
30
+
31
+ def scan(self, path: Path) -> EngineResult:
32
+ started = time.monotonic()
33
+ cmd = [self.binary, "scan", str(path), "--policy", self.policy, "--format", "json"]
34
+ try:
35
+ proc = self.runner(cmd, capture_output=True, text=True, timeout=self.timeout_s)
36
+ except subprocess.TimeoutExpired:
37
+ return EngineResult(engine=self.name, error=f"timeout after {self.timeout_s}s", duration_ms=self._ms(started))
38
+ except OSError as exc:
39
+ return EngineResult(engine=self.name, error=f"runner failed: {exc}", duration_ms=self._ms(started))
40
+ stdout = (proc.stdout or "").strip()
41
+ if not stdout:
42
+ tail = (proc.stderr or "").strip()[-2000:]
43
+ return EngineResult(engine=self.name, error=f"no json output: {tail}", duration_ms=self._ms(started))
44
+ try:
45
+ data = json.loads(stdout)
46
+ except json.JSONDecodeError as exc:
47
+ return EngineResult(engine=self.name, error=f"invalid json: {exc}", duration_ms=self._ms(started))
48
+ return self._parse(data, started)
49
+
50
+ def _parse(self, data: dict, started: float) -> EngineResult:
51
+ findings: list[EngineFinding] = []
52
+ for f in data.get("findings") or []:
53
+ finding = EngineFinding(
54
+ engine=self.name,
55
+ rule_id=str(f.get("id") or f.get("rule_id") or ""),
56
+ category=str(f.get("category") or ""),
57
+ title=str(f.get("title") or f.get("description") or ""),
58
+ severity=normalize_severity(f.get("severity")),
59
+ file_path=str(f.get("path") or f.get("file") or ""),
60
+ evidence=truncate_evidence(f.get("snippet") or f.get("evidence")),
61
+ )
62
+ finding.fingerprint = make_fingerprint(self.name, finding.rule_id, finding.file_path, finding.title)
63
+ findings.append(finding)
64
+ return EngineResult(engine=self.name, score=None, findings=findings, duration_ms=self._ms(started))
65
+
66
+ @staticmethod
67
+ def _ms(started: float) -> int:
68
+ return int((time.monotonic() - started) * 1000)
@@ -0,0 +1,49 @@
1
+ from dataclasses import dataclass
2
+
3
+ from skillguard_core.engines.base import EngineResult
4
+
5
+ SEVERITY_SCORES = {"critical": 95, "high": 80, "medium": 50, "low": 20}
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class FusedVerdict:
10
+ score: int
11
+ severity: str
12
+ verdict: str
13
+ engine_errors: dict[str, str]
14
+
15
+
16
+ def engine_score(result: EngineResult) -> int:
17
+ score = 0
18
+ if result.score is not None:
19
+ score = max(0, min(100, result.score))
20
+ if result.findings:
21
+ score = max(score, *(SEVERITY_SCORES.get(f.severity, 0) for f in result.findings))
22
+ return score
23
+
24
+
25
+ def score_to_severity(score: int) -> str:
26
+ if score >= 90:
27
+ return "critical"
28
+ if score >= 70:
29
+ return "high"
30
+ if score >= 30:
31
+ return "medium"
32
+ if score > 0:
33
+ return "low"
34
+ return "none"
35
+
36
+
37
+ def fuse(results: list[EngineResult], danger_min: int = 70, caution_min: int = 30) -> FusedVerdict:
38
+ errors = {r.engine: r.error for r in results if r.error}
39
+ valid = [r for r in results if r.error is None]
40
+ if not valid:
41
+ return FusedVerdict(score=0, severity="unknown", verdict="inconclusive", engine_errors=errors)
42
+ score = max(engine_score(r) for r in valid)
43
+ if score >= danger_min:
44
+ verdict = "dangerous"
45
+ elif score >= caution_min:
46
+ verdict = "caution"
47
+ else:
48
+ verdict = "safe"
49
+ return FusedVerdict(score=score, severity=score_to_severity(score), verdict=verdict, engine_errors=errors)
@@ -0,0 +1,75 @@
1
+ import json
2
+ import subprocess
3
+ import tempfile
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from skillguard_core.engines.base import (
8
+ EngineFinding,
9
+ EngineResult,
10
+ Runner,
11
+ make_fingerprint,
12
+ normalize_severity,
13
+ truncate_evidence,
14
+ )
15
+
16
+
17
+ class SkillspectorEngine:
18
+ name = "skillspector"
19
+
20
+ def __init__(self, binary: str = "skillspector", timeout_s: int = 300, runner: Runner = subprocess.run):
21
+ self.binary = binary
22
+ self.timeout_s = timeout_s
23
+ self.runner = runner
24
+
25
+ def scan(self, path: Path) -> EngineResult:
26
+ started = time.monotonic()
27
+ with tempfile.TemporaryDirectory() as td:
28
+ report_path = Path(td) / "report.json"
29
+ cmd = [
30
+ self.binary, "scan", str(path),
31
+ "--no-llm", "--format", "json", "--output", str(report_path),
32
+ ]
33
+ try:
34
+ proc = self.runner(cmd, capture_output=True, text=True, timeout=self.timeout_s)
35
+ except subprocess.TimeoutExpired:
36
+ return EngineResult(engine=self.name, error=f"timeout after {self.timeout_s}s", duration_ms=self._ms(started))
37
+ except OSError as exc:
38
+ return EngineResult(engine=self.name, error=f"runner failed: {exc}", duration_ms=self._ms(started))
39
+ if not report_path.exists():
40
+ tail = (proc.stderr or proc.stdout or "").strip()[-2000:]
41
+ return EngineResult(engine=self.name, error=f"no report produced: {tail}", duration_ms=self._ms(started))
42
+ try:
43
+ data = json.loads(report_path.read_text())
44
+ except (json.JSONDecodeError, OSError) as exc:
45
+ return EngineResult(engine=self.name, error=f"cannot read report: {exc}", duration_ms=self._ms(started))
46
+ return self._parse(data, started)
47
+
48
+ def _parse(self, data: dict, started: float) -> EngineResult:
49
+ findings: list[EngineFinding] = []
50
+ items = data.get("issues") or data.get("findings") or []
51
+ for f in items:
52
+ loc = f.get("location") or {}
53
+ finding = EngineFinding(
54
+ engine=self.name,
55
+ rule_id=str(f.get("rule_id") or f.get("id") or ""),
56
+ category=str(f.get("category") or ""),
57
+ title=str(f.get("title") or f.get("pattern") or f.get("finding") or f.get("description") or ""),
58
+ severity=normalize_severity(f.get("severity")),
59
+ file_path=str(f.get("file") or loc.get("file") or f.get("path") or ""),
60
+ evidence=truncate_evidence(f.get("snippet") or f.get("code_snippet") or f.get("evidence") or f.get("finding")),
61
+ )
62
+ finding.fingerprint = make_fingerprint(self.name, finding.rule_id, finding.file_path, finding.title)
63
+ findings.append(finding)
64
+ risk = data.get("risk_assessment") or {}
65
+ score = data.get("risk_score") or risk.get("score")
66
+ return EngineResult(
67
+ engine=self.name,
68
+ score=int(score) if score is not None else None,
69
+ findings=findings,
70
+ duration_ms=self._ms(started),
71
+ )
72
+
73
+ @staticmethod
74
+ def _ms(started: float) -> int:
75
+ return int((time.monotonic() - started) * 1000)
File without changes
@@ -0,0 +1,151 @@
1
+ import hashlib
2
+ import io
3
+ import subprocess
4
+ import tempfile
5
+ import zipfile
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+
13
+ class IngestLimitExceeded(RuntimeError):
14
+ pass
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class Fetched:
19
+ path: Path
20
+ origin: str
21
+ source_url: str
22
+ version_ref: str
23
+
24
+
25
+ def content_hash(root: Path) -> str:
26
+ digest = hashlib.sha256()
27
+ for file_path in sorted(p for p in root.rglob("*") if p.is_file() and not p.is_symlink()):
28
+ rel = file_path.relative_to(root).as_posix()
29
+ file_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
30
+ digest.update(f"{rel}:{file_hash}\n".encode())
31
+ return digest.hexdigest()
32
+
33
+
34
+ def fetch(
35
+ target: str,
36
+ *,
37
+ tmp_root: Path,
38
+ max_bytes: int,
39
+ max_zip_members: int = 10_000,
40
+ client: httpx.Client | None = None,
41
+ git_runner: Callable[..., subprocess.CompletedProcess] = subprocess.run,
42
+ timeout_s: int = 120,
43
+ ) -> Fetched:
44
+ tmp_root.mkdir(parents=True, exist_ok=True)
45
+ if "://" not in target and not target.endswith(".git"):
46
+ local = Path(target).expanduser().resolve()
47
+ if not local.is_dir():
48
+ raise FileNotFoundError(f"not a directory: {target}")
49
+ if not (local / "SKILL.md").exists():
50
+ raise FileNotFoundError(f"no SKILL.md found in '{target}'")
51
+ return Fetched(path=local, origin="local", source_url=str(local), version_ref="")
52
+
53
+ workdir = Path(tempfile.mkdtemp(dir=tmp_root))
54
+ if target.endswith(".zip") or "/zipball/" in target:
55
+ _download_zip(target, workdir, max_bytes, max_zip_members, client, timeout_s)
56
+ return Fetched(
57
+ path=_unwrap_single_dir(workdir), origin="zip", source_url=target, version_ref=""
58
+ )
59
+
60
+ version_ref = _git_clone(target, workdir, git_runner, timeout_s)
61
+ return Fetched(
62
+ path=workdir / "repo", origin="git", source_url=target, version_ref=version_ref
63
+ )
64
+
65
+
66
+ def discover_skills(root: Path) -> list[Fetched]:
67
+ root = root.expanduser().resolve()
68
+ if not root.is_dir():
69
+ raise FileNotFoundError(f"not a directory: {root}")
70
+ subdirs = sorted(
71
+ d for d in root.iterdir() if d.is_dir() and (d / "SKILL.md").exists()
72
+ )
73
+ if (root / "SKILL.md").exists():
74
+ subdirs.insert(0, root)
75
+ if not subdirs:
76
+ raise FileNotFoundError(f"no SKILL.md found in '{root}'")
77
+ return [
78
+ Fetched(path=d, origin="local", source_url=str(d), version_ref="")
79
+ for d in subdirs
80
+ ]
81
+
82
+
83
+ def _download_zip(
84
+ url: str,
85
+ dest: Path,
86
+ max_bytes: int,
87
+ max_zip_members: int,
88
+ client: httpx.Client | None,
89
+ timeout_s: int,
90
+ ) -> None:
91
+ own_client = client is None
92
+ client = client or httpx.Client(follow_redirects=True, timeout=timeout_s)
93
+ try:
94
+ data = bytearray()
95
+ with client.stream("GET", url) as response:
96
+ response.raise_for_status()
97
+ for chunk in response.iter_bytes():
98
+ data.extend(chunk)
99
+ if len(data) > max_bytes:
100
+ raise IngestLimitExceeded(f"download exceeds {max_bytes} bytes")
101
+ buf = io.BytesIO(bytes(data))
102
+ with zipfile.ZipFile(buf) as zf:
103
+ members = zf.infolist()
104
+ if len(members) > max_zip_members:
105
+ raise IngestLimitExceeded(
106
+ f"zip has {len(members)} members (cap {max_zip_members})"
107
+ )
108
+ total = sum(m.file_size for m in members)
109
+ if total > max_bytes:
110
+ raise IngestLimitExceeded(
111
+ f"uncompressed size {total} exceeds {max_bytes} bytes"
112
+ )
113
+ for member in members:
114
+ member_path = Path(member.filename)
115
+ if member_path.is_absolute() or ".." in member_path.parts:
116
+ raise IngestLimitExceeded(f"unsafe zip entry: {member.filename}")
117
+ zf.extractall(dest)
118
+ finally:
119
+ if own_client:
120
+ client.close()
121
+
122
+
123
+ def _git_clone(
124
+ url: str,
125
+ workdir: Path,
126
+ runner: Callable[..., subprocess.CompletedProcess],
127
+ timeout_s: int,
128
+ ) -> str:
129
+ dest = workdir / "repo"
130
+ clone = runner(
131
+ ["git", "clone", "--depth", "1", url, str(dest)],
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=timeout_s,
135
+ )
136
+ if clone.returncode != 0:
137
+ raise RuntimeError(f"git clone failed: {(clone.stderr or '').strip()[-2000:]}")
138
+ rev = runner(
139
+ ["git", "-C", str(dest), "rev-parse", "HEAD"],
140
+ capture_output=True,
141
+ text=True,
142
+ timeout=30,
143
+ )
144
+ return rev.stdout.strip()
145
+
146
+
147
+ def _unwrap_single_dir(workdir: Path) -> Path:
148
+ entries = list(workdir.iterdir())
149
+ if len(entries) == 1 and entries[0].is_dir():
150
+ return entries[0]
151
+ return workdir
File without changes