skillops-cli 0.0.0-stage → 1.2.1
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/.github/agents/address-review.agent.md +14 -0
- package/.github/agents/docs-sync.agent.md +18 -0
- package/.github/agents/pr-brief.agent.md +24 -0
- package/.github/agents/skill-auditor.agent.md +22 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/SKILL_CONTRACT.md +146 -0
- package/catalog/ai-engineering/agent-harness-auditor/SKILL.md +44 -0
- package/catalog/ai-engineering/agent-harness-auditor/evals/benchmark.json +22 -0
- package/catalog/ai-engineering/agent-harness-auditor/evals/evals.json +40 -0
- package/catalog/ai-engineering/agent-harness-auditor/scripts/harness_linter.py +101 -0
- package/catalog/ai-engineering/context-pruner-advisor/SKILL.md +44 -0
- package/catalog/ai-engineering/context-pruner-advisor/evals/benchmark.json +22 -0
- package/catalog/ai-engineering/context-pruner-advisor/evals/evals.json +40 -0
- package/catalog/ai-engineering/context-pruner-advisor/scripts/context_pruner.py +114 -0
- package/catalog/ai-engineering/mcp-architect/SKILL.md +45 -0
- package/catalog/ai-engineering/mcp-architect/evals/benchmark.json +22 -0
- package/catalog/ai-engineering/mcp-architect/evals/evals.json +54 -0
- package/catalog/ai-engineering/mcp-architect/scripts/validate_mcp_server.py +156 -0
- package/catalog/ai-engineering/prompt-red-teaming/SKILL.md +43 -0
- package/catalog/ai-engineering/prompt-red-teaming/evals/benchmark.json +22 -0
- package/catalog/ai-engineering/prompt-red-teaming/evals/evals.json +40 -0
- package/catalog/ai-engineering/prompt-red-teaming/scripts/injection_scanner.py +79 -0
- package/catalog/arch-quality/ast-blast-radius/SKILL.md +41 -0
- package/catalog/arch-quality/ast-blast-radius/evals/benchmark.json +22 -0
- package/catalog/arch-quality/ast-blast-radius/evals/evals.json +40 -0
- package/catalog/arch-quality/ast-blast-radius/scripts/analyze_call_graph.py +114 -0
- package/catalog/arch-quality/bug-reproducer-tdd/SKILL.md +42 -0
- package/catalog/arch-quality/bug-reproducer-tdd/evals/benchmark.json +22 -0
- package/catalog/arch-quality/bug-reproducer-tdd/evals/evals.json +40 -0
- package/catalog/arch-quality/bug-reproducer-tdd/scripts/repro_harness_check.py +129 -0
- package/catalog/arch-quality/security-sast-gate/SKILL.md +42 -0
- package/catalog/arch-quality/security-sast-gate/evals/benchmark.json +22 -0
- package/catalog/arch-quality/security-sast-gate/evals/evals.json +40 -0
- package/catalog/arch-quality/security-sast-gate/scripts/owasp_ast_scanner.py +153 -0
- package/catalog/python-backend/concurrency-race-detector/SKILL.md +43 -0
- package/catalog/python-backend/concurrency-race-detector/evals/benchmark.json +22 -0
- package/catalog/python-backend/concurrency-race-detector/evals/evals.json +40 -0
- package/catalog/python-backend/concurrency-race-detector/scripts/event_loop_blocking_scanner.py +127 -0
- package/catalog/python-backend/fastapi-production/SKILL.md +44 -0
- package/catalog/python-backend/fastapi-production/evals/benchmark.json +22 -0
- package/catalog/python-backend/fastapi-production/evals/evals.json +54 -0
- package/catalog/python-backend/fastapi-production/scripts/audit_fastapi_routes.py +169 -0
- package/catalog/python-backend/postgres-query-doctor/SKILL.md +44 -0
- package/catalog/python-backend/postgres-query-doctor/evals/benchmark.json +22 -0
- package/catalog/python-backend/postgres-query-doctor/evals/evals.json +54 -0
- package/catalog/python-backend/postgres-query-doctor/scripts/analyze_sql_safety.py +130 -0
- package/catalog/subagents/blast-radius-auditor/SUBAGENT.md +26 -0
- package/catalog/subagents/context-scout/SUBAGENT.md +23 -0
- package/catalog/subagents/red-team-breaker/SUBAGENT.md +22 -0
- package/catalog/subagents/tdd-reproducer-agent/SUBAGENT.md +23 -0
- package/catalog/subagents/token-budget-governor/SUBAGENT.md +26 -0
- package/dist/cli.js +4578 -0
- package/dist/index.d.ts +2039 -0
- package/dist/index.js +4129 -0
- package/docs/BENCHMARKS.md +82 -0
- package/package.json +84 -4
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic OWASP SAST scanner for Python AST sinks and hardcoded credentials."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import ast
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
DANGEROUS_CALLS: dict[str, tuple[str, str]] = {
|
|
14
|
+
"eval": ("ARBITRARY_CODE_EVAL", "Never use eval() on untrusted input; use ast.literal_eval() or structured parsing."),
|
|
15
|
+
"exec": ("ARBITRARY_CODE_EXEC", "Never use exec() in application code."),
|
|
16
|
+
"os.system": ("OS_COMMAND_INJECTION", "Replace os.system() with subprocess.run([...], shell=False, check=True)."),
|
|
17
|
+
"pickle.loads": ("INSECURE_DESERIALIZATION", "Never deserialize untrusted payloads with pickle.loads(); use JSON/Pydantic."),
|
|
18
|
+
"pickle.load": ("INSECURE_DESERIALIZATION", "Never deserialize untrusted streams with pickle.load(); use JSON/Pydantic."),
|
|
19
|
+
"hashlib.md5": ("WEAK_CRYPTO_HASH", "Replace broken MD5 digest with hashlib.sha256()."),
|
|
20
|
+
"hashlib.sha1": ("WEAK_CRYPTO_HASH", "Replace weak SHA-1 digest with hashlib.sha256()."),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
SECRET_VAR_KEYWORDS = ("api_key", "secret_key", "password", "private_key", "access_token")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _call_name(node: ast.Call) -> str:
|
|
27
|
+
if isinstance(node.func, ast.Name):
|
|
28
|
+
return node.func.id
|
|
29
|
+
if isinstance(node.func, ast.Attribute):
|
|
30
|
+
parts: list[str] = [node.func.attr]
|
|
31
|
+
curr: ast.AST = node.func.value
|
|
32
|
+
while isinstance(curr, ast.Attribute):
|
|
33
|
+
parts.append(curr.attr)
|
|
34
|
+
curr = curr.value
|
|
35
|
+
if isinstance(curr, ast.Name):
|
|
36
|
+
parts.append(curr.id)
|
|
37
|
+
return ".".join(reversed(parts))
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def scan_python_file(file_path: Path) -> list[dict[str, Any]]:
|
|
42
|
+
"""Scan a Python module for OWASP AST security vulnerabilities."""
|
|
43
|
+
findings: list[dict[str, Any]] = []
|
|
44
|
+
try:
|
|
45
|
+
source = file_path.read_text(encoding="utf-8")
|
|
46
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
47
|
+
except (OSError, SyntaxError) as exc:
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
"rule": "SYNTAX_OR_IO_ERROR",
|
|
51
|
+
"severity": "error",
|
|
52
|
+
"file": file_path.as_posix(),
|
|
53
|
+
"line": 1,
|
|
54
|
+
"message": str(exc),
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
for node in ast.walk(tree):
|
|
59
|
+
if isinstance(node, ast.Call):
|
|
60
|
+
cname = _call_name(node)
|
|
61
|
+
if cname in DANGEROUS_CALLS:
|
|
62
|
+
rule_id, msg = DANGEROUS_CALLS[cname]
|
|
63
|
+
findings.append(
|
|
64
|
+
{
|
|
65
|
+
"rule": rule_id,
|
|
66
|
+
"severity": "error",
|
|
67
|
+
"file": file_path.as_posix(),
|
|
68
|
+
"line": node.lineno,
|
|
69
|
+
"call": cname,
|
|
70
|
+
"message": msg,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
if cname.startswith("subprocess."):
|
|
74
|
+
for kw in node.keywords:
|
|
75
|
+
if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
76
|
+
findings.append(
|
|
77
|
+
{
|
|
78
|
+
"rule": "SUBPROCESS_SHELL_TRUE",
|
|
79
|
+
"severity": "error",
|
|
80
|
+
"file": file_path.as_posix(),
|
|
81
|
+
"line": node.lineno,
|
|
82
|
+
"call": cname,
|
|
83
|
+
"message": "Never pass shell=True to subprocess calls; pass an explicit argv list.",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
if cname == "yaml.load":
|
|
87
|
+
has_safe_loader = any(
|
|
88
|
+
kw.arg == "Loader" and "SafeLoader" in (ast.unparse(kw.value) if hasattr(ast, "unparse") else "")
|
|
89
|
+
for kw in node.keywords
|
|
90
|
+
)
|
|
91
|
+
if not has_safe_loader:
|
|
92
|
+
findings.append(
|
|
93
|
+
{
|
|
94
|
+
"rule": "UNSAFE_YAML_LOAD",
|
|
95
|
+
"severity": "error",
|
|
96
|
+
"file": file_path.as_posix(),
|
|
97
|
+
"line": node.lineno,
|
|
98
|
+
"call": cname,
|
|
99
|
+
"message": "Use yaml.safe_load() instead of yaml.load() without SafeLoader.",
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
elif isinstance(node, ast.Assign):
|
|
103
|
+
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) and len(node.value.value) >= 8:
|
|
104
|
+
for target in node.targets:
|
|
105
|
+
if isinstance(target, ast.Name) and any(k in target.id.lower() for k in SECRET_VAR_KEYWORDS):
|
|
106
|
+
findings.append(
|
|
107
|
+
{
|
|
108
|
+
"rule": "HARDCODED_SECRET_LITERAL",
|
|
109
|
+
"severity": "error",
|
|
110
|
+
"file": file_path.as_posix(),
|
|
111
|
+
"line": node.lineno,
|
|
112
|
+
"variable": target.id,
|
|
113
|
+
"message": f"Hardcoded credential assigned to '{target.id}'; load from environment variables.",
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
return findings
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def analyze_target(target_path: Path) -> dict[str, Any]:
|
|
120
|
+
files = [target_path] if target_path.is_file() else sorted(target_path.rglob("*.py"))
|
|
121
|
+
findings: list[dict[str, Any]] = []
|
|
122
|
+
for f in files:
|
|
123
|
+
findings.extend(scan_python_file(f))
|
|
124
|
+
return {
|
|
125
|
+
"status": "error" if findings else "ok",
|
|
126
|
+
"target": target_path.as_posix(),
|
|
127
|
+
"files_scanned": len(files),
|
|
128
|
+
"finding_count": len(findings),
|
|
129
|
+
"findings": findings,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv: list[str] | None = None) -> int:
|
|
134
|
+
parser = argparse.ArgumentParser(description="Run deterministic OWASP SAST AST scan.")
|
|
135
|
+
parser.add_argument("--path", required=True, type=Path, help="Target Python file or directory.")
|
|
136
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON output.")
|
|
137
|
+
args = parser.parse_args(argv)
|
|
138
|
+
|
|
139
|
+
if not args.path.exists():
|
|
140
|
+
payload = {"status": "error", "message": f"Path not found: {args.path.as_posix()}"}
|
|
141
|
+
print(json.dumps(payload, indent=2))
|
|
142
|
+
return 2
|
|
143
|
+
|
|
144
|
+
report = analyze_target(args.path)
|
|
145
|
+
if args.json:
|
|
146
|
+
print(json.dumps(report, indent=2))
|
|
147
|
+
else:
|
|
148
|
+
print(f"[{report['status'].upper()}] {report['target']} ({report['finding_count']} findings)")
|
|
149
|
+
return 1 if report["status"] == "error" else 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
sys.exit(main())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "concurrency-race-detector"
|
|
3
|
+
description: "Use when debugging or authoring asynchronous Python coroutines, background worker pools, and concurrent task pipelines to detect event-loop blocking synchronous calls, orphaned fire-and-forget tasks, lock acquisition deadlocks, unbounded gather fan-out starvation, and non-idempotent retry storms across high-throughput distributed asyncio services before deploying background workers to production."
|
|
4
|
+
version: "1.0.0"
|
|
5
|
+
track: "python-backend"
|
|
6
|
+
tags:
|
|
7
|
+
- "asyncio"
|
|
8
|
+
- "concurrency"
|
|
9
|
+
- "race-conditions"
|
|
10
|
+
- "event-loop"
|
|
11
|
+
- "deadlocks"
|
|
12
|
+
routing_signals:
|
|
13
|
+
languages: ["python"]
|
|
14
|
+
files_any: ["pyproject.toml", "workers.py", "tasks.py"]
|
|
15
|
+
dependencies_any: ["anyio", "aiohttp", "celery", "redis"]
|
|
16
|
+
negative_triggers:
|
|
17
|
+
- "Do not trigger for synchronous CLI scripts, static SQL DDL migrations, or pure CSS layouts."
|
|
18
|
+
entrypoints:
|
|
19
|
+
scripts:
|
|
20
|
+
- path: "scripts/event_loop_blocking_scanner.py"
|
|
21
|
+
description: "AST scanner detecting synchronous blocking calls and untracked tasks inside async def coroutines."
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# Concurrency & Race Condition Detector (`concurrency-race-detector`)
|
|
25
|
+
|
|
26
|
+
## When to Use This Skill
|
|
27
|
+
Invoke this skill when authoring or troubleshooting `asyncio` coroutines, concurrent task groups, distributed locks, or background worker queues to prevent event-loop stalls, silent task garbage collection, and race conditions.
|
|
28
|
+
|
|
29
|
+
## Mandatory Execution Workflow
|
|
30
|
+
|
|
31
|
+
1. **Run the Deterministic Event-Loop Scanner First:**
|
|
32
|
+
```bash
|
|
33
|
+
python catalog/python-backend/concurrency-race-detector/scripts/event_loop_blocking_scanner.py --path <target_dir_or_file> --json
|
|
34
|
+
```
|
|
35
|
+
2. **Eliminate Synchronous Event-Loop Blockers:**
|
|
36
|
+
- Never invoke `time.sleep()`, `requests.*`, `urllib.request.*`, synchronous `open()`, or `subprocess.run()` directly inside an `async def` coroutine.
|
|
37
|
+
- Replace with `await asyncio.sleep()`, `httpx.AsyncClient`, or offload CPU/blocking I/O via `await asyncio.to_thread(...)`.
|
|
38
|
+
3. **Prevent Orphaned Fire-and-Forget Tasks:**
|
|
39
|
+
- Never call `asyncio.create_task(coro())` without storing a strong reference (e.g., `background_tasks.add(task); task.add_done_callback(background_tasks.discard)`) or using `asyncio.TaskGroup()`.
|
|
40
|
+
4. **Enforce Bounded Concurrency & Lock Ordering:**
|
|
41
|
+
- Guard fan-out `asyncio.gather` calls with `asyncio.Semaphore(limit)` and wrap external awaits in `asyncio.timeout(seconds)`.
|
|
42
|
+
5. **Re-Verify Concurrency Safety:**
|
|
43
|
+
- Re-run `event_loop_blocking_scanner.py` and confirm `"status": "ok"` with `0` blocking hazards.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "concurrency-race-detector",
|
|
3
|
+
"runner": "deterministic-ast",
|
|
4
|
+
"timestamp": "2026-10-30T15:00:00Z",
|
|
5
|
+
"with_skill": {
|
|
6
|
+
"pass_rate_mean": 0.95,
|
|
7
|
+
"pass_rate_stddev": 0.03,
|
|
8
|
+
"tokens_mean": 1140,
|
|
9
|
+
"latency_ms_mean": 2050
|
|
10
|
+
},
|
|
11
|
+
"without_skill": {
|
|
12
|
+
"pass_rate_mean": 0.56,
|
|
13
|
+
"pass_rate_stddev": 0.11,
|
|
14
|
+
"tokens_mean": 2360,
|
|
15
|
+
"latency_ms_mean": 4200
|
|
16
|
+
},
|
|
17
|
+
"delta": {
|
|
18
|
+
"pass_rate_gain": 0.39,
|
|
19
|
+
"token_reduction_pct": -51.69,
|
|
20
|
+
"verdict": "CERTIFIED_IMPROVEMENT"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "concurrency-race-detector",
|
|
3
|
+
"schema_version": "1.0",
|
|
4
|
+
"evals": [
|
|
5
|
+
{
|
|
6
|
+
"id": "eval-01-detect-blocking-calls-in-async",
|
|
7
|
+
"name": "Eliminate synchronous time.sleep and requests calls inside async def",
|
|
8
|
+
"prompt": "Audit `src/workers/dispatcher.py` for event-loop stalls where `time.sleep` and `requests.post` are called inside an `async def` worker.",
|
|
9
|
+
"fixtures": [],
|
|
10
|
+
"assertions": [
|
|
11
|
+
{
|
|
12
|
+
"id": "assert-ran-blocking-scanner",
|
|
13
|
+
"type": "tool_call_contains",
|
|
14
|
+
"target": "event_loop_blocking_scanner.py",
|
|
15
|
+
"description": "Agent executed the deterministic event-loop blocking AST scanner."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "assert-async-sleep-or-to-thread",
|
|
19
|
+
"type": "regex_match",
|
|
20
|
+
"target": "asyncio\\.(sleep|to_thread)",
|
|
21
|
+
"description": "Replaced blocking calls with non-blocking asyncio primitives."
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "eval-02-prevent-orphaned-tasks",
|
|
27
|
+
"name": "Store strong references or use TaskGroup for background tasks",
|
|
28
|
+
"prompt": "Fix the fire-and-forget `asyncio.create_task(send_audit_event())` call so tasks are never garbage-collected mid-flight.",
|
|
29
|
+
"fixtures": [],
|
|
30
|
+
"assertions": [
|
|
31
|
+
{
|
|
32
|
+
"id": "assert-task-group-or-reference-set",
|
|
33
|
+
"type": "regex_match",
|
|
34
|
+
"target": "(TaskGroup|add_done_callback)",
|
|
35
|
+
"description": "Used asyncio.TaskGroup or a strong reference set with add_done_callback."
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
package/catalog/python-backend/concurrency-race-detector/scripts/event_loop_blocking_scanner.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic AST scanner for asyncio event-loop blocking calls and orphaned tasks."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import ast
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
BLOCKING_CALLS: dict[str, str] = {
|
|
14
|
+
"time.sleep": "Replace time.sleep() with await asyncio.sleep() inside async def.",
|
|
15
|
+
"requests.get": "Replace synchronous requests.get() with httpx.AsyncClient.",
|
|
16
|
+
"requests.post": "Replace synchronous requests.post() with httpx.AsyncClient.",
|
|
17
|
+
"requests.put": "Replace synchronous requests.put() with httpx.AsyncClient.",
|
|
18
|
+
"requests.delete": "Replace synchronous requests.delete() with httpx.AsyncClient.",
|
|
19
|
+
"subprocess.run": "Replace subprocess.run() with asyncio.create_subprocess_exec() or asyncio.to_thread().",
|
|
20
|
+
"subprocess.check_output": "Offload blocking subprocess calls via asyncio.to_thread().",
|
|
21
|
+
"urllib.request.urlopen": "Replace urllib.request.urlopen() with an async HTTP client.",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _call_name(node: ast.Call) -> str:
|
|
26
|
+
if isinstance(node.func, ast.Name):
|
|
27
|
+
return node.func.id
|
|
28
|
+
if isinstance(node.func, ast.Attribute):
|
|
29
|
+
parts: list[str] = [node.func.attr]
|
|
30
|
+
curr: ast.AST = node.func.value
|
|
31
|
+
while isinstance(curr, ast.Attribute):
|
|
32
|
+
parts.append(curr.attr)
|
|
33
|
+
curr = curr.value
|
|
34
|
+
if isinstance(curr, ast.Name):
|
|
35
|
+
parts.append(curr.id)
|
|
36
|
+
return ".".join(reversed(parts))
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def scan_python_file(file_path: Path) -> list[dict[str, Any]]:
|
|
41
|
+
"""Scan a Python file for event-loop blocking calls and orphaned create_task invocations."""
|
|
42
|
+
findings: list[dict[str, Any]] = []
|
|
43
|
+
try:
|
|
44
|
+
source = file_path.read_text(encoding="utf-8")
|
|
45
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
46
|
+
except (OSError, SyntaxError) as exc:
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
"rule": "SYNTAX_OR_IO_ERROR",
|
|
50
|
+
"severity": "error",
|
|
51
|
+
"file": file_path.as_posix(),
|
|
52
|
+
"line": 1,
|
|
53
|
+
"message": str(exc),
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
for node in ast.walk(tree):
|
|
58
|
+
if isinstance(node, ast.AsyncFunctionDef):
|
|
59
|
+
for child in ast.walk(node):
|
|
60
|
+
if isinstance(child, ast.Expr) and isinstance(child.value, ast.Call):
|
|
61
|
+
cname = _call_name(child.value)
|
|
62
|
+
if cname in ("asyncio.create_task", "create_task"):
|
|
63
|
+
findings.append(
|
|
64
|
+
{
|
|
65
|
+
"rule": "ORPHANED_ASYNC_TASK",
|
|
66
|
+
"severity": "error",
|
|
67
|
+
"file": file_path.as_posix(),
|
|
68
|
+
"line": child.lineno,
|
|
69
|
+
"function": node.name,
|
|
70
|
+
"message": (
|
|
71
|
+
"Unreferenced asyncio.create_task() may be garbage-collected mid-execution; "
|
|
72
|
+
"assign the task reference or use asyncio.TaskGroup()."
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
if isinstance(child, ast.Call):
|
|
77
|
+
cname = _call_name(child)
|
|
78
|
+
if cname in BLOCKING_CALLS:
|
|
79
|
+
findings.append(
|
|
80
|
+
{
|
|
81
|
+
"rule": "EVENT_LOOP_BLOCKING_CALL",
|
|
82
|
+
"severity": "error",
|
|
83
|
+
"file": file_path.as_posix(),
|
|
84
|
+
"line": child.lineno,
|
|
85
|
+
"function": node.name,
|
|
86
|
+
"call": cname,
|
|
87
|
+
"message": BLOCKING_CALLS[cname],
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
return findings
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def analyze_target(target_path: Path) -> dict[str, Any]:
|
|
94
|
+
files = [target_path] if target_path.is_file() else sorted(target_path.rglob("*.py"))
|
|
95
|
+
findings: list[dict[str, Any]] = []
|
|
96
|
+
for f in files:
|
|
97
|
+
findings.extend(scan_python_file(f))
|
|
98
|
+
return {
|
|
99
|
+
"status": "error" if findings else "ok",
|
|
100
|
+
"target": target_path.as_posix(),
|
|
101
|
+
"files_scanned": len(files),
|
|
102
|
+
"finding_count": len(findings),
|
|
103
|
+
"findings": findings,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: list[str] | None = None) -> int:
|
|
108
|
+
parser = argparse.ArgumentParser(description="Scan Python async coroutines for blocking hazards.")
|
|
109
|
+
parser.add_argument("--path", required=True, type=Path, help="Target Python file or directory.")
|
|
110
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON output.")
|
|
111
|
+
args = parser.parse_args(argv)
|
|
112
|
+
|
|
113
|
+
if not args.path.exists():
|
|
114
|
+
payload = {"status": "error", "message": f"Path not found: {args.path.as_posix()}"}
|
|
115
|
+
print(json.dumps(payload, indent=2))
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
report = analyze_target(args.path)
|
|
119
|
+
if args.json:
|
|
120
|
+
print(json.dumps(report, indent=2))
|
|
121
|
+
else:
|
|
122
|
+
print(f"[{report['status'].upper()}] {report['target']} ({report['finding_count']} findings)")
|
|
123
|
+
return 1 if report["status"] == "error" else 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
sys.exit(main())
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "fastapi-production"
|
|
3
|
+
description: "Use when building, refactoring, or reviewing async FastAPI REST endpoints and Pydantic v2 request or response pipelines to eliminate event-loop blocking calls inside async def routes, enforce explicit response_model contracts, configure lifespan dependency injection, and prevent wildcard CORS vulnerabilities in high-throughput production APIs."
|
|
4
|
+
version: "1.0.0"
|
|
5
|
+
track: "python-backend"
|
|
6
|
+
tags:
|
|
7
|
+
- "fastapi"
|
|
8
|
+
- "pydantic"
|
|
9
|
+
- "async-python"
|
|
10
|
+
- "rest-api"
|
|
11
|
+
- "backend"
|
|
12
|
+
routing_signals:
|
|
13
|
+
languages: ["python"]
|
|
14
|
+
files_any: ["pyproject.toml", "requirements.txt"]
|
|
15
|
+
dependencies_any: ["fastapi", "uvicorn", "starlette"]
|
|
16
|
+
negative_triggers:
|
|
17
|
+
- "Do not trigger for pure CLI scripts, frontend React components, or offline batch pandas jobs."
|
|
18
|
+
entrypoints:
|
|
19
|
+
scripts:
|
|
20
|
+
- path: "scripts/audit_fastapi_routes.py"
|
|
21
|
+
description: "Static AST auditor for FastAPI routers detecting blocking sync calls in async def, missing response contracts, and wildcard CORS."
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# FastAPI Production (`fastapi-production`)
|
|
25
|
+
|
|
26
|
+
## When to Use This Skill
|
|
27
|
+
Invoke this skill when designing, implementing, or reviewing **FastAPI** applications and routers (`APIRouter`, `FastAPI`) to guarantee non-blocking `asyncio` execution, strict Pydantic v2 serialization contracts, and secure middleware defaults.
|
|
28
|
+
|
|
29
|
+
## Mandatory Execution Workflow
|
|
30
|
+
|
|
31
|
+
1. **Run the Deterministic FastAPI Route Auditor First:**
|
|
32
|
+
```bash
|
|
33
|
+
python catalog/python-backend/fastapi-production/scripts/audit_fastapi_routes.py --path <target.py> --json
|
|
34
|
+
```
|
|
35
|
+
2. **Eliminate Blocking Calls Inside `async def` Endpoints:**
|
|
36
|
+
- Never invoke `time.sleep(...)`, `requests.get(...)`, or synchronous `subprocess.run(...)` directly inside an `async def` route handler—doing so stalls the entire Uvicorn event loop.
|
|
37
|
+
- Replace with `await asyncio.sleep(...)`, `httpx.AsyncClient`, or define pure synchronous I/O routes as `def` so FastAPI runs them in a worker threadpool.
|
|
38
|
+
3. **Declare Explicit Response Contracts:**
|
|
39
|
+
- Every `@router.get/post/put/delete/patch` endpoint must specify either `response_model=...` in the decorator or an explicit Python return type annotation (`-> ResponseSchema`).
|
|
40
|
+
4. **Harden CORS & Lifespan Configuration:**
|
|
41
|
+
- Never combine `allow_origins=["*"]` with `allow_credentials=True` in `CORSMiddleware`.
|
|
42
|
+
- Prefer `@asynccontextmanager` `lifespan` over deprecated `@app.on_event("startup")`.
|
|
43
|
+
5. **Re-Verify with the Bundled Script:**
|
|
44
|
+
Re-run `audit_fastapi_routes.py --path <target.py> --json` and verify `"status": "ok"`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "fastapi-production",
|
|
3
|
+
"runner": "deterministic-ast",
|
|
4
|
+
"timestamp": "2026-10-29T14:00:00Z",
|
|
5
|
+
"with_skill": {
|
|
6
|
+
"pass_rate_mean": 0.94,
|
|
7
|
+
"pass_rate_stddev": 0.05,
|
|
8
|
+
"tokens_mean": 3110,
|
|
9
|
+
"latency_ms_mean": 3500
|
|
10
|
+
},
|
|
11
|
+
"without_skill": {
|
|
12
|
+
"pass_rate_mean": 0.58,
|
|
13
|
+
"pass_rate_stddev": 0.14,
|
|
14
|
+
"tokens_mean": 5840,
|
|
15
|
+
"latency_ms_mean": 6900
|
|
16
|
+
},
|
|
17
|
+
"delta": {
|
|
18
|
+
"pass_rate_gain": 0.36,
|
|
19
|
+
"token_reduction_pct": -46.75,
|
|
20
|
+
"verdict": "CERTIFIED_IMPROVEMENT"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "fastapi-production",
|
|
3
|
+
"schema_version": "1.0",
|
|
4
|
+
"evals": [
|
|
5
|
+
{
|
|
6
|
+
"id": "eval-01-async-blocking-io",
|
|
7
|
+
"name": "Replace blocking requests.get and time.sleep inside async def route",
|
|
8
|
+
"prompt": "Audit our FastAPI router in `routes.py` where `async def fetch_profile()` uses `requests.get` and `time.sleep`, causing latency spikes under load.",
|
|
9
|
+
"fixtures": [],
|
|
10
|
+
"assertions": [
|
|
11
|
+
{
|
|
12
|
+
"id": "assert-ran-fastapi-audit-script",
|
|
13
|
+
"type": "tool_call_contains",
|
|
14
|
+
"target": "audit_fastapi_routes.py",
|
|
15
|
+
"description": "Agent executed the deterministic FastAPI AST auditor script."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "assert-non-blocking-async-client",
|
|
19
|
+
"type": "regex_match",
|
|
20
|
+
"target": "(httpx\\.AsyncClient|asyncio\\.sleep)",
|
|
21
|
+
"description": "Replaced blocking calls with non-blocking async primitives."
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "eval-02-explicit-response-model",
|
|
27
|
+
"name": "Enforce explicit Pydantic v2 response_model or return annotation",
|
|
28
|
+
"prompt": "Add a `POST /v1/deployments` endpoint to our FastAPI router with strict Pydantic v2 response serialization.",
|
|
29
|
+
"fixtures": [],
|
|
30
|
+
"assertions": [
|
|
31
|
+
{
|
|
32
|
+
"id": "assert-response-contract-present",
|
|
33
|
+
"type": "regex_match",
|
|
34
|
+
"target": "(response_model=|->\\s*[A-Z][A-Za-z0-9_]+)",
|
|
35
|
+
"description": "Endpoint declares explicit response_model or return type annotation."
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "eval-03-cors-origin-hardening",
|
|
41
|
+
"name": "Remediate wildcard allow_origins=['*'] in CORSMiddleware",
|
|
42
|
+
"prompt": "Review `main.py` CORS configuration and harden it for production deployment.",
|
|
43
|
+
"fixtures": [],
|
|
44
|
+
"assertions": [
|
|
45
|
+
{
|
|
46
|
+
"id": "assert-explicit-cors-origins",
|
|
47
|
+
"type": "regex_match",
|
|
48
|
+
"target": "allow_origins=\\[(?!\\s*[\"']\\*[\"'])",
|
|
49
|
+
"description": "Wildcard origin was replaced with explicit allowed origins."
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|