Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta

### Changed

- **Loader:** `SkillLoader.load_skill()` validates manifest `requirements` version specifiers (for example `web3>=6.0.0`) against installed package versions before loading `skill.py`; unpinned entries still require importability only (#14).
- **GitHub**: New Skill Proposal template category dropdown synced with the registry (`creative`, `security`); added `creative` label (#279, #277).

## [0.4.8] - 2026-08-03
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ Defines the tool interface, safety constitution, dependencies, and issuer attrib
- `short_description` — optional one-line summary (~80 chars) shown in `skillware list` when present
- `parameters` — valid JSON Schema for LLM tool calling
- `constitution` — safety boundaries enforced at the prompt level
- `requirements` — when external packages are needed (for example `requests`, `pandas`)
- `requirements` — when external packages are needed (for example `requests`, `pandas`). Use PEP 508 strings; add version specifiers (for example `web3>=6.0.0`) when the skill depends on a minimum package version — `SkillLoader.load_skill()` validates pins at load time (see [Install extras](docs/usage/install_extras.md#loader-behavior)).

**Optional but common:**

Expand Down Expand Up @@ -308,6 +308,8 @@ Registry skills are shipped inside the `skillware` wheel. Per-skill layout uses
**Manifest `requirements` and optional extras**

- List runtime packages in the skill's `manifest.yaml` `requirements` (source of truth for loaders and docs).
- **Unpinned** entries (for example `requests`) — loader checks the importable module exists.
- **Pinned** entries (for example `rembg>=2.0.0`) — loader also verifies the installed distribution satisfies the specifier before `skill.py` runs. Pin when API or behavior breaks across versions; unpinned is fine for stable deps.
- Run `python scripts/sync_extras.py` after changing manifests — it regenerates category, per-skill, and `[all]` rows in `pyproject.toml` (see [Install extras](docs/usage/install_extras.md)).
- Core already includes `requests`, `pyyaml`, and `beautifulsoup4` (manifests may say `bs4`); the sync script omits core packages from extras automatically.
- Hand-maintained extras (`dev`, `gemini`, `claude`, `openai`, `agents`) stay above the generated block in `pyproject.toml`.
Expand Down
2 changes: 1 addition & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ pip install -r requirements.txt
| :--- | :--- | :--- |
| Manifest + execute contract for one skill | Bundle test | `skills/compliance/tos_evaluator/test_skill.py` |
| Loader path + mocked externals (optional depth) | Maintainer test | `tests/skills/compliance/test_tos_evaluator.py` |
| Loader, CLI, registry issuer rules, param validation | Framework test | `tests/test_loader.py`, `tests/test_skill_issuer.py`, `tests/test_validate_params.py`, `tests/test_registry_docs.py`, `tests/test_card_ui_schema.py` |
| Loader, CLI, registry issuer rules, param validation, manifest requirement pins | Framework test | `tests/test_loader.py`, `tests/test_requirements_check.py`, `tests/test_skill_issuer.py`, `tests/test_validate_params.py`, `tests/test_registry_docs.py`, `tests/test_card_ui_schema.py` |
| End-to-end provider demo script | Usage example | `examples/gemini_tos_evaluator.py` |

**Rule of thumb:** if it ships with the skill and must pass before merge → **bundle test** (CI + local). If it is extra regression depth for clone-repo work → **maintainer test** (optional). If it proves provider integration → **example**, not pytest.
Expand Down
7 changes: 4 additions & 3 deletions docs/contributing/ai_native_workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ These align with [CONTRIBUTING.md](../../CONTRIBUTING.md). Violations block merg

- Bundle: `manifest.yaml`, `skill.py`, `instructions.md`, `card.json`, `test_skill.py`, plus catalog docs
- `manifest.yaml` is source of truth for schema, constitution, `requirements`, `env_vars`, and `issuer`
- `requirements` — PEP 508 strings; unpinned checks importability only; version specifiers (for example `>=2.0.0`) are validated at load time — pin when the skill is sensitive to package API versions
- `manifest.yaml` `name` must equal `category/skill_name` (matches folder path); loader warns on mismatch for registry layout
- `issuer.name` and `issuer.email` required; `github` and `org` optional; no template placeholders in registry paths
- `card.json` issuer must match manifest `name` and `email` when present
Expand All @@ -252,7 +253,7 @@ These align with [CONTRIBUTING.md](../../CONTRIBUTING.md). Violations block merg
- Require a framework feature issue; add tests under `tests/`
- Do not change `loader.py` unless the issue requires it
- Issuer metadata is not passed into LLM tool schemas today
- Update `docs/usage/` when adapter behavior changes
- Update `docs/usage/` when loader or adapter behavior changes (for example requirement validation, `registry_id`, identity warnings)

### Documentation

Expand Down Expand Up @@ -305,8 +306,8 @@ Complete the checklist that matches your issue during Stage 5.
### Core framework

- [ ] Framework issue approved
- [ ] Changes in `skillware/` and relevant `tests/`
- [ ] Loader or API docs updated when behavior changes (e.g. `registry_id`, identity warnings, `validate_params`)
- [ ] Changes in `skillware/` and relevant `tests/` (for example `tests/test_loader.py`, `tests/test_requirements_check.py`)
- [ ] Loader or API docs updated when behavior changes (e.g. `registry_id`, identity warnings, manifest requirement validation, `validate_params`)
- [ ] `pytest tests/` passes
- [ ] Usage docs updated if API changed
- [ ] No undeclared breaking changes
Expand Down
5 changes: 3 additions & 2 deletions docs/security/skill-trust-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This page explains how Skillware resolves and executes skills on disk, and how y

When you load a skill, Skillware executes that skill's skill.py in your host process. Import runs the module's top-level code immediately, and from that point the skill's code has the same reach your process has: the full filesystem and every environment variable in os.environ, including secrets and API keys.

There is no isolation, no permission boundary, and no capability restriction around this execution. SkillLoader checks that a skill's declared requirements are importable, but it does not inspect, restrict, or sandbox what the code does. So the real decision each time you load a skill is: am I willing to hand this code my machine and my secrets?
There is no isolation, no permission boundary, and no capability restriction around this execution. SkillLoader checks that a skill's declared requirements are importable and, when a manifest entry includes a version specifier, that the installed distribution satisfies it — but it does not inspect, restrict, or sandbox what the code does. So the real decision each time you load a skill is: am I willing to hand this code my machine and my secrets?

The trust tiers in this document describe how much you should trust a skill's origin. They are not isolation levels. To be explicit: none of them are sandboxed today.

Expand Down Expand Up @@ -65,12 +65,13 @@ A skill's manifest.yaml can declare a constitution — a set of natural-language

A constitution guides the agent (the LLM calling the skill). It does not constrain skill.py. Nothing in the loader enforces a constitution at the Python level — code that ignores every rule in the constitution loads and runs exactly the same. The constitution is a prompt-level norm, not a process-level boundary.

The same distinction applies to other manifest fields. Declaring env_vars documents which variables a skill expects; it does not limit what the code can read. The loader's requirements check confirms that declared packages are importable; it does not inspect what the code does with them. In short:
The same distinction applies to other manifest fields. Declaring env_vars documents which variables a skill expects; it does not limit what the code can read. The loader's requirements check confirms that declared packages are importable and, when a manifest entry includes a version specifier, that the installed distribution satisfies it — it does not inspect what the code does with them. In short:

| constitution / manifest does | It does not |
| :--- | :--- |
| Tell the agent how the skill should be used | Sandbox or restrict skill.py |
| Document expected env vars and dependencies | Limit which env vars or files the code can access |
| Validate importability (and version pins when declared) before load | Enforce dependency versions by upgrading packages automatically |
| Set norms reviewers and agents can rely on | Enforce those norms at runtime |

### Instruction-only content
Expand Down
2 changes: 2 additions & 0 deletions docs/usage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ How to load Skillware skills and connect them to language models. Each guide cov

For pip-installed apps, keep project skills in `./skills/<category>/<name>/` or set `SKILLWARE_SKILL_PATH` to your skills root.

By default, `SkillLoader.load_skill()` validates manifest `requirements` before loading `skill.py`: unpinned deps must be importable; pinned specifiers (for example `web3>=6.0.0`) must match the installed version. See [Install extras — Loader behavior](install_extras.md#loader-behavior).

> **Security:** Loading a skill executes its `skill.py` in your process — there is no sandbox, and the first matching id in the search order wins (a local skill can shadow a bundled one). Only load skills you trust, and see the [skill trust model](../security/skill-trust-model.md) before loading external skills.

To list locally available skills, inspect path resolution, or run bundle tests from the terminal, see the [CLI reference](cli.md) (`skillware list`, `skillware paths`, `skillware test`).
Expand Down
11 changes: 9 additions & 2 deletions docs/usage/install_extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,19 @@ pip install "skillware[gemini]"

## Loader behavior

When `SkillLoader.load_skill(..., check_requirements=True)` (default) finds manifest `requirements` that are not importable, it raises `ImportError` with:
When `SkillLoader.load_skill(..., check_requirements=True)` (default) validates manifest `requirements`:

1. The missing package names
1. **Unpinned** entries (for example `requests`) — the importable module must exist.
2. **Pinned** entries (for example `web3>=6.0.0`) — the module must exist and the installed distribution version must satisfy the specifier.

On failure it raises `ImportError` with:

1. Missing packages and/or version mismatches (required spec vs installed)
2. Suggested `pip install "skillware[<category>_<skill>]"`
3. Category and `[all]` fallbacks

The loader does not install or upgrade packages — use pip extras or install the requirement strings yourself.

Packaging smoke tests use `check_requirements=False` so a base wheel install can verify bundles without optional extras ([TESTING.md](../TESTING.md#packaging-smoke-test)).

## Contributors
Expand Down
3 changes: 2 additions & 1 deletion scripts/wheel_smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import yaml

from skillware.core.discovery import bundled_skills_root, list_registry_skill_ids
from skillware.core.extras import requirement_import_module
from skillware.core.loader import SkillLoader

BUNDLE_FILES = (
Expand Down Expand Up @@ -58,7 +59,7 @@ class SmokeReport:
def _missing_manifest_requirements(manifest: dict) -> List[str]:
missing: List[str] = []
for req in manifest.get("requirements") or []:
import_name = SkillLoader._requirement_import_name(req)
import_name = requirement_import_module(req)
if importlib.util.find_spec(import_name) is None:
missing.append(import_name)
return missing
Expand Down
143 changes: 127 additions & 16 deletions skillware/core/extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

from __future__ import annotations

import importlib.util
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple

import yaml
from packaging.requirements import InvalidRequirement, Requirement

from skillware.core.discovery import bundled_skills_root, list_registry_skill_ids

Expand All @@ -28,6 +31,16 @@
"bs4": "beautifulsoup4",
}

# PyPI distribution names that differ from their import paths (loader presence checks).
_REQUIREMENT_IMPORT_ALIASES: Mapping[str, str] = {
"google-genai": "google.genai",
"google-generativeai": "google.generativeai",
"pymupdf": "fitz",
"beautifulsoup4": "bs4",
"pyyaml": "yaml",
"pillow": "PIL",
}

GENERATED_BEGIN = "# --- extras: begin generated by scripts/sync_extras.py ---"
GENERATED_END = "# --- extras: end generated by scripts/sync_extras.py ---"

Expand All @@ -47,6 +60,26 @@ def normalize_pkg_name(requirement: str) -> str:
return _REQUIREMENT_CORE_ALIASES.get(pkg, pkg)


def parse_requirement(requirement: str) -> Requirement:
"""Parse a PEP 508 requirement string from a manifest entry."""
return Requirement(requirement.split(";")[0].strip())


def requirement_import_module(requirement: str) -> str:
"""Return the importable module name used for find_spec presence checks."""
dist = normalize_pkg_name(requirement)
return _REQUIREMENT_IMPORT_ALIASES.get(dist, dist)


def _installed_distribution_version(distribution_name: str) -> Optional[str]:
for candidate in (distribution_name, normalize_pkg_name(distribution_name)):
try:
return version(candidate)
except PackageNotFoundError:
continue
return None


def is_core_requirement(requirement: str) -> bool:
return normalize_pkg_name(requirement) in CORE_DEPENDENCIES

Expand Down Expand Up @@ -189,6 +222,23 @@ def render_generated_block(skills_root: Optional[Path] = None) -> str:
return "\n".join(lines)


def _pip_install_hints(registry_id: Optional[str]) -> List[str]:
if not registry_id:
return []
skill_extra = registry_id_to_extra(registry_id)
category = registry_id.split("/", 1)[0]
return [
"Install the skill runtime extra:",
f' pip install "skillware[{skill_extra}]"',
"",
"Or install all skills in the category:",
f' pip install "skillware[{category}]"',
"",
"Or install runtime deps for every bundled skill:",
' pip install "skillware[all]"',
]


def build_missing_requirements_message(
manifest: Mapping[str, object],
registry_id: Optional[str],
Expand All @@ -201,28 +251,89 @@ def build_missing_requirements_message(
f"Skill '{skill_name}' requires missing packages: {missing_list}.",
"",
]

if registry_id:
skill_extra = registry_id_to_extra(registry_id)
category = registry_id.split("/", 1)[0]
lines.extend(
[
"Install the skill runtime extra:",
f' pip install "skillware[{skill_extra}]"',
"",
"Or install all skills in the category:",
f' pip install "skillware[{category}]"',
"",
"Or install runtime deps for every bundled skill:",
' pip install "skillware[all]"',
]
)
hints = _pip_install_hints(registry_id)
if hints:
lines.extend(hints)
else:
lines.append(f"Please run: pip install {' '.join(missing)}")

return "\n".join(lines)


def build_version_mismatch_message(
manifest: Mapping[str, object],
registry_id: Optional[str],
mismatches: Sequence[tuple[str, str, str]],
) -> str:
"""Build a pip install hint when installed versions fail manifest specifiers."""
skill_name = str(manifest.get("name") or registry_id or "unknown")
lines = [f"Skill '{skill_name}' has unsatisfied version requirements:", ""]
for requirement, installed, specifier in mismatches:
lines.append(f" - {requirement} (installed: {installed}, need: {specifier})")
lines.append("")
hints = _pip_install_hints(registry_id)
if hints:
lines.extend(hints)
else:
reqs = " ".join(req for req, _, _ in mismatches)
lines.append(f"Please run: pip install '{reqs}'")
return "\n".join(lines)


def check_manifest_requirements(
requirements: Sequence[str],
*,
registry_id: Optional[str] = None,
manifest: Optional[Mapping[str, object]] = None,
) -> None:
"""
Validate manifest requirements before loading skill.py.

Unpinned entries: importable module must exist (find_spec).
Pinned entries: module must exist and installed distribution version must
satisfy the specifier. Does not install or upgrade packages.
"""
manifest = manifest or {}
missing: List[str] = []
mismatches: List[tuple[str, str, str]] = []

for raw in requirements:
requirement = str(raw).strip()
if not requirement:
continue
try:
parsed = parse_requirement(requirement)
except InvalidRequirement as exc:
raise ImportError(
f"Invalid requirement in manifest: {requirement!r} ({exc})"
) from exc

import_name = requirement_import_module(requirement)
if importlib.util.find_spec(import_name) is None:
missing.append(requirement)
continue

if not parsed.specifier:
continue

installed = _installed_distribution_version(parsed.name)
if installed is None:
missing.append(requirement)
continue

if not parsed.specifier.contains(installed, prereleases=True):
mismatches.append((requirement, installed, str(parsed.specifier)))

if missing:
raise ImportError(
build_missing_requirements_message(manifest, registry_id, missing)
)
if mismatches:
raise ImportError(
build_version_mismatch_message(manifest, registry_id, mismatches)
)


def extras_for_registry_id(registry_id: str) -> Tuple[str, str]:
"""Return (category_extra, skill_extra) names for a registry skill id."""
category = registry_id.split("/", 1)[0]
Expand Down
33 changes: 6 additions & 27 deletions skillware/core/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
get_skill_roots,
is_skill_dir,
)
from skillware.core.extras import build_missing_requirements_message
from skillware.core.extras import check_manifest_requirements

SKILLWARE_SKILL_PATH_ENV = _discovery.SKILLWARE_SKILL_PATH_ENV

Expand All @@ -27,28 +27,12 @@ class SkillwareIdentityWarning(UserWarning):
"""Emitted when manifest.name does not match the registry folder path (warn-only in v1)."""


# PyPI distribution names that differ from their import paths.
_REQUIREMENT_IMPORT_ALIASES = {
"google-genai": "google.genai",
"google-generativeai": "google.generativeai",
"pymupdf": "fitz",
"beautifulsoup4": "bs4",
"pyyaml": "yaml",
"pillow": "PIL",
}


class SkillLoader:
"""
Utility to load skills dynamically or by path, bundling their
manifests, instructions, and logic for LLM usage.
"""

@staticmethod
def _requirement_import_name(requirement: str) -> str:
pkg_name = requirement.split(">")[0].split("<")[0].split("=")[0].strip()
return _REQUIREMENT_IMPORT_ALIASES.get(pkg_name, pkg_name)

@staticmethod
def _is_skill_dir(path: Path) -> bool:
return is_skill_dir(path)
Expand Down Expand Up @@ -217,16 +201,11 @@ def load_skill(

# Check Dependencies
if check_requirements and "requirements" in manifest:
missing = []
for req in manifest["requirements"]:
import_name = SkillLoader._requirement_import_name(req)
if not importlib.util.find_spec(import_name):
missing.append(req)

if missing:
raise ImportError(
build_missing_requirements_message(manifest, registry_id, missing)
)
check_manifest_requirements(
manifest["requirements"],
registry_id=registry_id,
manifest=manifest,
)

# Load Instructions
instructions = ""
Expand Down
Loading
Loading