agent-engineering-skills 1.0.0
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.
- package/AGENTS.md +249 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.js +223 -0
- package/docs/agent-integration.md +200 -0
- package/docs/philosophy.md +131 -0
- package/docs/reference-authoring.md +117 -0
- package/docs/skill-authoring.md +126 -0
- package/examples/authorization-bypass.md +191 -0
- package/examples/frontend-review.md +244 -0
- package/examples/race-condition.md +128 -0
- package/examples/xp-reward-loop.md +123 -0
- package/package.json +45 -0
- package/references/engineering.yaml +88 -0
- package/references/frontend.yaml +139 -0
- package/references/product.yaml +54 -0
- package/references/research.yaml +88 -0
- package/references/security.yaml +85 -0
- package/references/ux.yaml +37 -0
- package/scripts/validate.py +454 -0
- package/skills/audit/adversarial-review/SKILL.md +190 -0
- package/skills/audit/business-logic-audit/SKILL.md +182 -0
- package/skills/audit/edge-case-hunter/SKILL.md +159 -0
- package/skills/audit/error-flow-audit/SKILL.md +184 -0
- package/skills/audit/state-consistency-audit/SKILL.md +174 -0
- package/skills/audit/user-flow-audit/SKILL.md +161 -0
- package/skills/frontend/accessibility-review/SKILL.md +186 -0
- package/skills/frontend/animation-review/SKILL.md +171 -0
- package/skills/frontend/interaction-design/SKILL.md +162 -0
- package/skills/frontend/ux-review/SKILL.md +172 -0
- package/skills/frontend/visual-quality-review/SKILL.md +160 -0
- package/skills/meta/research-router/SKILL.md +184 -0
- package/skills/meta/skill-router/SKILL.md +206 -0
- package/skills/product/gamification-audit/SKILL.md +213 -0
- package/skills/reliability/data-integrity-audit/SKILL.md +187 -0
- package/skills/reliability/idempotency-audit/SKILL.md +191 -0
- package/skills/reliability/race-condition-hunter/SKILL.md +181 -0
- package/skills/research/github-reference-research/SKILL.md +197 -0
- package/skills/research/implementation-research/SKILL.md +181 -0
- package/skills/research/market-research/SKILL.md +202 -0
- package/skills/research/reference-research/SKILL.md +186 -0
- package/skills/security/api-abuse-audit/SKILL.md +178 -0
- package/skills/security/authorization-audit/SKILL.md +176 -0
- package/skills/security/input-trust-audit/SKILL.md +178 -0
- package/templates/audit-report.md +89 -0
- package/templates/bug-report.md +107 -0
- package/templates/design-review.md +122 -0
- package/templates/research-report.md +96 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate the agent-engineering-skills repository against its own contracts.
|
|
3
|
+
|
|
4
|
+
Checks:
|
|
5
|
+
1. Every skills/**/SKILL.md has valid frontmatter (name, description, category,
|
|
6
|
+
triggers, priority) and all 9 mandated body sections in order.
|
|
7
|
+
2. name matches the directory; category is valid; priority is low|medium|high.
|
|
8
|
+
3. Every references/*.yaml parses, and each entry has the 7 required fields with
|
|
9
|
+
valid type/authority enums and matching category.
|
|
10
|
+
4. Cross-reference integrity: every skill name referenced in
|
|
11
|
+
skills/meta/skill-router/SKILL.md resolves to an existing SKILL.md.
|
|
12
|
+
|
|
13
|
+
Exit code 0 if all pass, 1 otherwise. Prints a summary.
|
|
14
|
+
|
|
15
|
+
No third-party dependencies — uses only the standard library.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Configuration
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
30
|
+
|
|
31
|
+
SKILLS_DIR = ROOT / "skills"
|
|
32
|
+
REFERENCES_DIR = ROOT / "references"
|
|
33
|
+
ROUTER = SKILLS_DIR / "meta" / "skill-router" / "SKILL.md"
|
|
34
|
+
|
|
35
|
+
VALID_CATEGORIES = {
|
|
36
|
+
"audit", "security", "reliability", "product",
|
|
37
|
+
"frontend", "research", "meta",
|
|
38
|
+
}
|
|
39
|
+
VALID_PRIORITIES = {"low", "medium", "high"}
|
|
40
|
+
|
|
41
|
+
VALID_REF_TYPES = {
|
|
42
|
+
"methodology", "heuristic", "inspiration", "implementation", "discovery",
|
|
43
|
+
}
|
|
44
|
+
VALID_AUTHORITIES = {"established", "community", "vendor", "curated"}
|
|
45
|
+
|
|
46
|
+
# All skill names defined by plan.md (the known universe). The router may reference
|
|
47
|
+
# skills from later milestones before they are implemented; references to names in
|
|
48
|
+
# this set that are not yet on disk are WARNINGS, not errors. A reference to a name
|
|
49
|
+
# in NEITHER this set NOR on disk is a real error (typo / unknown skill).
|
|
50
|
+
PLAN_SKILLS = {
|
|
51
|
+
# audit (core + deferred dead-end-flow-audit)
|
|
52
|
+
"adversarial-review", "user-flow-audit", "business-logic-audit",
|
|
53
|
+
"edge-case-hunter", "state-consistency-audit", "error-flow-audit",
|
|
54
|
+
"dead-end-flow-audit",
|
|
55
|
+
# security
|
|
56
|
+
"authorization-audit", "api-abuse-audit", "input-trust-audit",
|
|
57
|
+
# reliability
|
|
58
|
+
"race-condition-hunter", "idempotency-audit", "data-integrity-audit",
|
|
59
|
+
# product
|
|
60
|
+
"gamification-audit",
|
|
61
|
+
# frontend
|
|
62
|
+
"ux-review", "visual-quality-review", "interaction-design",
|
|
63
|
+
"animation-review", "accessibility-review",
|
|
64
|
+
# research
|
|
65
|
+
"reference-research", "github-reference-research", "market-research",
|
|
66
|
+
"implementation-research",
|
|
67
|
+
# meta
|
|
68
|
+
"skill-router", "research-router",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
REQUIRED_FRONTMATTER = ["name", "description", "category", "triggers", "priority"]
|
|
72
|
+
|
|
73
|
+
# The 9 mandated body sections, in order (docs/skill-authoring.md).
|
|
74
|
+
REQUIRED_SECTIONS = [
|
|
75
|
+
"Objective",
|
|
76
|
+
"When to Use",
|
|
77
|
+
"Mental Model",
|
|
78
|
+
"Investigation Procedure",
|
|
79
|
+
"Questions to Ask",
|
|
80
|
+
"Attack Patterns",
|
|
81
|
+
"Evidence Requirements",
|
|
82
|
+
"False Positives",
|
|
83
|
+
"Output Format",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
REQUIRED_REF_FIELDS = [
|
|
87
|
+
"name", "url", "type", "category",
|
|
88
|
+
"authority", "use_when", "avoid_when", "search_queries",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Minimal YAML subset parser
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# We avoid a PyYAML dependency. We only need: a top-level list of mappings where
|
|
95
|
+
# values are scalars or lists of scalars. Field order in a mapping is preserved by
|
|
96
|
+
# reading sequentially. This is intentionally narrow and strict — it rejects anything
|
|
97
|
+
# it cannot fully understand so it never silently passes malformed input.
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class YAMLError(ValueError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_scalar(token: str):
|
|
105
|
+
token = token.strip()
|
|
106
|
+
if len(token) >= 2 and token[0] == token[-1] and token[0] in ("'", '"'):
|
|
107
|
+
return token[1:-1]
|
|
108
|
+
return token
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_yaml_list(text: str) -> list[dict]:
|
|
112
|
+
"""Parse a YAML file that is a list of mappings (scalar / list-of-scalar values).
|
|
113
|
+
|
|
114
|
+
Handles two indentation shapes:
|
|
115
|
+
A) mapping item with nested list field:
|
|
116
|
+
- name: x
|
|
117
|
+
use_when:
|
|
118
|
+
- a
|
|
119
|
+
- b
|
|
120
|
+
B) list field at the same indent as its items (frontmatter uses this):
|
|
121
|
+
triggers:
|
|
122
|
+
- a
|
|
123
|
+
- b
|
|
124
|
+
"""
|
|
125
|
+
# Drop comments and blank lines, but keep indentation.
|
|
126
|
+
lines = []
|
|
127
|
+
for raw in text.splitlines():
|
|
128
|
+
stripped = raw.rstrip()
|
|
129
|
+
if not stripped.strip():
|
|
130
|
+
continue
|
|
131
|
+
if stripped.lstrip().startswith("#"):
|
|
132
|
+
continue
|
|
133
|
+
lines.append(stripped)
|
|
134
|
+
|
|
135
|
+
entries: list[dict] = []
|
|
136
|
+
current: dict | None = None
|
|
137
|
+
# The field currently collecting list items, and the indent at which those items
|
|
138
|
+
# are expected. None when not inside a list field.
|
|
139
|
+
list_field: str | None = None
|
|
140
|
+
list_indent: int | None = None
|
|
141
|
+
|
|
142
|
+
for line in lines:
|
|
143
|
+
indent = len(line) - len(line.lstrip())
|
|
144
|
+
body = line.strip()
|
|
145
|
+
|
|
146
|
+
is_list_marker = body == "-" or body.startswith("- ")
|
|
147
|
+
marker_payload = body[2:].strip() if body.startswith("- ") else ""
|
|
148
|
+
|
|
149
|
+
# Decide whether this line is a nested list item (belongs to list_field) or a
|
|
150
|
+
# new mapping field/item. A nested list item must be indented strictly deeper
|
|
151
|
+
# than the field's own line (shape A) OR at a deeper indent than the current
|
|
152
|
+
# mapping item while list_field is open (shape B for frontmatter).
|
|
153
|
+
nested_item = (
|
|
154
|
+
is_list_marker
|
|
155
|
+
and list_field is not None
|
|
156
|
+
and (list_indent is None or indent > list_indent)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if nested_item:
|
|
160
|
+
# "list_field" stays open; append the item. If the marker carries a
|
|
161
|
+
# "key: value", that's a mapping inside the list — not supported here.
|
|
162
|
+
if marker_payload and ":" in marker_payload:
|
|
163
|
+
raise YAMLError(
|
|
164
|
+
f"Mapping list items not supported: {body!r}"
|
|
165
|
+
)
|
|
166
|
+
if marker_payload:
|
|
167
|
+
current[list_field].append(_parse_scalar(marker_payload))
|
|
168
|
+
# bare "-" with payload on next line is unsupported; ignore empties.
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# Not a nested list item → this line either opens a list field, sets a scalar,
|
|
172
|
+
# or starts a new top-level mapping item. Close any open list field.
|
|
173
|
+
list_field = None
|
|
174
|
+
list_indent = None
|
|
175
|
+
|
|
176
|
+
if body.startswith("- "):
|
|
177
|
+
current = {}
|
|
178
|
+
entries.append(current)
|
|
179
|
+
rest = marker_payload
|
|
180
|
+
if ":" in rest:
|
|
181
|
+
key, _, val = rest.partition(":")
|
|
182
|
+
key = key.strip()
|
|
183
|
+
val = val.strip()
|
|
184
|
+
if val == "":
|
|
185
|
+
current[key] = []
|
|
186
|
+
list_field = key
|
|
187
|
+
list_indent = indent
|
|
188
|
+
else:
|
|
189
|
+
current[key] = _parse_scalar(val)
|
|
190
|
+
else:
|
|
191
|
+
raise YAMLError(f"Unexpected bare item at top level: {body!r}")
|
|
192
|
+
elif body == "-":
|
|
193
|
+
current = {}
|
|
194
|
+
entries.append(current)
|
|
195
|
+
elif ":" in body:
|
|
196
|
+
key, _, val = body.partition(":")
|
|
197
|
+
key = key.strip()
|
|
198
|
+
val = val.strip()
|
|
199
|
+
if current is None:
|
|
200
|
+
raise YAMLError(f"Key outside any item: {body!r}")
|
|
201
|
+
if val == "":
|
|
202
|
+
current[key] = []
|
|
203
|
+
list_field = key
|
|
204
|
+
list_indent = indent
|
|
205
|
+
else:
|
|
206
|
+
current[key] = _parse_scalar(val)
|
|
207
|
+
else:
|
|
208
|
+
raise YAMLError(f"Unexpected line: {body!r}")
|
|
209
|
+
|
|
210
|
+
return entries
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
214
|
+
"""Parse leading '---' YAML frontmatter. Returns (metadata, body)."""
|
|
215
|
+
if not text.startswith("---"):
|
|
216
|
+
raise YAMLError("Missing opening '---' frontmatter delimiter")
|
|
217
|
+
end = text.find("\n---", 3)
|
|
218
|
+
if end == -1:
|
|
219
|
+
raise YAMLError("Missing closing '---' frontmatter delimiter")
|
|
220
|
+
fm_text = text[3:end].strip()
|
|
221
|
+
body = text[end + 4:].lstrip("\n")
|
|
222
|
+
|
|
223
|
+
# Frontmatter is a single mapping (not a list). Parse as one item.
|
|
224
|
+
entries = parse_yaml_list("- \n" + fm_text) if fm_text else []
|
|
225
|
+
if not entries:
|
|
226
|
+
return {}, body
|
|
227
|
+
return entries[0], body
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Checks
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def find_skills() -> list[Path]:
|
|
236
|
+
if not SKILLS_DIR.is_dir():
|
|
237
|
+
return []
|
|
238
|
+
return sorted(SKILLS_DIR.rglob("SKILL.md"))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def check_skill(path: Path) -> list[str]:
|
|
242
|
+
"""Return a list of error strings for this SKILL.md (empty = ok)."""
|
|
243
|
+
errors: list[str] = []
|
|
244
|
+
rel = path.relative_to(ROOT)
|
|
245
|
+
text = path.read_text(encoding="utf-8")
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
meta, body = parse_frontmatter(text)
|
|
249
|
+
except YAMLError as e:
|
|
250
|
+
return [f"{rel}: frontmatter invalid: {e}"]
|
|
251
|
+
|
|
252
|
+
for field in REQUIRED_FRONTMATTER:
|
|
253
|
+
if field not in meta:
|
|
254
|
+
errors.append(f"{rel}: missing frontmatter field '{field}'")
|
|
255
|
+
|
|
256
|
+
# name == directory name (the skill dir, two levels up from SKILL.md)
|
|
257
|
+
skill_dir = path.parent.name
|
|
258
|
+
if "name" in meta and meta["name"] != skill_dir:
|
|
259
|
+
errors.append(
|
|
260
|
+
f"{rel}: frontmatter name {meta['name']!r} != directory {skill_dir!r}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if "category" in meta and meta["category"] not in VALID_CATEGORIES:
|
|
264
|
+
errors.append(
|
|
265
|
+
f"{rel}: invalid category {meta['category']!r} "
|
|
266
|
+
f"(valid: {sorted(VALID_CATEGORIES)})"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if "priority" in meta and meta["priority"] not in VALID_PRIORITIES:
|
|
270
|
+
errors.append(
|
|
271
|
+
f"{rel}: invalid priority {meta['priority']!r} "
|
|
272
|
+
f"(valid: {sorted(VALID_PRIORITIES)})"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if "triggers" in meta and not isinstance(meta["triggers"], list):
|
|
276
|
+
errors.append(f"{rel}: 'triggers' must be a list")
|
|
277
|
+
|
|
278
|
+
# 9 sections, in order, as '## Heading' (allow trailing text after the heading).
|
|
279
|
+
headings = re.findall(r"^##\s+(.+?)\s*$", body, flags=re.MULTILINE)
|
|
280
|
+
heading_names = [h.strip() for h in headings]
|
|
281
|
+
# The first '## Objective' etc. must appear in order. Allow extra '##' sections
|
|
282
|
+
# after, but the 9 required ones must be present in order.
|
|
283
|
+
idx = 0
|
|
284
|
+
for required in REQUIRED_SECTIONS:
|
|
285
|
+
# find the next heading equal to the required one at or after idx
|
|
286
|
+
found = False
|
|
287
|
+
for j in range(idx, len(heading_names)):
|
|
288
|
+
if heading_names[j] == required:
|
|
289
|
+
idx = j + 1
|
|
290
|
+
found = True
|
|
291
|
+
break
|
|
292
|
+
if not found:
|
|
293
|
+
errors.append(
|
|
294
|
+
f"{rel}: missing or out-of-order section '## {required}'"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return errors
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_references() -> list[str]:
|
|
301
|
+
errors: list[str] = []
|
|
302
|
+
if not REFERENCES_DIR.is_dir():
|
|
303
|
+
return [f"{REFERENCES_DIR.relative_to(ROOT)}: references/ directory missing"]
|
|
304
|
+
|
|
305
|
+
for yml in sorted(REFERENCES_DIR.glob("*.yaml")):
|
|
306
|
+
rel = yml.relative_to(ROOT)
|
|
307
|
+
expected_category = yml.stem # frontend.yaml → "frontend"
|
|
308
|
+
text = yml.read_text(encoding="utf-8")
|
|
309
|
+
try:
|
|
310
|
+
entries = parse_yaml_list(text)
|
|
311
|
+
except YAMLError as e:
|
|
312
|
+
errors.append(f"{rel}: YAML parse error: {e}")
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
if not entries:
|
|
316
|
+
errors.append(f"{rel}: no entries (empty catalog)")
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
for n, entry in enumerate(entries, 1):
|
|
320
|
+
for field in REQUIRED_REF_FIELDS:
|
|
321
|
+
if field not in entry:
|
|
322
|
+
errors.append(f"{rel}: entry #{n} missing field '{field}'")
|
|
323
|
+
|
|
324
|
+
if "type" in entry and entry["type"] not in VALID_REF_TYPES:
|
|
325
|
+
errors.append(
|
|
326
|
+
f"{rel}: entry #{n} invalid type {entry['type']!r} "
|
|
327
|
+
f"(valid: {sorted(VALID_REF_TYPES)})"
|
|
328
|
+
)
|
|
329
|
+
if "authority" in entry and entry["authority"] not in VALID_AUTHORITIES:
|
|
330
|
+
errors.append(
|
|
331
|
+
f"{rel}: entry #{n} invalid authority {entry['authority']!r} "
|
|
332
|
+
f"(valid: {sorted(VALID_AUTHORITIES)})"
|
|
333
|
+
)
|
|
334
|
+
if "category" in entry and entry["category"] != expected_category:
|
|
335
|
+
errors.append(
|
|
336
|
+
f"{rel}: entry #{n} category {entry['category']!r} != "
|
|
337
|
+
f"file category {expected_category!r}"
|
|
338
|
+
)
|
|
339
|
+
if "url" in entry:
|
|
340
|
+
url = str(entry["url"])
|
|
341
|
+
if not re.match(r"^https?://", url):
|
|
342
|
+
errors.append(f"{rel}: entry #{n} url not absolute: {url!r}")
|
|
343
|
+
for list_field in ("use_when", "avoid_when", "search_queries"):
|
|
344
|
+
if list_field in entry:
|
|
345
|
+
val = entry[list_field]
|
|
346
|
+
if not isinstance(val, list) or len(val) == 0:
|
|
347
|
+
errors.append(
|
|
348
|
+
f"{rel}: entry #{n} '{list_field}' must be a non-empty list"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return errors
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def collect_skill_names() -> set[str]:
|
|
355
|
+
names = set()
|
|
356
|
+
for path in find_skills():
|
|
357
|
+
names.add(path.parent.name)
|
|
358
|
+
return names
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def check_router_integrity() -> tuple[list[str], list[str]]:
|
|
362
|
+
"""Check skill references in skill-router/SKILL.md.
|
|
363
|
+
|
|
364
|
+
Returns (errors, warnings). A reference to a name that is neither on disk nor in
|
|
365
|
+
PLAN_SKILLS is an error (typo / unknown skill). A reference to a name in PLAN_SKILLS
|
|
366
|
+
but not yet implemented is a warning (later-milestone skill).
|
|
367
|
+
"""
|
|
368
|
+
errors: list[str] = []
|
|
369
|
+
warnings: list[str] = []
|
|
370
|
+
if not ROUTER.is_file():
|
|
371
|
+
return errors, warnings # router not built yet; skip
|
|
372
|
+
existing = collect_skill_names()
|
|
373
|
+
text = ROUTER.read_text(encoding="utf-8")
|
|
374
|
+
pattern = re.compile(r"\b([a-z]+-[a-z]+(?:-[a-z]+)*)\b")
|
|
375
|
+
candidates = set(pattern.findall(text))
|
|
376
|
+
known_suffixes = ("-audit", "-hunter", "-review", "-research", "-router",
|
|
377
|
+
"-design")
|
|
378
|
+
for cand in candidates:
|
|
379
|
+
# Only consider structural references: inside a code block, or a table cell.
|
|
380
|
+
structural = False
|
|
381
|
+
for block in re.findall(r"```.*?```", text, flags=re.DOTALL):
|
|
382
|
+
if cand in block:
|
|
383
|
+
structural = True
|
|
384
|
+
break
|
|
385
|
+
if re.search(rf"\|\s*{re.escape(cand)}\s*\|", text):
|
|
386
|
+
structural = True
|
|
387
|
+
if not structural:
|
|
388
|
+
continue
|
|
389
|
+
if not cand.endswith(known_suffixes):
|
|
390
|
+
continue
|
|
391
|
+
if cand in existing:
|
|
392
|
+
continue
|
|
393
|
+
if cand in PLAN_SKILLS:
|
|
394
|
+
warnings.append(
|
|
395
|
+
f"skill-router references '{cand}' — planned but not yet "
|
|
396
|
+
f"implemented (later milestone)"
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
errors.append(
|
|
400
|
+
f"skill-router references unknown skill '{cand}' "
|
|
401
|
+
f"(not in plan.md and not found under skills/*/)"
|
|
402
|
+
)
|
|
403
|
+
return errors, warnings
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# Main
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def main() -> int:
|
|
412
|
+
errors: list[str] = []
|
|
413
|
+
warnings: list[str] = []
|
|
414
|
+
|
|
415
|
+
skills = find_skills()
|
|
416
|
+
if not skills:
|
|
417
|
+
errors.append("No skills/**/SKILL.md files found")
|
|
418
|
+
for path in skills:
|
|
419
|
+
errors.extend(check_skill(path))
|
|
420
|
+
|
|
421
|
+
errors.extend(check_references())
|
|
422
|
+
e, w = check_router_integrity()
|
|
423
|
+
errors.extend(e)
|
|
424
|
+
warnings.extend(w)
|
|
425
|
+
|
|
426
|
+
# Summary
|
|
427
|
+
print(f"Skills found: {len(skills)}")
|
|
428
|
+
ref_count = 0
|
|
429
|
+
if REFERENCES_DIR.is_dir():
|
|
430
|
+
ref_count = len(list(REFERENCES_DIR.glob("*.yaml")))
|
|
431
|
+
print(f"Reference catalogs: {ref_count}")
|
|
432
|
+
print(f"Errors: {len(errors)}")
|
|
433
|
+
print(f"Warnings: {len(warnings)}")
|
|
434
|
+
print()
|
|
435
|
+
|
|
436
|
+
if warnings:
|
|
437
|
+
for msg in warnings:
|
|
438
|
+
print(f" ⚠ {msg}")
|
|
439
|
+
print()
|
|
440
|
+
|
|
441
|
+
if errors:
|
|
442
|
+
for e in errors:
|
|
443
|
+
print(f" ✗ {e}")
|
|
444
|
+
return 1
|
|
445
|
+
|
|
446
|
+
if warnings:
|
|
447
|
+
print("✓ All contracts satisfied (with planned-skill warnings above).")
|
|
448
|
+
else:
|
|
449
|
+
print("✓ All contracts satisfied.")
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
if __name__ == "__main__":
|
|
454
|
+
sys.exit(main())
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: adversarial-review
|
|
3
|
+
description: Teaches the agent to attack the system as curious, malicious, power, careless, competitor, and stale-state users using repeat, reverse, reorder, skip, replay, concurrent, and manipulate operations.
|
|
4
|
+
category: audit
|
|
5
|
+
triggers:
|
|
6
|
+
- "audit a feature"
|
|
7
|
+
- "test a flow adversarially"
|
|
8
|
+
- "find bugs by attacking assumptions"
|
|
9
|
+
- "review before shipping"
|
|
10
|
+
- "what could a malicious user do"
|
|
11
|
+
priority: high
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Adversarial Review
|
|
15
|
+
|
|
16
|
+
## Objective
|
|
17
|
+
|
|
18
|
+
Ensinar o agente a **atacar as suposições por trás do sistema** em vez de apenas ler o
|
|
19
|
+
código. A skill não procura o que está escrito; ela procura o que o código *assume* que
|
|
20
|
+
é verdade — e testa se essa suposição sobrevive a um usuário que não coopera.
|
|
21
|
+
|
|
22
|
+
> Don't just review the code. Attack the assumptions behind the system.
|
|
23
|
+
|
|
24
|
+
## When to Use
|
|
25
|
+
|
|
26
|
+
* Antes de lançar ou refatorar um fluxo não-trivial.
|
|
27
|
+
* Quando uma tarefa envolve estado, permissões, recompensas, dinheiro, ou contadores.
|
|
28
|
+
* Como a skill "guarda-chuva" que abre uma auditoria: ela gera hipóteses que as skills
|
|
29
|
+
especializadas (`business-logic-audit`, `race-condition-hunter`, etc.) confirmam.
|
|
30
|
+
* Quando o pedido inclui "audit", "attack", "stress test", "what could go wrong".
|
|
31
|
+
* **Composição:** raramente atua sozinha. Abre o leque: gera hipóteses que despacham
|
|
32
|
+
para `business-logic-audit`, `edge-case-hunter`, `race-condition-hunter`,
|
|
33
|
+
`state-consistency-audit`, `error-flow-audit`, `authorization-audit`,
|
|
34
|
+
`idempotency-audit`, `api-abuse-audit`.
|
|
35
|
+
|
|
36
|
+
## Mental Model
|
|
37
|
+
|
|
38
|
+
Existem dois erros simétricos ao revisar código:
|
|
39
|
+
|
|
40
|
+
1. **Ler como o autor esperava** — segue o happy path, vê o que *deveria* acontecer,
|
|
41
|
+
aprova. Falso negativo.
|
|
42
|
+
2. **Procurar bugs sem método** — acha coisas estranhas aleatórias, reporta ruído.
|
|
43
|
+
Falso positivo.
|
|
44
|
+
|
|
45
|
+
O modelo adversarial é uma terceira via: **adotar uma persona e aplicar um conjunto
|
|
46
|
+
finito de operações canônicas**. Cada (persona × operação) é uma hipótese testável.
|
|
47
|
+
Isto é sistemático, não aleatório, e gera hipóteses que se convertem em findings apenas
|
|
48
|
+
com evidência.
|
|
49
|
+
|
|
50
|
+
As personas são **modelos de uso**, não perfis de marketing. Cada uma representa uma
|
|
51
|
+
classe de pressão sobre uma suposição diferente:
|
|
52
|
+
|
|
53
|
+
| Persona | Qual suposição ela ataca |
|
|
54
|
+
|---|---|
|
|
55
|
+
| usuário curioso | "campos hidden / IDs / params são só display" |
|
|
56
|
+
| usuário malicioso | "o sistema confia que ninguém vai tentar X" |
|
|
57
|
+
| power user | "ninguém usa atalhos, reorder, ou bypass" |
|
|
58
|
+
| usuário descuidado | "todo mundo completa o fluxo na ordem certa" |
|
|
59
|
+
| usuário concorrente | "dois usuários não agirão sobre o mesmo recurso ao mesmo tempo" |
|
|
60
|
+
| usuário com estado antigo | "o estado do cliente/sessão está sempre sincronizado" |
|
|
61
|
+
|
|
62
|
+
As operações são verbos canônicos que transformam uma execução "normal" em um caso de
|
|
63
|
+
pressão:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
repeat — fazer a mesma ação N vezes
|
|
67
|
+
reverse — desfazer e refazer
|
|
68
|
+
reorder — executar passos fora da ordem esperada
|
|
69
|
+
skip — pular um passo que deveria ser obrigatório
|
|
70
|
+
replay — repetir um request idempotente-deveria-ser
|
|
71
|
+
concurrent — duas execuções sobre o mesmo estado ao mesmo tempo
|
|
72
|
+
manipulate — alterar IDs, campos, roles, timestamps direto no payload
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Investigation Procedure
|
|
76
|
+
|
|
77
|
+
1. **Mapear o fluxo nominal.** Liste os passos como o sistema *espera* que ocorram.
|
|
78
|
+
2. **Listar as suposições.** Para cada passo, escreva o que ele assume sobre o input,
|
|
79
|
+
o estado, e o usuário.
|
|
80
|
+
3. **Gerar hipóteses (persona × operação).** Para cada persona, aplique cada operação
|
|
81
|
+
aos passos. Não tente confirmar ainda — só gere a hipótese "se eu fizer X, e a
|
|
82
|
+
suposição Y for falsa, então Z".
|
|
83
|
+
4. **Triar por plausibilidade.** Descarte as obviamente impossíveis (sem caminho no
|
|
84
|
+
código). Priorize as que atacam uma suposição de servidor ou estado compartilhado.
|
|
85
|
+
5. **Confirmar com evidência.** Para cada hipótese sobrevivente, reproduza ou encontre
|
|
86
|
+
o mecanismo no código. Suba o nível de confiança só com evidência.
|
|
87
|
+
6. **Classificar falso positivo.** Para cada finding, verifique se o comportamento é
|
|
88
|
+
intencional/aceitável antes de reportar.
|
|
89
|
+
7. **Reportar** no formato de saída, apontando para `templates/audit-report.md`.
|
|
90
|
+
|
|
91
|
+
## Questions to Ask
|
|
92
|
+
|
|
93
|
+
* Quem é o público deste fluxo? Qual deles NÃO coopera com a UI?
|
|
94
|
+
* Quais campos o servidor aceita que a UI nem mostra? (`manipulate`)
|
|
95
|
+
* Se eu repetir essa ação 100 vezes, o que cresce que não deveria? (`repeat`)
|
|
96
|
+
* Se eu desfizer e refazer, ganho algo de volta que não devia? (`reverse`)
|
|
97
|
+
* Posso pular o passo de pré-condição e ir direto ao efeito? (`skip`)
|
|
98
|
+
* Posso chamar os passos fora de ordem? (`reorder`)
|
|
99
|
+
* Se dois requests simultâneos passam pela mesma checagem, ambos efetivam? (`concurrent`)
|
|
100
|
+
* O que acontece se eu refizer um request cuja resposta se perdeu? (`replay`)
|
|
101
|
+
* Qual estado o cliente guarda que pode ficar desatualizado vs o servidor? (`stale state`)
|
|
102
|
+
|
|
103
|
+
## Attack Patterns
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
repeat
|
|
107
|
+
request → 200 OK
|
|
108
|
+
request → 200 OK (deveria ser 409/idempotente?)
|
|
109
|
+
...
|
|
110
|
+
contador/reward inflado
|
|
111
|
+
|
|
112
|
+
reverse
|
|
113
|
+
action → reward granted
|
|
114
|
+
undo action (reward é removida?)
|
|
115
|
+
redo action → reward granted again
|
|
116
|
+
→ farming infinito se reward não foi removida OU se foi re-concedida
|
|
117
|
+
|
|
118
|
+
reorder
|
|
119
|
+
step C (efeito) antes do step A (pré-condição)
|
|
120
|
+
→ o efeito ocorre sem a pré-condição?
|
|
121
|
+
|
|
122
|
+
skip
|
|
123
|
+
POST /grant-directly (pulando o fluxo que valida)
|
|
124
|
+
→ a validação server-side cobre o caminho direto?
|
|
125
|
+
|
|
126
|
+
replay
|
|
127
|
+
request → 200 (resposta perdida na rede)
|
|
128
|
+
retry request
|
|
129
|
+
→ efeito duplicado?
|
|
130
|
+
|
|
131
|
+
concurrent
|
|
132
|
+
request A: read balance (ok)
|
|
133
|
+
request B: read balance (ok) ← mesmo estado lido
|
|
134
|
+
request A: write (deduct)
|
|
135
|
+
request B: write (deduct) ← double-spend
|
|
136
|
+
|
|
137
|
+
manipulate
|
|
138
|
+
GET /resource/123 → 200 (é meu? sou só autenticado, não autorizado)
|
|
139
|
+
PUT /resource/123 {role:"admin"} (campo extra aceito?)
|
|
140
|
+
POST /reward {xp: 99999} (valor confiável no payload?)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Evidence Requirements
|
|
144
|
+
|
|
145
|
+
Um finding de `adversarial-review` deve, no mínimo:
|
|
146
|
+
|
|
147
|
+
* **Nomear a suposição atacada** — qual invariant/assumption foi violada.
|
|
148
|
+
* **Nomear a persona e a operação** — qual combinação gerou a hipótese.
|
|
149
|
+
* **Mostrar o mecanismo** — onde no código a suposição é feita e onde falha. Ou
|
|
150
|
+
reprodução concreta (sequência de requests, passos).
|
|
151
|
+
* **Escalar confiança:**
|
|
152
|
+
* `CONFIRMED` — reproduzido (request sequência + resposta, ou teste).
|
|
153
|
+
* `HIGH CONFIDENCE` — mecanismo identificado no código, sem reprodução executada.
|
|
154
|
+
* `POSSIBLE` — hipótese plausível, caminho existe, mecanismo não confirmado.
|
|
155
|
+
* `SPECULATIVE` — "parece que poderia" sem caminho no código; reportar como risco.
|
|
156
|
+
|
|
157
|
+
Sem mecanismo e sem reprodução, no máximo `POSSIBLE`.
|
|
158
|
+
|
|
159
|
+
## False Positives
|
|
160
|
+
|
|
161
|
+
* **Rate limiting já protege** — repetição é bloqueada antes de efeito. Verificar se o
|
|
162
|
+
limite está em vigor antes de reportar `repeat` como defeito.
|
|
163
|
+
* **Idempotência real** — se o servidor usa idempotency key / unique constraint, `replay`
|
|
164
|
+
e `repeat` não duplicam. Confirmar a ausência da proteção antes de reportar.
|
|
165
|
+
* **Autorização server-side presente** — se o servidor valida ownership no handler,
|
|
166
|
+
`manipulate` de ID não funciona. Não reportar bypass sem confirmar que a checagem
|
|
167
|
+
falta.
|
|
168
|
+
* **Comportamento intencional** — algumas ações são *desenhadas* para serem repetíveis
|
|
169
|
+
ou reversíveis. Se o produto exige isso, não é bug. Quando em dúvida, marque como
|
|
170
|
+
`POSSIBLE` e levante na seção "Out of scope / precisa decisão de produto".
|
|
171
|
+
* **Ambiente de teste** — repetir em staging pode não refletir produção (locks, limits).
|
|
172
|
+
Reportar confiança reduzida se não puder testar em condições reais.
|
|
173
|
+
|
|
174
|
+
## Output Format
|
|
175
|
+
|
|
176
|
+
Para cada hipótese sobrevivente, produza um finding seguindo
|
|
177
|
+
`templates/audit-report.md`. Campos obrigatórios: Severity, Confidence, Affected
|
|
178
|
+
component, Affected flow, Reproduction, Expected behavior, Actual behavior, Root cause,
|
|
179
|
+
Impact, Recommendation.
|
|
180
|
+
|
|
181
|
+
Inclua adicionalmente em **Evidence**:
|
|
182
|
+
* a persona + operação que gerou a hipótese;
|
|
183
|
+
* a suposição exata que foi atacada;
|
|
184
|
+
* o mecanismo ou reprodução.
|
|
185
|
+
|
|
186
|
+
Findings `SPECULATIVE` vão em uma seção separada "Riscos a verificar", não na lista
|
|
187
|
+
principal de bugs.
|
|
188
|
+
|
|
189
|
+
Ao final, liste quais hipóteses foram **descartadas** e por quê — isto mostra a
|
|
190
|
+
cobertura e previne retrabalho.
|