vouch-agent 0.3.0__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.
- vouch/__init__.py +36 -0
- vouch/agent.py +278 -0
- vouch/api.py +113 -0
- vouch/capabilities.py +148 -0
- vouch/cli.py +212 -0
- vouch/cv.py +283 -0
- vouch/engine.py +312 -0
- vouch/llm.py +171 -0
- vouch/loader.py +90 -0
- vouch/mcp_server.py +111 -0
- vouch/models.py +138 -0
- vouch/rules.py +359 -0
- vouch_agent-0.3.0.dist-info/METADATA +322 -0
- vouch_agent-0.3.0.dist-info/RECORD +18 -0
- vouch_agent-0.3.0.dist-info/WHEEL +5 -0
- vouch_agent-0.3.0.dist-info/entry_points.txt +4 -0
- vouch_agent-0.3.0.dist-info/licenses/LICENSE +21 -0
- vouch_agent-0.3.0.dist-info/top_level.txt +1 -0
vouch/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""vouch: vet agent Skills and agents, and vouch for the safe ones.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
from vouch import validate_skill, validate_path, validate_text
|
|
6
|
+
from vouch import build_cv, build_agent_cv
|
|
7
|
+
|
|
8
|
+
Each validator returns a :class:`~vouch.models.Report` with a ``verdict`` of
|
|
9
|
+
``"valid"``, ``"suspicious"`` or ``"malicious"``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .agent import AgentCV, build_agent_cv, discover_skills
|
|
13
|
+
from .cv import SkillCV, build_cv, render_markdown, render_text
|
|
14
|
+
from .engine import Engine, validate_path, validate_skill, validate_text
|
|
15
|
+
from .models import Finding, Report, Severity, SkillInput, Verdict
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AgentCV",
|
|
19
|
+
"Engine",
|
|
20
|
+
"Finding",
|
|
21
|
+
"Report",
|
|
22
|
+
"Severity",
|
|
23
|
+
"SkillCV",
|
|
24
|
+
"SkillInput",
|
|
25
|
+
"Verdict",
|
|
26
|
+
"build_agent_cv",
|
|
27
|
+
"build_cv",
|
|
28
|
+
"discover_skills",
|
|
29
|
+
"render_markdown",
|
|
30
|
+
"render_text",
|
|
31
|
+
"validate_path",
|
|
32
|
+
"validate_skill",
|
|
33
|
+
"validate_text",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "0.3.0"
|
vouch/agent.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Agent CV: a trust profile for a whole agent (all of its skills).
|
|
2
|
+
|
|
3
|
+
Where a :class:`~vouch.cv.SkillCV` profiles a single skill, an
|
|
4
|
+
:class:`AgentCV` aggregates *every* skill an agent has loaded into one report:
|
|
5
|
+
the overall verdict, the union of capabilities across skills, and a per-skill
|
|
6
|
+
breakdown. Think of it as "would you hire this agent?".
|
|
7
|
+
|
|
8
|
+
An "agent" here is simply a directory that contains one or more skills (each a
|
|
9
|
+
folder with a ``SKILL.md``). Discovery walks the tree and treats every folder
|
|
10
|
+
containing a ``SKILL.md`` as a distinct skill.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from . import loader
|
|
21
|
+
from .cv import SkillCV, build_cv
|
|
22
|
+
from .models import Severity, Verdict
|
|
23
|
+
|
|
24
|
+
# Ordering used to pick the "worst" verdict across skills.
|
|
25
|
+
_VERDICT_RANK = {Verdict.VALID: 0, Verdict.SUSPICIOUS: 1, Verdict.MALICIOUS: 2}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def discover_skills(root: str | os.PathLike[str]) -> list[Path]:
|
|
29
|
+
"""Return the directory of every skill (folder with a SKILL.md) under root.
|
|
30
|
+
|
|
31
|
+
If ``root`` itself is a single skill (or a file), it is returned as-is.
|
|
32
|
+
"""
|
|
33
|
+
p = Path(root)
|
|
34
|
+
if p.is_file():
|
|
35
|
+
return [p]
|
|
36
|
+
found: list[Path] = []
|
|
37
|
+
for dirpath, dirnames, filenames in os.walk(p):
|
|
38
|
+
dirnames[:] = [d for d in dirnames if d not in loader._SKIP_DIRS]
|
|
39
|
+
if any(f.lower() == "skill.md" for f in filenames):
|
|
40
|
+
found.append(Path(dirpath))
|
|
41
|
+
# Don't descend further into a skill's own subdirectories.
|
|
42
|
+
dirnames[:] = []
|
|
43
|
+
if not found:
|
|
44
|
+
# No SKILL.md anywhere; treat the root itself as one skill.
|
|
45
|
+
return [p]
|
|
46
|
+
return sorted(found)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SkillSummary:
|
|
51
|
+
name: str
|
|
52
|
+
path: str
|
|
53
|
+
verdict: Verdict
|
|
54
|
+
risk_score: int
|
|
55
|
+
finding_count: int
|
|
56
|
+
top_capabilities: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
return {
|
|
60
|
+
"name": self.name,
|
|
61
|
+
"path": self.path,
|
|
62
|
+
"verdict": self.verdict.value,
|
|
63
|
+
"risk_score": self.risk_score,
|
|
64
|
+
"finding_count": self.finding_count,
|
|
65
|
+
"top_capabilities": self.top_capabilities,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class AgentCV:
|
|
71
|
+
name: str
|
|
72
|
+
verdict: Verdict
|
|
73
|
+
risk_score: int # max across skills
|
|
74
|
+
recommendation: str
|
|
75
|
+
skill_count: int
|
|
76
|
+
skills: list[SkillSummary]
|
|
77
|
+
capabilities: dict[str, int] # capability label -> how many skills use it
|
|
78
|
+
findings_by_severity: dict[str, int]
|
|
79
|
+
skill_cvs: list[SkillCV] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"name": self.name,
|
|
84
|
+
"verdict": self.verdict.value,
|
|
85
|
+
"risk_score": self.risk_score,
|
|
86
|
+
"recommendation": self.recommendation,
|
|
87
|
+
"skill_count": self.skill_count,
|
|
88
|
+
"capabilities": self.capabilities,
|
|
89
|
+
"findings_by_severity": self.findings_by_severity,
|
|
90
|
+
"skills": [s.to_dict() for s in self.skills],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_RECOMMENDATION = {
|
|
95
|
+
Verdict.VALID: "TRUSTED — all skills passed. Safe to run this agent.",
|
|
96
|
+
Verdict.SUSPICIOUS: "REVIEW — one or more skills need a look before trusting this agent.",
|
|
97
|
+
Verdict.MALICIOUS: "QUARANTINE — this agent has a malicious skill. Do not run.",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_agent_cv(
|
|
102
|
+
root: str | os.PathLike[str],
|
|
103
|
+
*,
|
|
104
|
+
name: str | None = None,
|
|
105
|
+
use_llm: bool | None = None,
|
|
106
|
+
model: str | None = None,
|
|
107
|
+
api_key: str | None = None,
|
|
108
|
+
human_signoff: bool = False,
|
|
109
|
+
) -> AgentCV:
|
|
110
|
+
"""Build an Agent CV by profiling every skill discovered under ``root``."""
|
|
111
|
+
skill_dirs = discover_skills(root)
|
|
112
|
+
|
|
113
|
+
skill_cvs: list[SkillCV] = []
|
|
114
|
+
for sd in skill_dirs:
|
|
115
|
+
skill = loader.load(str(sd))
|
|
116
|
+
skill_cvs.append(
|
|
117
|
+
build_cv(
|
|
118
|
+
skill,
|
|
119
|
+
use_llm=use_llm,
|
|
120
|
+
model=model,
|
|
121
|
+
api_key=api_key,
|
|
122
|
+
human_signoff=human_signoff,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
worst = Verdict.VALID
|
|
127
|
+
max_risk = 0
|
|
128
|
+
cap_counts: dict[str, int] = {}
|
|
129
|
+
sev_totals: dict[str, int] = {s.value: 0 for s in Severity}
|
|
130
|
+
summaries: list[SkillSummary] = []
|
|
131
|
+
|
|
132
|
+
for cv in skill_cvs:
|
|
133
|
+
if _VERDICT_RANK[cv.verdict] > _VERDICT_RANK[worst]:
|
|
134
|
+
worst = cv.verdict
|
|
135
|
+
max_risk = max(max_risk, cv.risk_score)
|
|
136
|
+
present = [c.label for c in cv.capabilities if c.present]
|
|
137
|
+
for label in present:
|
|
138
|
+
cap_counts[label] = cap_counts.get(label, 0) + 1
|
|
139
|
+
for sev, n in cv.findings_by_severity.items():
|
|
140
|
+
sev_totals[sev] += n
|
|
141
|
+
summaries.append(
|
|
142
|
+
SkillSummary(
|
|
143
|
+
name=cv.name,
|
|
144
|
+
path=cv.report.skill_name,
|
|
145
|
+
verdict=cv.verdict,
|
|
146
|
+
risk_score=cv.risk_score,
|
|
147
|
+
finding_count=len(cv.report.findings),
|
|
148
|
+
top_capabilities=present[:4],
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Worst skills first, then by risk.
|
|
153
|
+
summaries.sort(key=lambda s: (_VERDICT_RANK[s.verdict], s.risk_score), reverse=True)
|
|
154
|
+
|
|
155
|
+
agent_name = name or Path(root).name or "agent"
|
|
156
|
+
return AgentCV(
|
|
157
|
+
name=agent_name,
|
|
158
|
+
verdict=worst,
|
|
159
|
+
risk_score=max_risk,
|
|
160
|
+
recommendation=_RECOMMENDATION[worst],
|
|
161
|
+
skill_count=len(skill_cvs),
|
|
162
|
+
skills=summaries,
|
|
163
|
+
capabilities=dict(sorted(cap_counts.items(), key=lambda kv: -kv[1])),
|
|
164
|
+
findings_by_severity=sev_totals,
|
|
165
|
+
skill_cvs=skill_cvs,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Renderers
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
_VERDICT_BADGE = {
|
|
174
|
+
Verdict.VALID: "🟢 TRUSTED",
|
|
175
|
+
Verdict.SUSPICIOUS: "🟡 REVIEW",
|
|
176
|
+
Verdict.MALICIOUS: "🔴 QUARANTINE",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_markdown(cv: AgentCV) -> str:
|
|
181
|
+
lines: list[str] = []
|
|
182
|
+
lines.append(f"# Agent CV — {cv.name}")
|
|
183
|
+
lines.append("")
|
|
184
|
+
lines.append(
|
|
185
|
+
f"**Status:** {_VERDICT_BADGE[cv.verdict]} · "
|
|
186
|
+
f"**Max risk:** {cv.risk_score}/100 · "
|
|
187
|
+
f"**Skills:** {cv.skill_count}"
|
|
188
|
+
)
|
|
189
|
+
lines.append("")
|
|
190
|
+
lines.append(f"**Recommendation:** {cv.recommendation}")
|
|
191
|
+
lines.append("")
|
|
192
|
+
|
|
193
|
+
lines.append("## Skills")
|
|
194
|
+
lines.append("")
|
|
195
|
+
lines.append("| Skill | Verdict | Risk | Findings | Capabilities |")
|
|
196
|
+
lines.append("|---|---|---:|---:|---|")
|
|
197
|
+
for s in cv.skills:
|
|
198
|
+
caps = ", ".join(s.top_capabilities) or "—"
|
|
199
|
+
lines.append(
|
|
200
|
+
f"| {s.name} | {s.verdict.value} | {s.risk_score} | "
|
|
201
|
+
f"{s.finding_count} | {caps} |"
|
|
202
|
+
)
|
|
203
|
+
lines.append("")
|
|
204
|
+
|
|
205
|
+
lines.append("## Capabilities across the agent")
|
|
206
|
+
if not cv.capabilities:
|
|
207
|
+
lines.append("_None detected._")
|
|
208
|
+
else:
|
|
209
|
+
for label, count in cv.capabilities.items():
|
|
210
|
+
lines.append(f"- **{label}** — used by {count} skill(s)")
|
|
211
|
+
lines.append("")
|
|
212
|
+
|
|
213
|
+
total = sum(cv.findings_by_severity.values())
|
|
214
|
+
lines.append(f"## Security ({total} finding(s) total)")
|
|
215
|
+
order = ["critical", "high", "medium", "low", "info"]
|
|
216
|
+
badge = ", ".join(
|
|
217
|
+
f"{cv.findings_by_severity[s]} {s}"
|
|
218
|
+
for s in order
|
|
219
|
+
if cv.findings_by_severity.get(s)
|
|
220
|
+
)
|
|
221
|
+
lines.append(badge or "_No findings._")
|
|
222
|
+
lines.append("")
|
|
223
|
+
return "\n".join(lines)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_C = {
|
|
227
|
+
Verdict.VALID: "\033[32m",
|
|
228
|
+
Verdict.SUSPICIOUS: "\033[33m",
|
|
229
|
+
Verdict.MALICIOUS: "\033[31m",
|
|
230
|
+
}
|
|
231
|
+
_RESET = "\033[0m"
|
|
232
|
+
_BOLD = "\033[1m"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def render_text(cv: AgentCV, color: bool = False) -> str:
|
|
236
|
+
def c(text: str, code: str) -> str:
|
|
237
|
+
return f"{code}{text}{_RESET}" if color else text
|
|
238
|
+
|
|
239
|
+
width = 62
|
|
240
|
+
lines: list[str] = []
|
|
241
|
+
lines.append("╔" + "═" * width + "╗")
|
|
242
|
+
lines.append("║" + f" AGENT CV — {cv.name}".ljust(width)[:width] + "║")
|
|
243
|
+
lines.append("╚" + "═" * width + "╝")
|
|
244
|
+
badge = _VERDICT_BADGE[cv.verdict].split(" ", 1)[1]
|
|
245
|
+
lines.append(
|
|
246
|
+
c(f"Status: {badge}", _BOLD + _C[cv.verdict])
|
|
247
|
+
+ f" Max risk: {cv.risk_score}/100 Skills: {cv.skill_count}"
|
|
248
|
+
)
|
|
249
|
+
lines.append(c(f"Recommendation: {cv.recommendation}", _C[cv.verdict]))
|
|
250
|
+
lines.append("")
|
|
251
|
+
|
|
252
|
+
lines.append(c("SKILLS", _BOLD))
|
|
253
|
+
for s in cv.skills:
|
|
254
|
+
mark = c(s.verdict.value.upper().ljust(10), _C[s.verdict])
|
|
255
|
+
lines.append(
|
|
256
|
+
f" {mark} risk {s.risk_score:>3} {s.finding_count:>2} finding(s) {s.name}"
|
|
257
|
+
)
|
|
258
|
+
if s.top_capabilities:
|
|
259
|
+
lines.append(f" caps: {', '.join(s.top_capabilities)}")
|
|
260
|
+
lines.append("")
|
|
261
|
+
|
|
262
|
+
lines.append(c("CAPABILITIES (agent-wide)", _BOLD))
|
|
263
|
+
if not cv.capabilities:
|
|
264
|
+
lines.append(" (none detected)")
|
|
265
|
+
for label, count in cv.capabilities.items():
|
|
266
|
+
lines.append(f" • {label} — {count} skill(s)")
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
total = sum(cv.findings_by_severity.values())
|
|
270
|
+
lines.append(c(f"SECURITY ({total} total)", _BOLD))
|
|
271
|
+
order = ["critical", "high", "medium", "low", "info"]
|
|
272
|
+
summary = " ".join(
|
|
273
|
+
f"{cv.findings_by_severity[s]} {s}"
|
|
274
|
+
for s in order
|
|
275
|
+
if cv.findings_by_severity.get(s)
|
|
276
|
+
)
|
|
277
|
+
lines.append(" " + (summary or "(none)"))
|
|
278
|
+
return "\n".join(lines)
|
vouch/api.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""HTTP API for skill validation (FastAPI).
|
|
2
|
+
|
|
3
|
+
Run with::
|
|
4
|
+
|
|
5
|
+
vouch-api # uvicorn on 0.0.0.0:8000
|
|
6
|
+
# or
|
|
7
|
+
uvicorn vouch.api:app --reload
|
|
8
|
+
|
|
9
|
+
Requires the ``api`` extra::
|
|
10
|
+
|
|
11
|
+
pip install "vouch[api]"
|
|
12
|
+
|
|
13
|
+
Endpoints
|
|
14
|
+
---------
|
|
15
|
+
- ``GET /health`` -> liveness probe
|
|
16
|
+
- ``POST /validate/text`` -> body: {content, name?, use_llm?}
|
|
17
|
+
- ``POST /validate/path`` -> body: {path, use_llm?} (local FS access)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_app():
|
|
26
|
+
try:
|
|
27
|
+
from fastapi import FastAPI, HTTPException
|
|
28
|
+
from pydantic import BaseModel, Field
|
|
29
|
+
except Exception as e: # pragma: no cover - import guard
|
|
30
|
+
raise SystemExit(
|
|
31
|
+
"FastAPI is required. Install with: pip install "
|
|
32
|
+
'"vouch[api]"'
|
|
33
|
+
) from e
|
|
34
|
+
|
|
35
|
+
from . import loader
|
|
36
|
+
from .engine import validate_skill
|
|
37
|
+
|
|
38
|
+
app = FastAPI(
|
|
39
|
+
title="Vouch",
|
|
40
|
+
version="0.3.0",
|
|
41
|
+
description="Vet agent Skills; classify as valid, suspicious, or malicious.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
class TextRequest(BaseModel):
|
|
45
|
+
content: str = Field(..., description="Raw skill content (e.g. SKILL.md body).")
|
|
46
|
+
name: str = Field("inline-skill", description="Optional skill name.")
|
|
47
|
+
use_llm: bool | None = Field(
|
|
48
|
+
None, description="Enable LLM auditor. Default: auto (if API key set)."
|
|
49
|
+
)
|
|
50
|
+
model: str | None = Field(None, description="Optional LLM model id override.")
|
|
51
|
+
|
|
52
|
+
class PathRequest(BaseModel):
|
|
53
|
+
path: str = Field(..., description="Local path to a skill dir or file.")
|
|
54
|
+
use_llm: bool | None = Field(None, description="Enable LLM auditor.")
|
|
55
|
+
model: str | None = Field(None, description="Optional LLM model id override.")
|
|
56
|
+
|
|
57
|
+
@app.get("/health")
|
|
58
|
+
def health() -> dict:
|
|
59
|
+
return {"status": "ok"}
|
|
60
|
+
|
|
61
|
+
@app.post("/validate/text")
|
|
62
|
+
def validate_text_endpoint(req: TextRequest) -> dict:
|
|
63
|
+
skill = loader.load_text(req.content, name=req.name)
|
|
64
|
+
report = validate_skill(skill, use_llm=req.use_llm, model=req.model)
|
|
65
|
+
return report.to_dict()
|
|
66
|
+
|
|
67
|
+
@app.post("/cv/text")
|
|
68
|
+
def cv_text_endpoint(req: TextRequest) -> dict:
|
|
69
|
+
from . import cv as cv_mod
|
|
70
|
+
|
|
71
|
+
skill = loader.load_text(req.content, name=req.name)
|
|
72
|
+
skill_cv = cv_mod.build_cv(skill, use_llm=req.use_llm, model=req.model)
|
|
73
|
+
payload = skill_cv.to_dict()
|
|
74
|
+
payload["markdown"] = cv_mod.render_markdown(skill_cv)
|
|
75
|
+
return payload
|
|
76
|
+
|
|
77
|
+
@app.post("/validate/path")
|
|
78
|
+
def validate_path_endpoint(req: PathRequest) -> dict:
|
|
79
|
+
# Guard: local-path validation is disabled unless explicitly allowed,
|
|
80
|
+
# since it exposes the host filesystem to callers.
|
|
81
|
+
if os.environ.get("VOUCH_ALLOW_PATH") != "1":
|
|
82
|
+
raise HTTPException(
|
|
83
|
+
status_code=403,
|
|
84
|
+
detail="Path validation disabled. Set VOUCH_ALLOW_PATH=1 "
|
|
85
|
+
"to enable local filesystem access.",
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
skill = loader.load(req.path)
|
|
89
|
+
except FileNotFoundError as e:
|
|
90
|
+
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
91
|
+
report = validate_skill(skill, use_llm=req.use_llm, model=req.model)
|
|
92
|
+
return report.to_dict()
|
|
93
|
+
|
|
94
|
+
return app
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Module-level app for `uvicorn vouch.api:app`.
|
|
98
|
+
try: # pragma: no cover - only succeeds when FastAPI is installed
|
|
99
|
+
app = create_app()
|
|
100
|
+
except SystemExit:
|
|
101
|
+
app = None # type: ignore[assignment]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run() -> None: # pragma: no cover - thin runner
|
|
105
|
+
import uvicorn
|
|
106
|
+
|
|
107
|
+
host = os.environ.get("VOUCH_HOST", "0.0.0.0")
|
|
108
|
+
port = int(os.environ.get("VOUCH_PORT", "8000"))
|
|
109
|
+
uvicorn.run("vouch.api:app", host=host, port=port)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__": # pragma: no cover
|
|
113
|
+
run()
|
vouch/capabilities.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Capability inference for skills.
|
|
2
|
+
|
|
3
|
+
Separate from :mod:`vouch.cv` (which imports the engine) so the engine can also
|
|
4
|
+
use capability data without a circular import.
|
|
5
|
+
|
|
6
|
+
A *capability* describes what a skill *can do* (benign-or-not): network access,
|
|
7
|
+
shell execution, dynamic code execution, filesystem read/write, credential
|
|
8
|
+
access, persistence, environment access. The engine uses the **combination** of
|
|
9
|
+
capabilities — not just rule findings — to gate verdicts, because the dangerous
|
|
10
|
+
minority of skills are multi-stage chains whose individual steps look benign.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .models import SkillInput
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Evidence:
|
|
24
|
+
file: str
|
|
25
|
+
line: int
|
|
26
|
+
excerpt: str
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
return asdict(self)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Capability:
|
|
34
|
+
key: str
|
|
35
|
+
label: str
|
|
36
|
+
present: bool = False
|
|
37
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"key": self.key,
|
|
42
|
+
"label": self.label,
|
|
43
|
+
"present": self.present,
|
|
44
|
+
"evidence": [e.to_dict() for e in self.evidence],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# key, label, pattern
|
|
49
|
+
_CAP_PATTERNS: list[tuple[str, str, re.Pattern[str]]] = [
|
|
50
|
+
(
|
|
51
|
+
"network",
|
|
52
|
+
"Network access",
|
|
53
|
+
re.compile(
|
|
54
|
+
r"\b(curl|wget|fetch\(|requests\.(get|post|put|delete)|urllib|"
|
|
55
|
+
r"http\.client|axios|http[sx]?://)\b",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
"shell",
|
|
61
|
+
"Shell execution",
|
|
62
|
+
re.compile(
|
|
63
|
+
r"\b(subprocess\.|os\.system|os\.popen|shell=True|/bin/(ba)?sh|"
|
|
64
|
+
r"\bsh\s+-c|\bbash\b|child_process|execSync|spawn\()\b",
|
|
65
|
+
re.IGNORECASE,
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"code_exec",
|
|
70
|
+
"Dynamic code execution",
|
|
71
|
+
re.compile(r"\b(eval\(|exec\(|Function\(|compile\(|importlib)\b"),
|
|
72
|
+
),
|
|
73
|
+
(
|
|
74
|
+
"fs_write",
|
|
75
|
+
"Filesystem writes",
|
|
76
|
+
re.compile(
|
|
77
|
+
r"(open\([^)]*['\"][wax]\+?['\"]|write_text|\.write\(|>>?\s*[~/.\w]|"
|
|
78
|
+
r"\b(rm|mv|cp|mkdir|rmdir|touch|chmod|chown)\b)",
|
|
79
|
+
re.IGNORECASE,
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
(
|
|
83
|
+
"fs_read",
|
|
84
|
+
"Filesystem reads",
|
|
85
|
+
re.compile(
|
|
86
|
+
r"(open\([^)]*['\"]r['\"]?|read_text|\.read\(|\b(cat|less|more|head|tail)\b)",
|
|
87
|
+
re.IGNORECASE,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
(
|
|
91
|
+
"credentials",
|
|
92
|
+
"Credential / secret access",
|
|
93
|
+
re.compile(
|
|
94
|
+
r"(\.ssh/|\.aws/|\.kube/|token|secret|api[_-]?key|password|passwd|"
|
|
95
|
+
r"credential|\.env\b)",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
(
|
|
100
|
+
"persistence",
|
|
101
|
+
"Persistence mechanisms",
|
|
102
|
+
re.compile(
|
|
103
|
+
r"\b(crontab|launchctl|systemd|\.bashrc|\.zshrc|\.profile|LaunchAgents|"
|
|
104
|
+
r"LaunchDaemons)\b",
|
|
105
|
+
re.IGNORECASE,
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
(
|
|
109
|
+
"env",
|
|
110
|
+
"Environment variable access",
|
|
111
|
+
re.compile(r"(os\.environ|process\.env|getenv|printenv|\benv\b)", re.IGNORECASE),
|
|
112
|
+
),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
_MAX_EVIDENCE = 3
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def scan_capabilities(skill: SkillInput) -> list[Capability]:
|
|
119
|
+
"""Return one Capability per class, marked present with up to 3 evidence hits."""
|
|
120
|
+
caps = {key: Capability(key, label) for key, label, _ in _CAP_PATTERNS}
|
|
121
|
+
seen: dict[str, set[tuple[str, int]]] = {key: set() for key, _, _ in _CAP_PATTERNS}
|
|
122
|
+
for sf in skill.files:
|
|
123
|
+
content_lines = sf.content.splitlines()
|
|
124
|
+
for key, _label, pat in _CAP_PATTERNS:
|
|
125
|
+
cap = caps[key]
|
|
126
|
+
for m in pat.finditer(sf.content):
|
|
127
|
+
cap.present = True
|
|
128
|
+
line = sf.content.count("\n", 0, m.start()) + 1
|
|
129
|
+
loc = (sf.path, line)
|
|
130
|
+
if loc in seen[key]:
|
|
131
|
+
continue
|
|
132
|
+
seen[key].add(loc)
|
|
133
|
+
if len(cap.evidence) < _MAX_EVIDENCE:
|
|
134
|
+
snippet = (
|
|
135
|
+
content_lines[line - 1].strip()
|
|
136
|
+
if line <= len(content_lines)
|
|
137
|
+
else ""
|
|
138
|
+
)
|
|
139
|
+
# Don't use markdown code-fence markers as evidence — they're
|
|
140
|
+
# noise (e.g. ```bash). The capability still counts as present.
|
|
141
|
+
if snippet.startswith("```"):
|
|
142
|
+
continue
|
|
143
|
+
cap.evidence.append(Evidence(sf.path, line, snippet[:120]))
|
|
144
|
+
return list(caps.values())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def present_keys(caps: list[Capability]) -> set[str]:
|
|
148
|
+
return {c.key for c in caps if c.present}
|