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 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}