evolveguard 0.1.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.
- evolveguard/__init__.py +93 -0
- evolveguard/cli.py +255 -0
- evolveguard/diff/__init__.py +200 -0
- evolveguard/errors.py +17 -0
- evolveguard/fixtures.py +106 -0
- evolveguard/formatters.py +90 -0
- evolveguard/parser/__init__.py +0 -0
- evolveguard/parser/skillmd.py +249 -0
- evolveguard/paths.py +78 -0
- evolveguard/record/__init__.py +30 -0
- evolveguard/replay/__init__.py +35 -0
- evolveguard/report/__init__.py +95 -0
- evolveguard/snapshot.py +113 -0
- evolveguard/types.py +292 -0
- evolveguard-0.1.0.dist-info/METADATA +275 -0
- evolveguard-0.1.0.dist-info/RECORD +19 -0
- evolveguard-0.1.0.dist-info/WHEEL +4 -0
- evolveguard-0.1.0.dist-info/entry_points.txt +2 -0
- evolveguard-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Human-readable output formatting for the CLI. Ported from
|
|
3
|
+
src/evolveguard-cli/formatters.ts.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
from .types import Baseline, CapabilityChange, EvolveGuardReport
|
|
10
|
+
|
|
11
|
+
_VERSION = "0.1.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_record_result(baseline: Baseline, baseline_path: str) -> str:
|
|
15
|
+
lines: List[str] = []
|
|
16
|
+
lines.append(f"EvolveGuard v{_VERSION} -- Baseline Recorded")
|
|
17
|
+
lines.append(f"skill: {baseline.skill_name} fixtures: {len(baseline.fixtures)}")
|
|
18
|
+
lines.append("")
|
|
19
|
+
for fixture in baseline.fixtures:
|
|
20
|
+
tool_names = ", ".join(e.tool for e in fixture.tool_call_sequence) or (
|
|
21
|
+
"(no capabilities detected)"
|
|
22
|
+
)
|
|
23
|
+
lines.append(f' recorded fixture: "{fixture.prompt}" tools: {tool_names}')
|
|
24
|
+
lines.append("")
|
|
25
|
+
lines.append(f"baseline written to {baseline_path}")
|
|
26
|
+
return "\n".join(lines)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _summarize_change(change: CapabilityChange) -> str:
|
|
30
|
+
if change.kind == "added":
|
|
31
|
+
return f"new tool call: {change.tool} (baseline had none)"
|
|
32
|
+
if change.kind == "removed":
|
|
33
|
+
return f"tool call removed: {change.tool}"
|
|
34
|
+
return f"scope changed: {change.tool}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_check_result(report: EvolveGuardReport, baseline_recorded_at: str) -> str:
|
|
38
|
+
lines: List[str] = []
|
|
39
|
+
date = baseline_recorded_at[:10]
|
|
40
|
+
lines.append(f"EvolveGuard v{_VERSION} -- Regression Check")
|
|
41
|
+
lines.append(
|
|
42
|
+
f"skill: {report.skill_name} baseline: {date} fixtures: {report.summary.total}"
|
|
43
|
+
)
|
|
44
|
+
lines.append("")
|
|
45
|
+
|
|
46
|
+
for result in report.results:
|
|
47
|
+
tag = "[PASS] " if result.verdict == "PASS" else "[DRIFT]"
|
|
48
|
+
if result.verdict == "PASS":
|
|
49
|
+
lines.append(f'{tag} fixture: "{result.prompt}" tool-call sequence unchanged')
|
|
50
|
+
else:
|
|
51
|
+
first = result.changes[0] if result.changes else None
|
|
52
|
+
summary = _summarize_change(first) if first else "behavior changed"
|
|
53
|
+
lines.append(f'{tag} fixture: "{result.prompt}" {summary}')
|
|
54
|
+
for change in result.changes:
|
|
55
|
+
lines.append(f" -> {change.message}")
|
|
56
|
+
|
|
57
|
+
if report.surface_changes:
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("skill-level surface changes (not tied to a specific fixture):")
|
|
60
|
+
for change in report.surface_changes:
|
|
61
|
+
lines.append(f" -> {change.message}")
|
|
62
|
+
|
|
63
|
+
lines.append("")
|
|
64
|
+
lines.append(f"{report.summary.pass_count} PASS, {report.summary.drift} DRIFT, 0 FAIL")
|
|
65
|
+
if report.exit_code == 1:
|
|
66
|
+
lines.append("exit code 1 (DRIFT blocks merge by default; override with --allow-drift)")
|
|
67
|
+
else:
|
|
68
|
+
lines.append("exit code 0")
|
|
69
|
+
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_report(report: EvolveGuardReport) -> str:
|
|
74
|
+
lines: List[str] = []
|
|
75
|
+
lines.append(f"EvolveGuard v{_VERSION} -- Report")
|
|
76
|
+
lines.append(f"skill: {report.skill_name} checked: {report.checked_at}")
|
|
77
|
+
lines.append("")
|
|
78
|
+
for result in report.results:
|
|
79
|
+
lines.append(f"[{result.verdict}] {result.id}: {result.prompt}")
|
|
80
|
+
for change in result.changes:
|
|
81
|
+
lines.append(f" -> {change.message}")
|
|
82
|
+
if report.surface_changes:
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append("skill-level surface changes:")
|
|
85
|
+
for change in report.surface_changes:
|
|
86
|
+
lines.append(f" -> {change.message}")
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append(f"{report.summary.pass_count} PASS, {report.summary.drift} DRIFT, 0 FAIL")
|
|
89
|
+
lines.append(f"exit code {report.exit_code}")
|
|
90
|
+
return "\n".join(lines)
|
|
File without changes
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parses a Claude Agent Skill file (SKILL.md) or an auto-memory file
|
|
3
|
+
(MEMORY.md). SKILL.md files carry YAML frontmatter declaring the skill's
|
|
4
|
+
name and scope; MEMORY.md files typically do not, so a file with no
|
|
5
|
+
frontmatter is treated as declaring an empty scope, and its capability
|
|
6
|
+
surface is derived entirely from static evidence found in its own body
|
|
7
|
+
text (see derive_capability_surface below).
|
|
8
|
+
|
|
9
|
+
Frontmatter schema this parser understands:
|
|
10
|
+
---
|
|
11
|
+
name: my-skill
|
|
12
|
+
description: ...
|
|
13
|
+
tools: [fs.read, fs.write] # declared tool names, optional
|
|
14
|
+
network: false # boolean, optional (default false)
|
|
15
|
+
filesystem: read-only # "none" | "read-only" | "read-write", optional (default none)
|
|
16
|
+
scope: "./workspace/**" # glob the filesystem tools are scoped to, optional (default "./**")
|
|
17
|
+
hooks: ["scripts/pre-run.sh"] # bundled hook scripts, paths relative to the skill file, optional
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
This schema is a superset compatible with SkillGuard's own DeclaredScope
|
|
21
|
+
(network: boolean, filesystem: none|read-only|read-write) -- EvolveGuard
|
|
22
|
+
reuses the same field names and parsing pattern deliberately so a skill
|
|
23
|
+
author only has to think about one frontmatter shape across both tools.
|
|
24
|
+
|
|
25
|
+
Ported from src/evolveguard/parser/skillmd.ts.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from typing import Dict, List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
from ..paths import resolve_within_base
|
|
36
|
+
from ..types import CapabilityEntry, DeclaredScope, EvidenceRef, ParsedSkillFile
|
|
37
|
+
|
|
38
|
+
_FRONTMATTER_RE = re.compile(r"^---\r?\n([\s\S]*?)\r?\n---")
|
|
39
|
+
|
|
40
|
+
_KNOWN_FILESYSTEM_VALUES = {"none", "read-only", "read-write"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_skill_file(content: str, file_base_name: str) -> ParsedSkillFile:
|
|
44
|
+
match = _FRONTMATTER_RE.match(content)
|
|
45
|
+
|
|
46
|
+
def base_name() -> str:
|
|
47
|
+
return os.path.splitext(os.path.basename(file_base_name))[0]
|
|
48
|
+
|
|
49
|
+
if not match:
|
|
50
|
+
return ParsedSkillFile(
|
|
51
|
+
name=base_name(),
|
|
52
|
+
has_frontmatter=False,
|
|
53
|
+
declared_scope=DeclaredScope(
|
|
54
|
+
tools=[], network=False, filesystem="none", scope="./**", hooks=[]
|
|
55
|
+
),
|
|
56
|
+
body=content,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
data = yaml.safe_load(match.group(1))
|
|
61
|
+
except yaml.YAMLError:
|
|
62
|
+
data = None
|
|
63
|
+
|
|
64
|
+
record = data if isinstance(data, dict) else {}
|
|
65
|
+
|
|
66
|
+
raw_name = record.get("name")
|
|
67
|
+
name = (
|
|
68
|
+
raw_name.strip()
|
|
69
|
+
if isinstance(raw_name, str) and raw_name.strip()
|
|
70
|
+
else base_name()
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
description = record.get("description")
|
|
74
|
+
if not isinstance(description, str):
|
|
75
|
+
description = None
|
|
76
|
+
|
|
77
|
+
raw_tools = record.get("tools")
|
|
78
|
+
tools = [t for t in raw_tools if isinstance(t, str)] if isinstance(raw_tools, list) else []
|
|
79
|
+
|
|
80
|
+
network = record.get("network") is True
|
|
81
|
+
|
|
82
|
+
filesystem_raw = record.get("filesystem")
|
|
83
|
+
filesystem = (
|
|
84
|
+
filesystem_raw
|
|
85
|
+
if isinstance(filesystem_raw, str) and filesystem_raw in _KNOWN_FILESYSTEM_VALUES
|
|
86
|
+
else "none"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
raw_scope = record.get("scope")
|
|
90
|
+
scope = raw_scope if isinstance(raw_scope, str) and raw_scope.strip() else "./**"
|
|
91
|
+
|
|
92
|
+
raw_hooks = record.get("hooks")
|
|
93
|
+
hooks = [h for h in raw_hooks if isinstance(h, str)] if isinstance(raw_hooks, list) else []
|
|
94
|
+
|
|
95
|
+
body = content[match.end() :]
|
|
96
|
+
|
|
97
|
+
return ParsedSkillFile(
|
|
98
|
+
name=name,
|
|
99
|
+
description=description,
|
|
100
|
+
has_frontmatter=True,
|
|
101
|
+
declared_scope=DeclaredScope(
|
|
102
|
+
tools=tools, network=network, filesystem=filesystem, scope=scope, hooks=hooks
|
|
103
|
+
),
|
|
104
|
+
body=body,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Note: `curl`/`wget` deliberately have no trailing `\s` baked into the
|
|
109
|
+
# alternation -- `\b` after "curl" already requires a non-word character to
|
|
110
|
+
# follow (a space, a flag like "-X", end of string, all count), so
|
|
111
|
+
# consuming the whitespace into the match would break that boundary check
|
|
112
|
+
# whenever the very next character after the space is itself non-word (e.g.
|
|
113
|
+
# "curl -X ..."), silently missing real command lines.
|
|
114
|
+
_NETWORK_EVIDENCE_RE = re.compile(
|
|
115
|
+
r"\b(fetch|axios|http\.request|https\.request|requests\.(get|post|put|delete)"
|
|
116
|
+
r"|urllib\.request|urlopen|socket\.socket|curl|wget)\b",
|
|
117
|
+
re.IGNORECASE,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
_FS_WRITE_EVIDENCE_RE = re.compile(
|
|
121
|
+
r"""\b(fs\.writeFile|fs\.writeFileSync|fs\.appendFile|fs\.unlink|open\([^)]*['"]w"""
|
|
122
|
+
r"""|os\.remove|os\.rmdir|shutil\.rmtree)\b|>\s*/|rm\s+-rf""",
|
|
123
|
+
re.IGNORECASE,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _first_match_line(content: str, pattern: re.Pattern) -> Optional[int]:
|
|
128
|
+
m = pattern.search(content)
|
|
129
|
+
if not m:
|
|
130
|
+
return None
|
|
131
|
+
return content.count("\n", 0, m.start()) + 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def infer_hook_evidence(
|
|
135
|
+
skill_dir: str, hooks: List[str]
|
|
136
|
+
) -> Dict[str, List[Dict[str, object]]]:
|
|
137
|
+
"""
|
|
138
|
+
Reads a skill's declared hook scripts (paths validated against the
|
|
139
|
+
skill's own directory -- see paths.py) and scans each for static
|
|
140
|
+
evidence of network or filesystem-write behavior. This is the same
|
|
141
|
+
read-only, regex-based evidence model SkillGuard uses; EvolveGuard
|
|
142
|
+
never executes anything from the skill or its hooks.
|
|
143
|
+
"""
|
|
144
|
+
network_evidence: List[Dict[str, object]] = []
|
|
145
|
+
fs_write_evidence: List[Dict[str, object]] = []
|
|
146
|
+
|
|
147
|
+
for hook_path in hooks:
|
|
148
|
+
try:
|
|
149
|
+
abs_path = resolve_within_base(skill_dir, hook_path)
|
|
150
|
+
except Exception:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with open(abs_path, "r", encoding="utf-8") as fh:
|
|
155
|
+
content = fh.read()
|
|
156
|
+
except OSError:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
net_line = _first_match_line(content, _NETWORK_EVIDENCE_RE)
|
|
160
|
+
if net_line is not None:
|
|
161
|
+
network_evidence.append({"file": hook_path, "line": net_line})
|
|
162
|
+
|
|
163
|
+
fs_line = _first_match_line(content, _FS_WRITE_EVIDENCE_RE)
|
|
164
|
+
if fs_line is not None:
|
|
165
|
+
fs_write_evidence.append({"file": hook_path, "line": fs_line})
|
|
166
|
+
|
|
167
|
+
return {"networkEvidence": network_evidence, "fsWriteEvidence": fs_write_evidence}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def derive_capability_surface(
|
|
171
|
+
parsed: ParsedSkillFile, skill_dir: str
|
|
172
|
+
) -> List[CapabilityEntry]:
|
|
173
|
+
"""
|
|
174
|
+
Derives the full capability surface for a parsed skill: declared entries
|
|
175
|
+
from frontmatter, plus inferred entries from static evidence in the
|
|
176
|
+
skill's own body text and any bundled hook scripts. This is the
|
|
177
|
+
deterministic "transcript baseline" EvolveGuard records and later
|
|
178
|
+
re-derives on replay -- see README "How it works" for why this is not a
|
|
179
|
+
live LLM run.
|
|
180
|
+
"""
|
|
181
|
+
entries: List[CapabilityEntry] = []
|
|
182
|
+
seen = set()
|
|
183
|
+
|
|
184
|
+
def add_declared(tool: str, scope: Optional[str] = None) -> None:
|
|
185
|
+
key = f"declared:{tool}"
|
|
186
|
+
if key in seen:
|
|
187
|
+
return
|
|
188
|
+
seen.add(key)
|
|
189
|
+
entries.append(CapabilityEntry(tool=tool, source="declared", scope=scope))
|
|
190
|
+
|
|
191
|
+
for tool in parsed.declared_scope.tools:
|
|
192
|
+
add_declared(tool)
|
|
193
|
+
|
|
194
|
+
if parsed.declared_scope.network:
|
|
195
|
+
add_declared("network.fetch")
|
|
196
|
+
|
|
197
|
+
if parsed.declared_scope.filesystem in ("read-only", "read-write"):
|
|
198
|
+
add_declared("fs.read", parsed.declared_scope.scope)
|
|
199
|
+
if parsed.declared_scope.filesystem == "read-write":
|
|
200
|
+
add_declared("fs.write", parsed.declared_scope.scope)
|
|
201
|
+
|
|
202
|
+
# Inferred evidence from the skill body itself (covers MEMORY.md, which has no frontmatter).
|
|
203
|
+
body_net_line = _first_match_line(parsed.body, _NETWORK_EVIDENCE_RE)
|
|
204
|
+
body_fs_line = _first_match_line(parsed.body, _FS_WRITE_EVIDENCE_RE)
|
|
205
|
+
|
|
206
|
+
inferred_network_evidence: List[Dict[str, object]] = (
|
|
207
|
+
[{"file": "(body)", "line": body_net_line}] if body_net_line is not None else []
|
|
208
|
+
)
|
|
209
|
+
inferred_fs_evidence: List[Dict[str, object]] = (
|
|
210
|
+
[{"file": "(body)", "line": body_fs_line}] if body_fs_line is not None else []
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Inferred evidence from bundled hook scripts declared in frontmatter.
|
|
214
|
+
hook_evidence = infer_hook_evidence(skill_dir, parsed.declared_scope.hooks)
|
|
215
|
+
inferred_network_evidence.extend(hook_evidence["networkEvidence"])
|
|
216
|
+
inferred_fs_evidence.extend(hook_evidence["fsWriteEvidence"])
|
|
217
|
+
|
|
218
|
+
def add_inferred(tool: str, evidence: List[Dict[str, object]]) -> None:
|
|
219
|
+
if not evidence:
|
|
220
|
+
return
|
|
221
|
+
key = f"inferred:{tool}"
|
|
222
|
+
if key in seen:
|
|
223
|
+
existing = next(
|
|
224
|
+
(e for e in entries if e.source == "inferred" and e.tool == tool), None
|
|
225
|
+
)
|
|
226
|
+
if existing is not None:
|
|
227
|
+
existing.evidence = (existing.evidence or []) + [
|
|
228
|
+
EvidenceRef(file=str(e["file"]), line=int(e["line"])) for e in evidence
|
|
229
|
+
]
|
|
230
|
+
return
|
|
231
|
+
# If this tool is already declared, do not duplicate it as a separate inferred entry --
|
|
232
|
+
# declared coverage takes precedence and the inferred evidence is redundant confirmation.
|
|
233
|
+
if f"declared:{tool}" in seen:
|
|
234
|
+
return
|
|
235
|
+
seen.add(key)
|
|
236
|
+
entries.append(
|
|
237
|
+
CapabilityEntry(
|
|
238
|
+
tool=tool,
|
|
239
|
+
source="inferred",
|
|
240
|
+
evidence=[
|
|
241
|
+
EvidenceRef(file=str(e["file"]), line=int(e["line"])) for e in evidence
|
|
242
|
+
],
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
add_inferred("network.fetch", inferred_network_evidence)
|
|
247
|
+
add_inferred("fs.write", inferred_fs_evidence)
|
|
248
|
+
|
|
249
|
+
return entries
|
evolveguard/paths.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resolves a user-supplied path (SKILL.md, fixtures.json, a hook script
|
|
3
|
+
referenced from frontmatter) and rejects anything that escapes the
|
|
4
|
+
expected base directory. This is the one place path-traversal protection
|
|
5
|
+
lives -- every module that reads a file supplied indirectly through
|
|
6
|
+
frontmatter (hook script paths) routes through this function rather than
|
|
7
|
+
concatenating strings and calling open() directly.
|
|
8
|
+
|
|
9
|
+
A skill file's own frontmatter is untrusted input (it may itself be the
|
|
10
|
+
edit under test), so `hooks: ["../../../etc/passwd"]` must never resolve
|
|
11
|
+
outside the skill's own directory.
|
|
12
|
+
|
|
13
|
+
The lexical check alone is not enough: a path that looks safe
|
|
14
|
+
("hooks/pre.sh") can itself be a symlink whose real target lives outside
|
|
15
|
+
the skill directory, and open() follows symlinks transparently. So after
|
|
16
|
+
the lexical check passes, this also resolves symlinks (via
|
|
17
|
+
os.path.realpath) on both the base and the target and re-checks the same
|
|
18
|
+
containment invariant against the real paths -- a symlink escape fails
|
|
19
|
+
exactly like a literal "../" escape. A target that does not exist yet
|
|
20
|
+
(e.g. a hook path being validated before the file is created) skips the
|
|
21
|
+
realpath re-check rather than erroring; the caller's own file read will
|
|
22
|
+
fail on it as before.
|
|
23
|
+
|
|
24
|
+
Ported from src/evolveguard/paths.ts.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
from .errors import EvolveGuardError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve(base: str, target: str) -> str:
|
|
34
|
+
combined = target if os.path.isabs(target) else os.path.join(base, target)
|
|
35
|
+
return os.path.normpath(combined)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _escapes_base(resolved_base: str, resolved_target: str) -> bool:
|
|
39
|
+
relative = os.path.relpath(resolved_target, resolved_base)
|
|
40
|
+
return relative == ".." or relative.startswith(".." + os.sep)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _outside_base_error(user_path: str, resolved_base: str) -> EvolveGuardError:
|
|
44
|
+
return EvolveGuardError(
|
|
45
|
+
f'Path "{user_path}" resolves outside its skill directory.',
|
|
46
|
+
f"Hook and reference paths declared in a skill file must stay within "
|
|
47
|
+
f"the skill's own directory ({resolved_base}) -- this prevents a "
|
|
48
|
+
f"malicious or accidentally-broken skill file from making EvolveGuard "
|
|
49
|
+
f"read files outside its scope.",
|
|
50
|
+
"Use a path relative to the skill file that stays inside its own "
|
|
51
|
+
"directory, and make sure it is not a symlink pointing elsewhere.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_within_base(base_dir: str, user_path: str) -> str:
|
|
56
|
+
resolved_base = os.path.abspath(base_dir)
|
|
57
|
+
resolved_target = _resolve(resolved_base, user_path)
|
|
58
|
+
|
|
59
|
+
if _escapes_base(resolved_base, resolved_target):
|
|
60
|
+
raise _outside_base_error(user_path, resolved_base)
|
|
61
|
+
|
|
62
|
+
if not os.path.exists(resolved_base) or not os.path.exists(resolved_target):
|
|
63
|
+
# Base or target doesn't exist on disk (yet) -- nothing to follow, the
|
|
64
|
+
# lexical check above already stands as the answer for this call.
|
|
65
|
+
return resolved_target
|
|
66
|
+
|
|
67
|
+
real_base = os.path.realpath(resolved_base)
|
|
68
|
+
real_target = os.path.realpath(resolved_target)
|
|
69
|
+
|
|
70
|
+
if _escapes_base(real_base, real_target):
|
|
71
|
+
raise _outside_base_error(user_path, resolved_base)
|
|
72
|
+
|
|
73
|
+
return resolved_target
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_cli_path(cwd: str, user_path: str) -> str:
|
|
77
|
+
"""Resolves and validates a top-level CLI-supplied path (SKILL.md, fixtures.json, baseline, report)."""
|
|
78
|
+
return _resolve(os.path.abspath(cwd), user_path)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Captures a golden-transcript baseline for a skill: parses the skill file,
|
|
3
|
+
derives its declared + inferred capability surface, and snapshots each
|
|
4
|
+
fixture's expected tool-call sequence against that surface. This never
|
|
5
|
+
invokes a live agent -- see README "How it works".
|
|
6
|
+
|
|
7
|
+
Ported from src/evolveguard/record/index.ts.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from ..fixtures import load_fixtures
|
|
14
|
+
from ..snapshot import build_fixture_snapshots, load_skill
|
|
15
|
+
from ..types import Baseline
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def record_baseline(skill_path: str, fixtures_path: str) -> Baseline:
|
|
19
|
+
skill = load_skill(skill_path)
|
|
20
|
+
fixtures = load_fixtures(fixtures_path)
|
|
21
|
+
fixture_snapshots = build_fixture_snapshots(skill.capability_surface, fixtures)
|
|
22
|
+
|
|
23
|
+
return Baseline(
|
|
24
|
+
schema_version=1,
|
|
25
|
+
skill_name=skill.name,
|
|
26
|
+
skill_path=skill.skill_path,
|
|
27
|
+
recorded_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
28
|
+
full_capability_surface=skill.capability_surface,
|
|
29
|
+
fixtures=fixture_snapshots,
|
|
30
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Re-parses the (possibly edited) skill file and re-derives its capability
|
|
3
|
+
surface using the exact same deterministic logic `record` used, then
|
|
4
|
+
re-snapshots each fixture from the baseline against the new surface. The
|
|
5
|
+
fixture list is taken from the baseline itself (not re-read from an
|
|
6
|
+
external fixtures.json) so `evolveguard check` only needs the skill path.
|
|
7
|
+
|
|
8
|
+
Ported from src/evolveguard/replay/index.ts.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from ..snapshot import build_fixture_snapshots, load_skill
|
|
15
|
+
from ..types import Baseline, Fixture, ReplayResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def replay_skill(skill_path: str, baseline: Baseline) -> ReplayResult:
|
|
19
|
+
skill = load_skill(skill_path)
|
|
20
|
+
|
|
21
|
+
fixtures = [
|
|
22
|
+
Fixture(id=f.id, prompt=f.prompt, expected_tool_calls=f.expected_tool_calls)
|
|
23
|
+
for f in baseline.fixtures
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
fixture_snapshots = build_fixture_snapshots(skill.capability_surface, fixtures)
|
|
27
|
+
|
|
28
|
+
return ReplayResult(
|
|
29
|
+
schema_version=1,
|
|
30
|
+
skill_name=skill.name,
|
|
31
|
+
skill_path=skill.skill_path,
|
|
32
|
+
replayed_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
33
|
+
full_capability_surface=skill.capability_surface,
|
|
34
|
+
fixtures=fixture_snapshots,
|
|
35
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reads and writes baseline (.evolveguard-baseline.json) and report
|
|
3
|
+
(evolveguard-report.json) files as pretty-printed, deterministic JSON, with
|
|
4
|
+
schema validation on read. Ported from src/evolveguard/report/index.ts
|
|
5
|
+
(which uses zod; this port validates by hand to avoid a schema-validation
|
|
6
|
+
dependency).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from ..errors import EvolveGuardError
|
|
13
|
+
from ..types import Baseline, EvolveGuardReport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_baseline(baseline_path: str, baseline: Baseline) -> None:
|
|
17
|
+
with open(baseline_path, "w", encoding="utf-8") as fh:
|
|
18
|
+
fh.write(json.dumps(baseline.to_dict(), indent=2) + "\n")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_baseline(baseline_path: str) -> Baseline:
|
|
22
|
+
try:
|
|
23
|
+
with open(baseline_path, "r", encoding="utf-8") as fh:
|
|
24
|
+
raw_text = fh.read()
|
|
25
|
+
except OSError as err:
|
|
26
|
+
raise EvolveGuardError(
|
|
27
|
+
f'Could not read baseline file at "{baseline_path}".',
|
|
28
|
+
str(err),
|
|
29
|
+
"Run `evolveguard record <SKILL.md> --fixtures <fixtures.json>` first to "
|
|
30
|
+
"create a baseline.",
|
|
31
|
+
) from err
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
parsed = json.loads(raw_text)
|
|
35
|
+
except json.JSONDecodeError as err:
|
|
36
|
+
raise EvolveGuardError(
|
|
37
|
+
f'Baseline file at "{baseline_path}" is not valid JSON.',
|
|
38
|
+
str(err),
|
|
39
|
+
"Re-run `evolveguard record` to regenerate the baseline file.",
|
|
40
|
+
) from err
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
if not isinstance(parsed, dict) or parsed.get("schemaVersion") != 1:
|
|
44
|
+
raise ValueError("missing or unexpected schemaVersion")
|
|
45
|
+
if not parsed.get("fixtures"):
|
|
46
|
+
raise ValueError("fixtures: expected a non-empty array")
|
|
47
|
+
baseline = Baseline.from_dict(parsed)
|
|
48
|
+
except (KeyError, ValueError, TypeError) as err:
|
|
49
|
+
raise EvolveGuardError(
|
|
50
|
+
f'Baseline file at "{baseline_path}" does not match the expected schema.',
|
|
51
|
+
str(err),
|
|
52
|
+
"Re-run `evolveguard record` to regenerate the baseline file, or check "
|
|
53
|
+
"it was not hand-edited.",
|
|
54
|
+
) from err
|
|
55
|
+
|
|
56
|
+
return baseline
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_report(report_path: str, report: EvolveGuardReport) -> None:
|
|
60
|
+
with open(report_path, "w", encoding="utf-8") as fh:
|
|
61
|
+
fh.write(json.dumps(report.to_dict(), indent=2) + "\n")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_report(report_path: str) -> EvolveGuardReport:
|
|
65
|
+
try:
|
|
66
|
+
with open(report_path, "r", encoding="utf-8") as fh:
|
|
67
|
+
raw_text = fh.read()
|
|
68
|
+
except OSError as err:
|
|
69
|
+
raise EvolveGuardError(
|
|
70
|
+
f'Could not read report file at "{report_path}".',
|
|
71
|
+
str(err),
|
|
72
|
+
"Run `evolveguard check <SKILL.md>` first to generate a report.",
|
|
73
|
+
) from err
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
parsed = json.loads(raw_text)
|
|
77
|
+
except json.JSONDecodeError as err:
|
|
78
|
+
raise EvolveGuardError(
|
|
79
|
+
f'Report file at "{report_path}" is not valid JSON.',
|
|
80
|
+
str(err),
|
|
81
|
+
"Re-run `evolveguard check` to regenerate the report file.",
|
|
82
|
+
) from err
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
if not isinstance(parsed, dict) or parsed.get("schemaVersion") != 1:
|
|
86
|
+
raise ValueError("missing or unexpected schemaVersion")
|
|
87
|
+
report = EvolveGuardReport.from_dict(parsed)
|
|
88
|
+
except (KeyError, ValueError, TypeError) as err:
|
|
89
|
+
raise EvolveGuardError(
|
|
90
|
+
f'Report file at "{report_path}" does not match the expected schema.',
|
|
91
|
+
str(err),
|
|
92
|
+
"Re-run `evolveguard check` to regenerate the report file.",
|
|
93
|
+
) from err
|
|
94
|
+
|
|
95
|
+
return report
|