diff --git a/.gitignore b/.gitignore index 640d560..a9a7aac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ dist/ build/ .claude/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 15dd64e..e470a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,13 +67,55 @@ All notable changes to this project are documented here. The format is based on ### Changed +- **Breaking:** for skill authors, `meta.yaml` now requires a + `capabilities` declaration, and a skill directory may hold only + `meta.yaml` and `skill.md` (#73). A skill without the declaration, or with + any other file (a script, an asset, a symlink), no longer loads. Add a + declaration as described in `docs/authoring-skills.md#capabilities`, and + move other files out. Every bundled skill already complies. +- Every bundled skill declares its capabilities (#73), so its `meta.yaml` + changed and its version is bumped: a patch for the declaration alone + (`authentication-review` 0.2.1, `ci-workflow-review` 0.4.1, `code-smells` + 0.3.1, `frontend-security-review` 0.1.1, `iac-review` 0.3.1, + `llm-integration-review` 0.1.1, `logging` 0.3.1, `migration-review` 0.4.1, + `privacy-review` 0.1.1, `resilience-review` 0.3.1, `security-review` 0.5.2, + `test-review` 0.3.1), and a minor bump for `dependency-review` 0.5.0, + whose instructions changed (below). The ten read-only reviews install + exactly as before. `dependency-review`, `logging` and `test-review` ask for + more (audit tools and web lookups; edits; the project's tests), so their + installed files gain a `## Declared capabilities` section: run + `skilldeck update` to refresh installed copies. +- `test-review` 0.3.1 says how to run a regression test against the + pre-change code without touching the working tree under review: in a + temporary `git worktree` outside the repository, removed afterwards; never + by stashing, resetting or checking out (#73). A patch under + `docs/lifecycle.md#skill-versions`: what it reports is unchanged, and the + step only spells out a safe way to do what it already asked. +- `dependency-review` 0.5.0 runs `pip-audit` only on a fully pinned + requirements file without resolving it + (`pip-audit --disable-pip --require-hashes -r `, or `--no-deps`), + and skips it otherwise, since `pip-audit -r` resolves like + `pip install -r`, per pip-audit's security model; and it reads a package's + registry page for its publish dates, maintainers and provenance (#73). A + minor bump under `docs/lifecycle.md#skill-versions`: it changes how the + skill gathers evidence (fewer pip-audit runs, a new registry lookup), + which can change what it reports, though its checklist and output shape + are unchanged. +- Every adapter appends a `## Declared capabilities` section to a skill that + asks for more than a read-only review (#73): an edit, a credential, an + agent tool, a new file, or any command beyond read-only git (`git fetch`, + `diff`, `ls-files`, `log`, `show`, `status`, `blame`). It tells the agent + in plain terms what the skill asks of it and that it asks for nothing else; + read-only reviews render unchanged. The adapter contract fixtures pin the + section, since the synthetic contract skill now declares the project's + test command it runs (contract `sha256:7c21e0856472`). - Asking an adapter for a scope it doesn't support now tells you what works instead (#79). For example, `--agent cursor-rule --scope global` names `--scope project`. On `install` it also names `--agent cursor`, which supports `--scope global`. - `meta.yaml` rejects keys other than `name`, `description`, `category`, - `version`, `supported-agents` and `deprecated`, so a misspelt field fails - loudly instead of being ignored (#77). + `version`, `supported-agents`, `capabilities` and `deprecated`, so a + misspelt field fails loudly instead of being ignored (#77, #73). - `skilldeck provenance --json` writes its JSON as UTF-8 bytes with `\n` line endings, as `catalog --json` does, so the output is byte-identical on Windows too (#77). @@ -485,6 +527,52 @@ All notable changes to this project are documented here. The format is based on policy. Without release tags it prints a note and skips the notice rules. An impossible CHANGELOG date is a clean error here and in `prepare_release.py`. +- Skill capability declarations (#73). Every `meta.yaml` now declares, under + a required, versioned `capabilities` block (schema 1), what the skill may + ask an agent to do: which files it reads (`none`, `diff` or `repo`) and + edits (`none` or `repo`), the commands it may run, what it contacts over + the network and why, the credentials it handles, the agent tools it needs, + and the files it may create. Anything not declared is not requested. The + registry rejects a malformed block: an unknown or missing key, another + schema number, a command given as a path to a script, an interpreter given + a script or inline code (`sh check.sh`, `python -c`), a command with shell + operators, or an artifact path that is absolute, names a drive or home + directory, uses `\` or has a `..` component. It is a declaration for + review; skilldeck cannot enforce it inside an agent. See + `docs/authoring-skills.md#capabilities`. + - `skilldeck show --summary` prints a skill's source, the build it + was recorded as coming from, its canonical digest (and whether it + matches the content manifest shipped in the package), deprecation state + and declared capabilities, and points to `provenance --verify` and + `docs/verifying-releases.md` for real verification. + - `skilldeck install ... --dry-run` prints that summary for each skill and + what installing it would do for each agent (install, update, rewrite, + overwrite, or the error a real install would hit, including a + destination folder that can't be created), and writes nothing. + - `skilldeck catalog --json` reports each skill's `capabilities`, an + additive field (`schema_version` stays 1). + - The bundled declarations were reviewed against each skill's + instructions: every review skill runs `git fetch`, `git diff` and + `git ls-files` and contacts the git remote; `dependency-review` may also + run `npm audit`, `pip-audit --disable-pip`, `osv-scanner`, + `govulncheck`, `cargo audit` and `gh api`, query advisory databases and + fetch advisory and package registry pages; `test-review` may run the + project's own tests in a temporary `git worktree`; `logging` may edit + repository files when it adds logging. +- Skill bundle validation (#73). A skill directory must hold exactly + `meta.yaml` and `skill.md` as regular files. Loading a skill rejects a + symlink or Windows junction (including a linked skill directory), a + subdirectory, and any other file, naming a script, a file with its execute + bit set or one starting with `#!` or a binary header as an undeclared + executable. It ignores OS and editor leftovers (`.DS_Store`, `Thumbs.db`, + `desktop.ini`, `__pycache__`, `._*`, `.#*`, `*~`, `#*#`, Vim swap files), + so one stray file doesn't break every command; `provenance --verify` and + `catalog` still report them as unexpected files, and also report a + `meta.yaml`, `skill.md` or skill directory that is a symlink or junction. + A `skill.md` link to a relative path, absolute path, `file:` URL or other + non-web scheme (in a Markdown link, image, reference definition or + autolink, or an HTML tag's `src`/`href`) is rejected as an asset the skill + can't ship. - Agent compatibility matrix, `docs/compatibility.md` (#79), linked from the README and `docs/adapters.md`. It lists every adapter, including the `copilot-prompt`, `cursor-rule` and `kiro-steering` legacy adapters, with: diff --git a/CLAUDE.md b/CLAUDE.md index 30bd2f6..6fa5701 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,17 @@ by `--agent all`); `skilldeck migrate` moves old-format installs to `SKILL.md`. - `cli.py` — `skilldeck list/show/install/uninstall/status/update/migrate`, `provenance` and `catalog` - `registry.py` — discovers and validates skills, including the optional - `deprecated` metadata + `deprecated` metadata, and the bundle rules: a skill directory is exactly + regular `meta.yaml` + `skill.md` (no scripts, assets, symlinks or + junctions; OS/editor leftovers such as `.DS_Store` are ignored when + loading but still fail `provenance --verify`), and `skill.md` links only + to the web or its own headings + - `capabilities.py` — the versioned `capabilities` declaration every + `meta.yaml` carries (files, commands, network, credentials, tools, + artifacts), its validation, the `## Declared capabilities` notice + adapters append for skills that ask for more than a read-only review + (reading files plus read-only git commands; those render unchanged), and + the summary `show --summary` / `install --dry-run` print - `catalog.py` + `catalog.schema.json` — the public, schema-versioned `skilldeck catalog --json` contract (the schema ships in the wheel); change it only per the compatibility rules in `docs/catalog.md` (bump @@ -106,7 +116,15 @@ by `--agent all`); `skilldeck migrate` moves old-format installs to `SKILL.md`. - Skills are authored once in `src/skilldeck/skills/`; never hand-edit per-agent output. - A skill's `meta.yaml` `name` must match its directory name; all metadata fields - are required and validated by the registry. + (except `deprecated`) are required and validated by the registry. +- Every skill declares `capabilities` (schema 1, every key spelled out, `[]` + or `none` when unused) that match what `skill.md` actually asks the agent + to do; update it with the body (`tests/test_skill_structure.py` checks + declared commands against the body both ways; list a span the skill only + quotes in its `MENTIONED_ONLY`). Undeclared means not requested; it is a + declaration for review, never presented as enforcement. The rendered notice + speaks to the agent and adds no instructions of its own. See + `docs/authoring-skills.md#capabilities`. - Any change to an adapter's output format, paths or env handling must update its contract fixtures, the `adapter-contract` digest and matrix rows in `docs/compatibility.md`, and CHANGELOG. The contract tests enforce the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09ade8d..c3230a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,8 @@ create them. Skills live in `src/skilldeck/skills//` as a `meta.yaml` + `skill.md`, authored once in an agent-neutral format — never hand-edit per-agent output. A skill's -`meta.yaml` `name` must match its directory name. See +`meta.yaml` `name` must match its directory name, and its `capabilities` must declare +what `skill.md` asks the agent to do (commands, network, credentials, edits). See [docs/authoring-skills.md](docs/authoring-skills.md) for the full guide, and bump that skill's own `version` in `meta.yaml` whenever its content changes. diff --git a/README.md b/README.md index ff34baf..99d0cc9 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,11 @@ package is unpublished, prefix each command with # See what's available skilldeck list -# Preview a skill before installing +# Preview a skill before installing: its instructions, then its source, +# digest and declared capabilities, then what an install would write skilldeck show security-review +skilldeck show security-review --summary +skilldeck install security-review --agent claude --dry-run # Install a skill for Claude into the current project skilldeck install security-review --agent claude @@ -158,6 +161,17 @@ skilldeck catalog --json --category security --agent claude `skilldeck catalog --json` is a stable contract for tools; see [docs/catalog.md](docs/catalog.md) for its schema and compatibility rules. +Every skill declares its capabilities: which files it reads or edits, the +commands it may ask your agent to run, what it contacts over the network and +why, and any credentials, agent tools or new files it needs. Anything not +declared is not requested. `show --summary` and `install --dry-run` print the +declaration before you install. A skill that asks for more than a read-only +review (reading files and read-only git commands) also carries it in its +installed `SKILL.md`, as a "Declared capabilities" section telling your agent +what the skill asks of it. It is a declaration for review, not a sandbox: +skilldeck can't enforce it inside your agent, so review what a skill asks for +(see [Capabilities](docs/authoring-skills.md#capabilities)). + Installed files carry a `skilldeck` stamp recording the skill version, so `status` can tell current, stale, and locally modified installs apart. Files you have edited, or that skilldeck didn't write, are never overwritten or deleted @@ -208,7 +222,8 @@ release; the package remains unpublished today. ## Authoring skills Each skill is a directory under `src/skilldeck/skills/` containing a `meta.yaml` -and a `skill.md`. See [docs/authoring-skills.md](docs/authoring-skills.md), and +(including its capability declaration) and a `skill.md`, and nothing else. See +[docs/authoring-skills.md](docs/authoring-skills.md), and follow the [contributor guide](CONTRIBUTING.md) for setup and validation. ## Changelog and support diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 7487e7d..2013a73 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skilldeck", - "version": "0.3.1-dev.sha256-30d962109325", + "version": "0.3.1-dev.sha256-d2c8bb65694a", "description": "Security and code-review skills for Claude Code: authentication-review, ci-workflow-review, code-smells, dependency-review, frontend-security-review, iac-review, llm-integration-review, logging, migration-review, privacy-review, resilience-review, security-review, test-review", "author": { "name": "Richard Hope", diff --git a/claude-plugin/.skilldeck/content-manifest.json b/claude-plugin/.skilldeck/content-manifest.json index c67ff18..d746469 100644 --- a/claude-plugin/.skilldeck/content-manifest.json +++ b/claude-plugin/.skilldeck/content-manifest.json @@ -4,107 +4,107 @@ "skills": [ { "body_sha256": "sha256:366ffcaa3183fd009c3c59953b1e571ab88bdc426f277584b60198b2d8d47ab8", - "canonical_sha256": "sha256:e45d275cd8fd659af6b6503430812da86c7222726bf189c0cb457fa3bea391f9", + "canonical_sha256": "sha256:348085527ac5aaa2cfb61114357ad2eb5eba688d70a0a6cc10f103a89736025e", "claude_rendered_sha256": "sha256:43aa9cb65459b19211fa2349e5b4f9362f90638f23884ec159e709cbdd3109e4", - "meta_sha256": "sha256:faf1d1d2f3f630a6efce7f5df11e3290b9f4124ab9562d96a980fe89dfc242ab", + "meta_sha256": "sha256:2e0f9a777255b57f101c51c94a14c9caea8557f581cb8db9f1b46937aa81f7b9", "name": "authentication-review", - "version": "0.2.0" + "version": "0.2.1" }, { "body_sha256": "sha256:e80a382e92dd367d577a3bf2251be01713f7ffc9405008857609bcfdd5f387c9", - "canonical_sha256": "sha256:fb28b8bc8aeae01b9983f2b91f1019767416e8ef4c56feb91cb7ad418fd64e34", + "canonical_sha256": "sha256:20ed849d325c711ee35ce6bc46125ce6bb3d240fc4891791634ee4f8e8caa035", "claude_rendered_sha256": "sha256:e7f770e9e6f9b552bb0474364bc132887920c880fbbe3b2bba659b3448b6adc7", - "meta_sha256": "sha256:121aef136ba48413dd05dac91934798580ea762b99f01d349898469918a625a8", + "meta_sha256": "sha256:216940dcd5591f170760ab0350a4975ba55fbf50518a836cf594362719dd4477", "name": "ci-workflow-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:bf082cd5c84b729d6c3412fb83d4d771e7dd4d1e30bba11bdb2b58fc3208dd3f", - "canonical_sha256": "sha256:e4dcbee8ff34424923afdfb40cf0966c6cf2f388e086142d4e3208b7ce8e5e7d", + "canonical_sha256": "sha256:16d3b7a55046e42c4fca537da00c1e7764d835671a9e98fb0660c732efeabb8b", "claude_rendered_sha256": "sha256:aa4f2b00ce2a7d44f66689064a5acfdde873a2d45d292a8d50bf53ef9d2033df", - "meta_sha256": "sha256:d289c63d3178f50e004fa6f1eec3489f2a3bd0236e37b778af45d086c327f32a", + "meta_sha256": "sha256:e97b0202b8caf9d7f8b008665a37ff0ad4b9fe259cf5e50e83739434df9ceabb", "name": "code-smells", - "version": "0.3.0" + "version": "0.3.1" }, { - "body_sha256": "sha256:d7081d47124427a0bf25ae6eb56c527efe63063b17781da4ee34fc3a118be2b7", - "canonical_sha256": "sha256:b9fa88c17d1211310ef19ce8b938c33de0b2741a61631a3779f52742208f39eb", - "claude_rendered_sha256": "sha256:0f1f0e421c1ca3297cd865a003f9fd0c52b3f6cfaabbdf1f41ba11bd6ec9c878", - "meta_sha256": "sha256:4e4b70672db9b6046462f3fb880ea2ce14ffd6ff36703a848ce881141a5fb539", + "body_sha256": "sha256:45fe7888314ff411831fff7591dcda21a2fb99bbc54fbed8ca35a347bd030ce9", + "canonical_sha256": "sha256:4d76accd4200eba551eb67fd3300de4e2da02f65a8901a311012ff4d7e867bf5", + "claude_rendered_sha256": "sha256:07723f0640d98090d95a0d88424eb12b7f99a297be85396fba35eeaf4bd97807", + "meta_sha256": "sha256:71af058a1cdb60a4013b42b09240f58b8a487ed2c6dfd018a297c4c29bdf8a9d", "name": "dependency-review", - "version": "0.4.0" + "version": "0.5.0" }, { "body_sha256": "sha256:94ab468a0837179a8fa7470be404884cfc475f05f8be2314bff6e409ecffc5e5", - "canonical_sha256": "sha256:c3c2bde57bbb2c220da2f44c775e1d2bb71956915572094acd0fcf8a1c411186", + "canonical_sha256": "sha256:74930194c26657d0107d59520a6baa33e6ccdb3c3915773f137f0f11940ceb2f", "claude_rendered_sha256": "sha256:11d8bdbf3419cb7c4040bc02e19b502d5b64ba77e65aa2190e1cd51dc826d76a", - "meta_sha256": "sha256:6337828e91940730210bd7fea7690573610f9413d484d40a2596772c387b94fb", + "meta_sha256": "sha256:d2dc89cdda1beaeff5a10b911488e89a00a4bd4d3408cb7a41d58a0b92833cd2", "name": "frontend-security-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:5503c0d7457247cfa17b81e7485e4ea2d8bdcc00c244f77e32d9df5ffc24b67b", - "canonical_sha256": "sha256:d3b1a7aedb82eb14724253a447c1b67d9d3e8f5928f9b3555c7c17a9975bf529", + "canonical_sha256": "sha256:92e44ffd9f514cf5b12fe4c97ce955d50a1167ff63b317549b5a81ca89850870", "claude_rendered_sha256": "sha256:0e2496faefe746b3e1db50e470c509f1eaea8cb29c4e273f958088cdbd2589a7", - "meta_sha256": "sha256:8888d07def4742b7c284f44392a5b4cbdb3bf551736e4a3b93e71cc36cf76913", + "meta_sha256": "sha256:38ddbca88c334c54c4c22832018635394e8e5feed57ef129c749d90057bb28bc", "name": "iac-review", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:14be89e51271a75e43d0fcf7104bf18ca9d7516cf6da63d063e3af7cb4fa8b4f", - "canonical_sha256": "sha256:cbf0af07dc478897b976c9ecb15f708f4f94de73dd2999b9433a7d2a374f80df", + "canonical_sha256": "sha256:e36515eb30b2eec5c54f42e2846514b564dfafc5b530168439321d327721fea2", "claude_rendered_sha256": "sha256:e1f93c5321cd7bdb6eeff97c5f8888a67d27294e2abbfe491fac260db0e7ea60", - "meta_sha256": "sha256:a90e3c80901dc33337063e09d9cfcd2d39d25e0266acf0a6988fe5a941ad4e08", + "meta_sha256": "sha256:268509e2a047c432df346db1f7833d028f28566813b62658c8fb1b60176bfb22", "name": "llm-integration-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:7e407631107275697b6c090de6a68af8389fd8b4141bd0253a47e9688098c4b9", - "canonical_sha256": "sha256:31e1bc83cb9811b7f606890b740f7e514a4544a31240b40924547248d7537acf", - "claude_rendered_sha256": "sha256:ae40c0fbec5bf3fc24c81b9ad6c404ddab545001d20bc4d0c50082012d922120", - "meta_sha256": "sha256:52549724d30304bf9a59ae919d9d630e75f553b485d5d44ed183462d21a2c7c6", + "canonical_sha256": "sha256:5c6f68d8ff18c50b8660874b5117e4bb307c0cc8b1fea7daf8e06b4906d12966", + "claude_rendered_sha256": "sha256:8ba9fba7ddc27c986f41621f80b5b3726960044a69968ecca80777917ccde03c", + "meta_sha256": "sha256:75c39abf8f7ae9599ceaaf65bcc79776e04ccc0595f54949c52af1dcf204c730", "name": "logging", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:3e869f7883e118db18e8c8002f049d8ecf2895e49aa7c76f1d7a39a6ee842c8b", - "canonical_sha256": "sha256:2a90dcac4e099baf9018049929e4cd803b236afb4219da70f9e84ddc501d2189", + "canonical_sha256": "sha256:1a4c38c4b51a52b60330cd09de25aa7f1b30458fb23c2cb3dd1bdb9b705fc6a9", "claude_rendered_sha256": "sha256:4dfff856963c37785deb71d8385783df137a6bfe7bc692877d71a9bb5d3bc6f9", - "meta_sha256": "sha256:006759e87ea326c310ad454d4da8f1459c909a0ac25905a38c37edb8d8b743db", + "meta_sha256": "sha256:d891c2343b62c6881a84d338f873021c550e159c0cad06efc2e6d7059862a463", "name": "migration-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:063c8b0a474515dce78dac4196f49e2a0a25912e0d794a65c64387a9033448f5", - "canonical_sha256": "sha256:e499fd8757af810839c4421ee5affbfbb43bb72d9ff542fb40849583fb3e81c8", + "canonical_sha256": "sha256:2c47acd249f207d5264d9e321d71c1c09fecd1877384c7557468dc45a8c57dc6", "claude_rendered_sha256": "sha256:1f9838f8b4d1e7def32d9eb7f3d2a6f6b8a9bdabbc1572870dae68bc843d341c", - "meta_sha256": "sha256:d934b1fa74fcce6b2825c6e4c7744a4a57d1fbcd7d15577f864a4c72941d029c", + "meta_sha256": "sha256:d98573ef1c6b622a49fec659de9f9c7171f47ece73f8775536810f2e6abd1380", "name": "privacy-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:3a063e1f8e93f442e622c6da68eeb0196c33d16d1a7b114b85ae18d48e4222bc", - "canonical_sha256": "sha256:2c1e1682efcb2e62296711cd1e0a143a33228600498d70a6fee5a20fdb0f5a9c", + "canonical_sha256": "sha256:8bb47653d96e9a754e49fffa9ca57b10d56a7a285fada21f41e84f93b43ae7eb", "claude_rendered_sha256": "sha256:8df93b79fceab51ff41d49996253e07c3b9c50c0ea132487e32a233dc5bc770d", - "meta_sha256": "sha256:33ef3b93f0720a661cc686854b5a9f60e67cd5195a706aee97af125fb06e02a2", + "meta_sha256": "sha256:4c7cd02a783821b8452c14e2dcdd966c1cbca52f54b3e45042e9347b386073aa", "name": "resilience-review", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:b11892bc3f625828f52c4d90a33b68e1df581cea5bd0cf984d1e56f88efff7be", - "canonical_sha256": "sha256:f7fb3e489ba1dff5324dda4682166d60b745379401a26505e29efb8261f4f46c", + "canonical_sha256": "sha256:5a8fb1996e46d2b3e587d2b179a1b6d72b61dcc99e488a8769de742204d6b256", "claude_rendered_sha256": "sha256:5250d9e6f57d0830a16a2ead1b505b77d61e0a4edc82ce6e3b878a59f040e710", - "meta_sha256": "sha256:60a12438cf365b8d31db1c15a22bec93acf3b9067b791933405ff038a54ef447", + "meta_sha256": "sha256:065932102ec3a6ef7ba965ed7977a52f3dd1bc49a83c8869850fd4f3b1f412ee", "name": "security-review", - "version": "0.5.1" + "version": "0.5.2" }, { - "body_sha256": "sha256:49215350ea63412ec0c5cc0aad38d74546002014abaeb0327f60f65f8082a4af", - "canonical_sha256": "sha256:91137c7fd8927f8cae795444a6382252d65a7e6e828467b9d39ad194b72e1bee", - "claude_rendered_sha256": "sha256:bd0f9978ce38fd191bdc98030e000e6bf50ba965de6691bc700a9044851eb415", - "meta_sha256": "sha256:5aac68078cf57dc41c4b76faad23aa4fd4a00380acedfbb510733ea612629a6f", + "body_sha256": "sha256:d0bb69d296ac3ea2e6f3e42667a75656927d46bb50359d0e4bfe3a769b8a77e7", + "canonical_sha256": "sha256:f3ceba12771a669de0dac3b4860f426582562bc56f52a6ce716656666d5868be", + "claude_rendered_sha256": "sha256:655b92dc6c7d2a0d188a417d0a484abc9fc2cec8a7dffcc178f178c89f98a1e8", + "meta_sha256": "sha256:72306a4ab3cf5c20ad4493877d06dbcce3d6809ecb99f357b42e317053416327", "name": "test-review", - "version": "0.3.0" + "version": "0.3.1" } ] } diff --git a/claude-plugin/skills/dependency-review/SKILL.md b/claude-plugin/skills/dependency-review/SKILL.md index 596c4ca..a89892f 100644 --- a/claude-plugin/skills/dependency-review/SKILL.md +++ b/claude-plugin/skills/dependency-review/SKILL.md @@ -37,7 +37,14 @@ rest of the application surface. 3. Diff old vs new versions to see exactly what changed. If automated tooling is available (`npm audit`, `pip-audit`, `osv-scanner`, `govulncheck`, `cargo audit`, `gh` advisory APIs), run it and cite the results; otherwise - reason from the version changes and known advisories. + reason from the version changes and known advisories. `pip-audit -r ` + resolves the requirements as `pip install -r` would, so run `pip-audit` only + on a fully pinned file without resolving it: + `pip-audit --disable-pip --require-hashes -r `, or `--no-deps` in place + of `--require-hashes` when the pins carry no hashes; skip it for anything + else ([pip-audit security model](https://github.com/pypa/pip-audit#security-model)). + For a package's publish dates, maintainers, and provenance, read its + registry page. 4. This skill owns package manifests and lockfiles; how CI steps and images are pinned belongs to `ci-workflow-review`, and IaC images and modules to `iac-review`. If the owner runs in the same review, leave its area to it; @@ -133,3 +140,18 @@ Open the report with one line stating what was reviewed and the outcome, e.g. `Reviewed origin/main...HEAD (2 manifests): 1 finding, high.` If the diff touches no dependency manifest or lockfile, say so and stop. If the dependency changes are clean, say so explicitly. + +## Declared capabilities + +Beyond reading the repository, this skill asks you to: + +- run `git fetch`, `git diff`, `git ls-files`, `npm audit`, `pip-audit --disable-pip`, `osv-scanner`, `govulncheck`, `cargo audit`, `gh api` +- contact: + - the git remote, via git fetch, to bring the base branch up to date + - vulnerability databases and package registries, queried by the audit commands + - GitHub's advisory API, via gh api with its existing login + - advisory pages, fetched to verify an advisory ID before citing it + - package registry pages, fetched for a package's publish dates, maintainers and provenance +- use these agent tools: web fetch, to read advisory and package registry pages + +It asks for nothing else. diff --git a/claude-plugin/skills/logging/SKILL.md b/claude-plugin/skills/logging/SKILL.md index c0a7275..475baa3 100644 --- a/claude-plugin/skills/logging/SKILL.md +++ b/claude-plugin/skills/logging/SKILL.md @@ -141,3 +141,13 @@ Open the report with one line stating what was reviewed and the outcome, e.g. neither touches logging nor adds security-relevant events that should be logged, say so and stop. If the logging is sound, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +Beyond reading the repository, this skill asks you to: + +- run `git fetch`, `git diff`, `git ls-files` +- edit files in the repository +- contact the git remote, via git fetch, to bring the base branch up to date + +It asks for nothing else. diff --git a/claude-plugin/skills/test-review/SKILL.md b/claude-plugin/skills/test-review/SKILL.md index 4e473bc..c7573e6 100644 --- a/claude-plugin/skills/test-review/SKILL.md +++ b/claude-plugin/skills/test-review/SKILL.md @@ -33,8 +33,14 @@ deterministic tests; coverage shows a line ran, not that it was checked), 3. Match the project's existing test conventions (framework, layout, naming); judge against them rather than imposing a different style. 4. For a bug fix, confirm the regression test would actually fail without the - fix — read the pre-change code (or run the test against it if cheap) rather - than assuming. + fix — read the pre-change code rather than assuming. If running the test is + cheap, run it against that code without touching the working tree: check + out the base in a temporary worktree outside the repository + (`git worktree add origin/`, or `HEAD` for uncommitted + changes), copy the new test into it, run it there with the project's test + command, then delete the worktree + (`git worktree remove --force `). Never stash, reset, or check out + in the working tree under review. ## What to look for @@ -118,3 +124,12 @@ Open the report with one line stating what was reviewed and the outcome, e.g. changes no behavior that needs tests (e.g. docs, comments, pure config), say so and stop. If the tests adequately cover the change, say so explicitly rather than inventing findings. + +## Declared capabilities + +Beyond reading the repository, this skill asks you to: + +- run `git fetch`, `git diff`, `git ls-files`, `git worktree add`, `git worktree remove`, `` +- contact the git remote, via git fetch, to bring the base branch up to date + +It asks for nothing else. diff --git a/docs/adapters.md b/docs/adapters.md index 2874031..db1e710 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -31,6 +31,13 @@ moved it with an [environment variable](#environment-variables). `skilldeck show --agent ` prints exactly what gets written (minus the [stamp](#stamps-what-skilldeck-will-overwrite-or-delete)). +When the skill asks for more than a read-only review (reading files and +read-only git commands), every adapter, legacy formats included, appends a +`## Declared capabilities` section to the body that tells the agent what the +skill's [capability declaration](authoring-skills.md#capabilities) lists, so +it travels with the installed file; a read-only review is written unchanged. +`skilldeck install ... --dry-run` previews an install, with each skill's +declaration and digest, without writing anything. `--agent all` selects these five native adapters and nothing else; a legacy adapter runs only when you name it. diff --git a/docs/authoring-skills.md b/docs/authoring-skills.md index d6d542e..fcbaa76 100644 --- a/docs/authoring-skills.md +++ b/docs/authoring-skills.md @@ -11,6 +11,9 @@ src/skilldeck/skills/ └── skill.md ``` +Those two regular files are the whole skill: see +[What a skill directory may hold](#what-a-skill-directory-may-hold). + ## `meta.yaml` ```yaml @@ -22,11 +25,25 @@ supported-agents: # non-empty list; adapters skip skills they aren't in - claude - codex - kiro +capabilities: # what the skill may ask the agent to do; see below + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] ``` -All five fields are required, and the loader (`skilldeck.registry`) rejects a +All six fields are required, and the loader (`skilldeck.registry`) rejects a `meta.yaml` that breaks any of these rules with an error naming the field. A -sixth field, `deprecated`, is optional (see +seventh field, `deprecated`, is optional (see [Deprecating a skill](#deprecating-a-skill)); any other key is an error, so a misspelt field fails loudly instead of being ignored. @@ -37,6 +54,7 @@ misspelt field fails loudly instead of being ignored. | `category` | A non-empty string. | | `version` | A **string** of the form `MAJOR.MINOR.PATCH`: three non-negative integers without leading zeroes, e.g. `0.1.0` or `1.10.0`. | | `supported-agents` | A non-empty list of agent names (strings), each listed once: `claude`, `codex`, `copilot`, `cursor`, `kiro`. The legacy adapters (`copilot-prompt`, ...) follow their agent's entry and are not listed. | +| `capabilities` | A capability declaration, capability schema 1: see [Capabilities](#capabilities). | Both `meta.yaml` and `skill.md` must be UTF-8. @@ -89,6 +107,114 @@ full path, including what happens to installed copies, and [Skill versions](lifecycle.md#skill-versions) for which changes are major, minor or patch. +### Capabilities + +`capabilities` declares what the skill may ask an agent to do beyond +following its text, so a reviewer (or a user about to install it) can see +that without reading every instruction. Declare what the body actually asks +for, and update the declaration whenever the body changes what it asks: + +| Key | Value | Declares | +|-----|-------|----------| +| `schema` | `1` | The capability schema. This page describes schema 1; a skilldeck that reads schema 1 rejects any other number. | +| `files` | a mapping of `read` and `write` | `read`: `none`, `diff` (the changed files) or `repo` (any file in the repository). `write`: `none`, or `repo` if the skill may edit files in the repository's working tree (as `logging` does when it adds logging). Files outside the repository are never covered, with one exception: what a declared command does by itself (below). | +| `commands` | list of commands | Commands the skill may ask the agent to run, each a program on `PATH` and its subcommand (`git diff`). An entry covers that command with the arguments the body gives it. A `` in angle brackets stands for a command the project defines, such as ``: running it runs the repository's own code, so review it as such. A path to a file (`./check.sh`) is rejected, and so is an interpreter (`sh`, `bash`, `python`, `node`, `ruby`, `perl`, `pwsh`, ...) given a script (`sh check.sh`, `python ../x.py`) or inline code (`-c`, `-e`, `--eval`, `-Command`): a skill cannot ship scripts, and inline code would hide what runs. Declare a project-defined command as a placeholder instead. | +| `network` | list of descriptions | What the skill may contact, and why: `the git remote, via git fetch, to bring the base branch up to date`. | +| `credentials` | list of descriptions | Secrets the skill asks the agent to read (from environment variables, files or a keychain), pass on or send. A declared command that authenticates by itself with the user's existing setup, as `git fetch` uses git's credential helper and `gh` its stored login, is not listed here: declare the command and its network use instead. | +| `tools` | list of descriptions | Agent tools the skill needs beyond reading files and running its commands, such as `web fetch, to read advisory pages`. | +| `artifacts` | list of paths | Files the skill may create in the project, as POSIX paths relative to the project root (`reports/review.md`). | + +Every key is required, so each skill states each capability; write `[]` (or +`none`) for one it doesn't need, rather than leaving the key out. Unknown keys +are errors. Each list entry is one line of printable text of at most 200 +characters, listed once. A command is one simple command: single-spaced, +starting with a program name (letters, digits, `.`, `_`, `+`, `-`), with no +shell operators, redirections or substitutions (`;`, `|`, `&`, `$`, `<`, `>`, +parentheses, backticks) that would hide what actually runs. An artifact +path uses `/` separators and only letters, digits, `.`, `_` and `-`; the +loader rejects one that is absolute, names a drive (`C:`) or a home directory +(`~`), uses `\`, or has an empty, `.` or `..` component, so none can reach +outside the project. + +A declared command's own side effects are covered by declaring it: `git fetch` +updates remote-tracking refs under `.git`, and `test-review`'s +`git worktree add` checks the base out into a temporary directory outside the +repository (which the skill copies the new test into and then removes with +`git worktree remove`). None of that is an edit to the repository's working +tree (`files.write`) or a file the skill leaves behind (`artifacts`), but +review a command with that in mind. + +**Anything not declared is not requested.** A person reviewing a skill, or +what an agent did with it, can read the declaration as the whole of what the +skill asks for; anything more came from somewhere else. The declaration is for +review, not enforcement: skilldeck cannot sandbox the agents it installs into, +and no metadata makes a malicious instruction safe. Review the body itself +too. + +Where the declaration shows up: + +- **Before install**: `skilldeck show --summary` prints it with the + skill's source, build and digest, and + `skilldeck install --agent --dry-run` prints the same + summary with what the install would do, writing nothing. +- **In the installed file**: every adapter appends a + `## Declared capabilities` section to a skill that asks for more than a + read-only review, telling the agent in plain terms everything the skill asks + of it beyond reading files, then that it asks for nothing else. A read-only + review reads files and runs only read-only git commands (`git fetch`, + `git diff`, `git ls-files`, `git log`, `git show`, `git status`, + `git blame`), which reach nothing but the git remote; it is rendered + unchanged. Anything else brings the section: an edit (`write: repo`), a + credential, an agent tool, an artifact, or any other command. Once the + section is there it lists every command and every network use, the + baseline ones included. It adds no instruction beyond the declaration. +- **For tools**: `skilldeck catalog --json` reports it as each skill's + `capabilities` (see [the skill catalog](catalog.md)). + +`tests/test_skill_structure.py` checks the bundled skills' `commands` +against their bodies both ways. A code span that runs a known program +(common package managers, scanners, test runners, network clients, +interpreters, and every program some skill declares), such as +`git diff origin/...HEAD`, must start with a command the skill +declares, and every declared command must appear in the body. A span the +skill only quotes, as a pattern to look for or a command to avoid, is listed +in the test's `MENTIONED_ONLY`. + +### What a skill directory may hold + +Exactly `meta.yaml` and `skill.md`, as regular files. skilldeck installs one +file per skill, so it has no way to ship a script, a reference file or an +image, and a skill cannot declare one. The loader rejects, naming each: + +- a symlink (or, on Windows, a junction), even one pointing at a file with + the right content, and a skill directory that is itself one; +- a directory or any other file, calling it an undeclared executable when + its suffix (`.sh`, `.py`, `.exe`, ...), execute bit (not on Windows) or + first bytes (`#!`, or a native binary) say it is a program. + +Loading ignores what an OS or editor leaves next to the files you edit, so +one stray file doesn't break every command: `.DS_Store`, `Thumbs.db`, +`desktop.ini`, `__pycache__`, and names starting `._` or `.#`, ending `~`, +of the form `#name#`, or Vim swap files (`.name.swp`, `.name.swo`, ...). A +symlink with one of those names is still rejected, except an Emacs `.#` +lock, which is one by nature. `skilldeck provenance --verify` (and so +`skilldeck catalog`), the release-integrity check, is stricter: it reports +any entry besides `meta.yaml` and `skill.md` as an unexpected file, leftovers +included, and a `meta.yaml`, `skill.md` or skill directory that is a symlink +or junction. + +An execute bit on `meta.yaml` or `skill.md` themselves is ignored: skilldeck +reads them as text and never copies a file's mode, and some filesystems +(a Windows drive under WSL, for one) mark every file executable. + +`skill.md` may link only to web pages (`http`, `https`, `mailto`) and to its +own headings (`#output`). A relative link, an absolute path, a `file:` URL or +any other scheme names a file the skill can't ship, so the loader rejects it +as a missing asset. It looks in Markdown links and images, reference +definitions and autolinks (``), and the `src` and `href` +attributes of HTML tags; not in prose (`location.href = input`), code spans, +or fenced or indented code blocks, so examples stay possible. + ## `skill.md` The agent-neutral body of the skill — the actual instructions/prompt. Write it @@ -120,6 +246,7 @@ skill one line that leaves the owner's area to the owner. ```bash skilldeck list # should show your new skill +skilldeck show my-skill --summary # check the capabilities it declares skilldeck install my-skill --agent claude --scope project ``` diff --git a/docs/catalog.md b/docs/catalog.md index 1022054..2b7a797 100644 --- a/docs/catalog.md +++ b/docs/catalog.md @@ -51,7 +51,18 @@ from it, rather than trusting a second file that could drift from it. "repository": "https://github.com/IcebergAI/skilldeck", "path": "src/skilldeck/skills/security-review" }, - "deprecated": null + "deprecated": null, + "capabilities": { + "schema": 1, + "files": {"read": "repo", "write": "none"}, + "commands": ["git fetch", "git diff", "git ls-files"], + "network": [ + "the git remote, via git fetch, to bring the base branch up to date" + ], + "credentials": [], + "tools": [], + "artifacts": [] + } } ] } @@ -80,12 +91,25 @@ from it, rather than trusting a second file that could drift from it. [Deprecating a skill](authoring-skills.md#deprecating-a-skill). A removed skill is simply absent; [Lifecycle and compatibility](lifecycle.md#the-catalog) covers each state and how long a deprecated skill stays. +- `capabilities` is what the skill declares it may ask an agent to do, + exactly as its `meta.yaml` states it: `schema` (the capability schema, + `1`), `files.read` (`none`, `diff` or `repo`), `files.write` (`none` or + `repo`), and the `commands`, `network`, `credentials`, `tools` and + `artifacts` lists in the order the skill declares them, `[]` for none. + Anything not listed is not requested; the declaration is for review, and + nothing enforces it. See + [Capabilities](authoring-skills.md#capabilities). `schema` is always `1` + under catalog `schema_version` 1: a new capability schema is a breaking + catalog change, which bumps `schema_version`. `catalog` runs the full `skilldeck provenance --verify` check first. If any -installed skill no longer matches its recorded digest, is missing, or has a -file the content manifest does not list next to it (or the skills directory -holds anything else), it prints each problem on stderr, nothing on stdout, and -exits 1. It never produces a catalog for content the package did not ship. +installed skill no longer matches its recorded digest, is missing, has a file +the content manifest does not list next to it (OS and editor leftovers +included), or has a `meta.yaml`, `skill.md` or skill directory that is a +symlink or junction (or the skills directory holds anything else), it prints +each problem on stderr, nothing on stdout, and exits 1. It never produces a +catalog for content the package did not ship. See the +[bundle rules](authoring-skills.md#what-a-skill-directory-may-hold). The output is deterministic: the same installed package always prints the same bytes on every platform: UTF-8 (in fact ASCII, with `\u` escapes), sorted @@ -149,6 +173,8 @@ These are **breaking** and bump `schema_version`: - changing what a value means, such as the digest algorithm, the bytes `canonical_sha256` covers, `rendered_sha256` no longer matching the install stamp, or the meaning of `since`; +- a new capability schema (`capabilities.schema`), or a new value for + `capabilities.files.read` or `capabilities.files.write`; - dropping a guarantee on this page, such as the sort order or one entry per skill. diff --git a/docs/compatibility.md b/docs/compatibility.md index 1c31d62..008aee8 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,6 +1,6 @@ # Agent compatibility - + This page lists what skilldeck installs for each agent and where it goes. It also covers how you then use a skill in that agent, the agent version it needs, @@ -225,7 +225,10 @@ The full list of sources, with paths and line numbers, is in `tests/fixtures/adapter-contracts/` pins each adapter's side of this table: - `skill/contract-demo/` is a small synthetic skill. Its description needs - YAML quoting and folding, and its body has non-ASCII text. + YAML quoting and folding, and its body has non-ASCII text. It declares a + command beyond the read-only git baseline + (``), so every expected file also pins the + `## Declared capabilities` section adapters append to such a skill. - `contracts.json` gives each adapter's project and global paths, the text its `--scope global` error must contain if it is project-only (for `install` and for other commands), and its expected frontmatter. It also diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 3641d82..3ce12dd 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -338,6 +338,13 @@ tools should see the field, the catalog, where it is an additive change. Removing or renaming a field, or tightening a rule so that existing metadata fails, is breaking for skill authors. +`capabilities` declares what a skill may ask an agent to do (see +[Capabilities](authoring-skills.md#capabilities)). It is versioned by its own +`schema` number: a new capability schema is breaking for skill authors, and a +catalog `schema_version` bump. Changing a skill's declaration versions the +skill like the body change behind it; declaring what the body already asked +for is a patch. + The one lifecycle field is `deprecated`, with `since`, `reason` and an optional `replacement`. It deliberately has no removal date or version. The notice period starts when a release publishes the deprecation, and only the diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index c35611a..3c25dcb 100644 --- a/docs/verifying-releases.md +++ b/docs/verifying-releases.md @@ -91,9 +91,10 @@ a modified install still reports the original identity. `skilldeck provenance --verify` **checks** those claims: it re-hashes every installed skill's `meta.yaml` and `skill.md`, and exits 1 with an error naming each skill that no longer matches its recorded canonical digest, is missing, -or has unexpected files next to it. It combines with `--json`. It detects -changes to installed skill files; it cannot vouch for a package whose code was -also changed, since that code does the checking. Verify the wheel itself (the +has unexpected files next to it, or is (or holds) a symlink or junction in +place of its files. It combines with `--json`. It detects changes to +installed skill files; it cannot vouch for a package whose code was also +changed, since that code does the checking. Verify the wheel itself (the steps above) before installing it. A source checkout honestly reports the tag and commit as unavailable instead diff --git a/scripts/verify_distribution_identity.py b/scripts/verify_distribution_identity.py index 382ba32..27896a0 100644 --- a/scripts/verify_distribution_identity.py +++ b/scripts/verify_distribution_identity.py @@ -55,6 +55,7 @@ sys.path.insert(0, str(ROOT / "src")) from skilldeck.adapters import ADAPTERS # noqa: E402 +from skilldeck.capabilities import CapabilityError, parse_capabilities # noqa: E402 from skilldeck.provenance import ( # noqa: E402 REPOSITORY_URL, canonical_skill_digest, @@ -652,6 +653,13 @@ def validate_plugin( or not all(isinstance(agent, str) for agent in agents) ): raise VerificationError(f"invalid canonical metadata: {record['name']}") + try: + # the rendered file carries the declared-capabilities notice + capabilities = parse_capabilities(meta.get("capabilities")) + except CapabilityError as exc: + raise VerificationError( + f"invalid canonical capabilities: {record['name']}: {exc}" + ) from exc skill = Skill( name=record["name"], description=str(meta.get("description")), @@ -660,6 +668,7 @@ def validate_plugin( supported_agents=tuple(agents), body=source["skill.md"], path=Path(record["name"]), + capabilities=capabilities, ) rendered_digest = sha256_text(ADAPTERS["claude"].render(skill)) if rendered_digest != record["claude_rendered_sha256"]: diff --git a/src/skilldeck/_content_manifest.json b/src/skilldeck/_content_manifest.json index c67ff18..d746469 100644 --- a/src/skilldeck/_content_manifest.json +++ b/src/skilldeck/_content_manifest.json @@ -4,107 +4,107 @@ "skills": [ { "body_sha256": "sha256:366ffcaa3183fd009c3c59953b1e571ab88bdc426f277584b60198b2d8d47ab8", - "canonical_sha256": "sha256:e45d275cd8fd659af6b6503430812da86c7222726bf189c0cb457fa3bea391f9", + "canonical_sha256": "sha256:348085527ac5aaa2cfb61114357ad2eb5eba688d70a0a6cc10f103a89736025e", "claude_rendered_sha256": "sha256:43aa9cb65459b19211fa2349e5b4f9362f90638f23884ec159e709cbdd3109e4", - "meta_sha256": "sha256:faf1d1d2f3f630a6efce7f5df11e3290b9f4124ab9562d96a980fe89dfc242ab", + "meta_sha256": "sha256:2e0f9a777255b57f101c51c94a14c9caea8557f581cb8db9f1b46937aa81f7b9", "name": "authentication-review", - "version": "0.2.0" + "version": "0.2.1" }, { "body_sha256": "sha256:e80a382e92dd367d577a3bf2251be01713f7ffc9405008857609bcfdd5f387c9", - "canonical_sha256": "sha256:fb28b8bc8aeae01b9983f2b91f1019767416e8ef4c56feb91cb7ad418fd64e34", + "canonical_sha256": "sha256:20ed849d325c711ee35ce6bc46125ce6bb3d240fc4891791634ee4f8e8caa035", "claude_rendered_sha256": "sha256:e7f770e9e6f9b552bb0474364bc132887920c880fbbe3b2bba659b3448b6adc7", - "meta_sha256": "sha256:121aef136ba48413dd05dac91934798580ea762b99f01d349898469918a625a8", + "meta_sha256": "sha256:216940dcd5591f170760ab0350a4975ba55fbf50518a836cf594362719dd4477", "name": "ci-workflow-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:bf082cd5c84b729d6c3412fb83d4d771e7dd4d1e30bba11bdb2b58fc3208dd3f", - "canonical_sha256": "sha256:e4dcbee8ff34424923afdfb40cf0966c6cf2f388e086142d4e3208b7ce8e5e7d", + "canonical_sha256": "sha256:16d3b7a55046e42c4fca537da00c1e7764d835671a9e98fb0660c732efeabb8b", "claude_rendered_sha256": "sha256:aa4f2b00ce2a7d44f66689064a5acfdde873a2d45d292a8d50bf53ef9d2033df", - "meta_sha256": "sha256:d289c63d3178f50e004fa6f1eec3489f2a3bd0236e37b778af45d086c327f32a", + "meta_sha256": "sha256:e97b0202b8caf9d7f8b008665a37ff0ad4b9fe259cf5e50e83739434df9ceabb", "name": "code-smells", - "version": "0.3.0" + "version": "0.3.1" }, { - "body_sha256": "sha256:d7081d47124427a0bf25ae6eb56c527efe63063b17781da4ee34fc3a118be2b7", - "canonical_sha256": "sha256:b9fa88c17d1211310ef19ce8b938c33de0b2741a61631a3779f52742208f39eb", - "claude_rendered_sha256": "sha256:0f1f0e421c1ca3297cd865a003f9fd0c52b3f6cfaabbdf1f41ba11bd6ec9c878", - "meta_sha256": "sha256:4e4b70672db9b6046462f3fb880ea2ce14ffd6ff36703a848ce881141a5fb539", + "body_sha256": "sha256:45fe7888314ff411831fff7591dcda21a2fb99bbc54fbed8ca35a347bd030ce9", + "canonical_sha256": "sha256:4d76accd4200eba551eb67fd3300de4e2da02f65a8901a311012ff4d7e867bf5", + "claude_rendered_sha256": "sha256:07723f0640d98090d95a0d88424eb12b7f99a297be85396fba35eeaf4bd97807", + "meta_sha256": "sha256:71af058a1cdb60a4013b42b09240f58b8a487ed2c6dfd018a297c4c29bdf8a9d", "name": "dependency-review", - "version": "0.4.0" + "version": "0.5.0" }, { "body_sha256": "sha256:94ab468a0837179a8fa7470be404884cfc475f05f8be2314bff6e409ecffc5e5", - "canonical_sha256": "sha256:c3c2bde57bbb2c220da2f44c775e1d2bb71956915572094acd0fcf8a1c411186", + "canonical_sha256": "sha256:74930194c26657d0107d59520a6baa33e6ccdb3c3915773f137f0f11940ceb2f", "claude_rendered_sha256": "sha256:11d8bdbf3419cb7c4040bc02e19b502d5b64ba77e65aa2190e1cd51dc826d76a", - "meta_sha256": "sha256:6337828e91940730210bd7fea7690573610f9413d484d40a2596772c387b94fb", + "meta_sha256": "sha256:d2dc89cdda1beaeff5a10b911488e89a00a4bd4d3408cb7a41d58a0b92833cd2", "name": "frontend-security-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:5503c0d7457247cfa17b81e7485e4ea2d8bdcc00c244f77e32d9df5ffc24b67b", - "canonical_sha256": "sha256:d3b1a7aedb82eb14724253a447c1b67d9d3e8f5928f9b3555c7c17a9975bf529", + "canonical_sha256": "sha256:92e44ffd9f514cf5b12fe4c97ce955d50a1167ff63b317549b5a81ca89850870", "claude_rendered_sha256": "sha256:0e2496faefe746b3e1db50e470c509f1eaea8cb29c4e273f958088cdbd2589a7", - "meta_sha256": "sha256:8888d07def4742b7c284f44392a5b4cbdb3bf551736e4a3b93e71cc36cf76913", + "meta_sha256": "sha256:38ddbca88c334c54c4c22832018635394e8e5feed57ef129c749d90057bb28bc", "name": "iac-review", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:14be89e51271a75e43d0fcf7104bf18ca9d7516cf6da63d063e3af7cb4fa8b4f", - "canonical_sha256": "sha256:cbf0af07dc478897b976c9ecb15f708f4f94de73dd2999b9433a7d2a374f80df", + "canonical_sha256": "sha256:e36515eb30b2eec5c54f42e2846514b564dfafc5b530168439321d327721fea2", "claude_rendered_sha256": "sha256:e1f93c5321cd7bdb6eeff97c5f8888a67d27294e2abbfe491fac260db0e7ea60", - "meta_sha256": "sha256:a90e3c80901dc33337063e09d9cfcd2d39d25e0266acf0a6988fe5a941ad4e08", + "meta_sha256": "sha256:268509e2a047c432df346db1f7833d028f28566813b62658c8fb1b60176bfb22", "name": "llm-integration-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:7e407631107275697b6c090de6a68af8389fd8b4141bd0253a47e9688098c4b9", - "canonical_sha256": "sha256:31e1bc83cb9811b7f606890b740f7e514a4544a31240b40924547248d7537acf", - "claude_rendered_sha256": "sha256:ae40c0fbec5bf3fc24c81b9ad6c404ddab545001d20bc4d0c50082012d922120", - "meta_sha256": "sha256:52549724d30304bf9a59ae919d9d630e75f553b485d5d44ed183462d21a2c7c6", + "canonical_sha256": "sha256:5c6f68d8ff18c50b8660874b5117e4bb307c0cc8b1fea7daf8e06b4906d12966", + "claude_rendered_sha256": "sha256:8ba9fba7ddc27c986f41621f80b5b3726960044a69968ecca80777917ccde03c", + "meta_sha256": "sha256:75c39abf8f7ae9599ceaaf65bcc79776e04ccc0595f54949c52af1dcf204c730", "name": "logging", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:3e869f7883e118db18e8c8002f049d8ecf2895e49aa7c76f1d7a39a6ee842c8b", - "canonical_sha256": "sha256:2a90dcac4e099baf9018049929e4cd803b236afb4219da70f9e84ddc501d2189", + "canonical_sha256": "sha256:1a4c38c4b51a52b60330cd09de25aa7f1b30458fb23c2cb3dd1bdb9b705fc6a9", "claude_rendered_sha256": "sha256:4dfff856963c37785deb71d8385783df137a6bfe7bc692877d71a9bb5d3bc6f9", - "meta_sha256": "sha256:006759e87ea326c310ad454d4da8f1459c909a0ac25905a38c37edb8d8b743db", + "meta_sha256": "sha256:d891c2343b62c6881a84d338f873021c550e159c0cad06efc2e6d7059862a463", "name": "migration-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:063c8b0a474515dce78dac4196f49e2a0a25912e0d794a65c64387a9033448f5", - "canonical_sha256": "sha256:e499fd8757af810839c4421ee5affbfbb43bb72d9ff542fb40849583fb3e81c8", + "canonical_sha256": "sha256:2c47acd249f207d5264d9e321d71c1c09fecd1877384c7557468dc45a8c57dc6", "claude_rendered_sha256": "sha256:1f9838f8b4d1e7def32d9eb7f3d2a6f6b8a9bdabbc1572870dae68bc843d341c", - "meta_sha256": "sha256:d934b1fa74fcce6b2825c6e4c7744a4a57d1fbcd7d15577f864a4c72941d029c", + "meta_sha256": "sha256:d98573ef1c6b622a49fec659de9f9c7171f47ece73f8775536810f2e6abd1380", "name": "privacy-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:3a063e1f8e93f442e622c6da68eeb0196c33d16d1a7b114b85ae18d48e4222bc", - "canonical_sha256": "sha256:2c1e1682efcb2e62296711cd1e0a143a33228600498d70a6fee5a20fdb0f5a9c", + "canonical_sha256": "sha256:8bb47653d96e9a754e49fffa9ca57b10d56a7a285fada21f41e84f93b43ae7eb", "claude_rendered_sha256": "sha256:8df93b79fceab51ff41d49996253e07c3b9c50c0ea132487e32a233dc5bc770d", - "meta_sha256": "sha256:33ef3b93f0720a661cc686854b5a9f60e67cd5195a706aee97af125fb06e02a2", + "meta_sha256": "sha256:4c7cd02a783821b8452c14e2dcdd966c1cbca52f54b3e45042e9347b386073aa", "name": "resilience-review", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:b11892bc3f625828f52c4d90a33b68e1df581cea5bd0cf984d1e56f88efff7be", - "canonical_sha256": "sha256:f7fb3e489ba1dff5324dda4682166d60b745379401a26505e29efb8261f4f46c", + "canonical_sha256": "sha256:5a8fb1996e46d2b3e587d2b179a1b6d72b61dcc99e488a8769de742204d6b256", "claude_rendered_sha256": "sha256:5250d9e6f57d0830a16a2ead1b505b77d61e0a4edc82ce6e3b878a59f040e710", - "meta_sha256": "sha256:60a12438cf365b8d31db1c15a22bec93acf3b9067b791933405ff038a54ef447", + "meta_sha256": "sha256:065932102ec3a6ef7ba965ed7977a52f3dd1bc49a83c8869850fd4f3b1f412ee", "name": "security-review", - "version": "0.5.1" + "version": "0.5.2" }, { - "body_sha256": "sha256:49215350ea63412ec0c5cc0aad38d74546002014abaeb0327f60f65f8082a4af", - "canonical_sha256": "sha256:91137c7fd8927f8cae795444a6382252d65a7e6e828467b9d39ad194b72e1bee", - "claude_rendered_sha256": "sha256:bd0f9978ce38fd191bdc98030e000e6bf50ba965de6691bc700a9044851eb415", - "meta_sha256": "sha256:5aac68078cf57dc41c4b76faad23aa4fd4a00380acedfbb510733ea612629a6f", + "body_sha256": "sha256:d0bb69d296ac3ea2e6f3e42667a75656927d46bb50359d0e4bfe3a769b8a77e7", + "canonical_sha256": "sha256:f3ceba12771a669de0dac3b4860f426582562bc56f52a6ce716656666d5868be", + "claude_rendered_sha256": "sha256:655b92dc6c7d2a0d188a417d0a484abc9fc2cec8a7dffcc178f178c89f98a1e8", + "meta_sha256": "sha256:72306a4ab3cf5c20ad4493877d06dbcce3d6809ecb99f357b42e317053416327", "name": "test-review", - "version": "0.3.0" + "version": "0.3.1" } ] } diff --git a/src/skilldeck/adapters/base.py b/src/skilldeck/adapters/base.py index 57642f3..587b667 100644 --- a/src/skilldeck/adapters/base.py +++ b/src/skilldeck/adapters/base.py @@ -17,11 +17,23 @@ import yaml +from ..capabilities import with_notice from ..registry import Skill, SkillError from ..stamp import Stamp, parse, stamp from ..targets import Scope, UserDir, project_base +def rendered_body(skill: Skill) -> str: + """``skill``'s body as every adapter writes it. + + A skill that asks for more than reading files (commands, network, + credentials, agent tools, edits or new files) gets its declared + capabilities appended as a short Markdown section, so the declaration + travels with the installed file; any other body is written unchanged. + """ + return with_notice(skill.body, skill.capabilities) + + def yaml_frontmatter(fields: dict[str, object], *, wrap: bool = True) -> str: """Serialize ``fields`` into a YAML frontmatter block. @@ -87,6 +99,22 @@ def _entry_mode(path: Path) -> int | None: raise SkillError(f"cannot inspect {path}: {exc}") from exc +def _check_creatable(skill: Skill, dest: Path) -> None: + """Raise :class:`SkillError`, as ``install`` would on writing, unless the + nearest existing directory on the way to ``dest`` could hold it.""" + ancestor = dest.parent + while not os.path.lexists(ancestor) and ancestor != ancestor.parent: + ancestor = ancestor.parent + if not ancestor.is_dir(): + raise SkillError( + f"cannot install {skill.name} to {dest}: {ancestor} is not a directory" + ) + if not os.access(ancestor, os.W_OK | os.X_OK): + raise SkillError( + f"cannot install {skill.name} to {dest}: {ancestor} is not writable" + ) + + def _special_kind(mode: int) -> str: """Name what a non-regular, non-symlink entry is, for error messages.""" return "a directory" if stat.S_ISDIR(mode) else "a special file" @@ -241,7 +269,16 @@ def install( project_root: Path | None = None, *, force: bool = False, + dry_run: bool = False, ) -> Path: + """Write ``skill`` to its destination; return the destination. + + ``dry_run`` makes the checks a real install makes, raising the same + errors, then returns without writing anything. Instead of creating + the destination's directory it checks that the nearest part of that + path that exists is a directory it may write to; a real install can + still fail for a reason only writing reveals, such as a full disk. + """ self.check_scope(scope, installing=True) dest = self.destination(skill, scope, project_root) mode = _entry_mode(dest) @@ -273,6 +310,9 @@ def install( f"{dest} has local modifications; " "re-run with --force to overwrite them" ) + if dry_run: + _check_creatable(skill, dest) + return dest try: dest.parent.mkdir(parents=True, exist_ok=True) write_atomic(dest, self._stamped(skill)) diff --git a/src/skilldeck/adapters/legacy.py b/src/skilldeck/adapters/legacy.py index c449167..9f7031d 100644 --- a/src/skilldeck/adapters/legacy.py +++ b/src/skilldeck/adapters/legacy.py @@ -14,7 +14,7 @@ from ..registry import Skill, SkillError from ..targets import Scope, UserDir -from .base import Adapter, yaml_frontmatter +from .base import Adapter, rendered_body, yaml_frontmatter class LegacyAdapter(Adapter): @@ -78,7 +78,7 @@ def render(self, skill: Skill) -> str: "description": skill.description, "agent": "agent", } - return f"{yaml_frontmatter(fields)}\n{skill.body}" + return f"{yaml_frontmatter(fields)}\n{rendered_body(skill)}" class CursorRuleAdapter(LegacyAdapter): @@ -120,7 +120,7 @@ def render(self, skill: Skill) -> str: "backslash, or an unprintable character)" ) front = f'---\ndescription: "{text}"\nalwaysApply: false\n---\n' - return f"{front}\n{skill.body}" + return f"{front}\n{rendered_body(skill)}" def _mdc_value(text: str) -> str: @@ -155,7 +155,7 @@ class KiroSteeringAdapter(LegacyAdapter): def render(self, skill: Skill) -> str: # Static frontmatter — no skill fields are interpolated, so there is # no injection surface here. - return f"---\ninclusion: manual\n---\n\n{skill.body}" + return f"---\ninclusion: manual\n---\n\n{rendered_body(skill)}" class OldKiroSteeringAdapter(KiroSteeringAdapter): @@ -191,4 +191,5 @@ class CodexPromptAdapter(LegacyAdapter): unstamped_installs = True def render(self, skill: Skill) -> str: + # the body alone, as skilldeck 0.3.0 wrote it; never installed now return skill.body diff --git a/src/skilldeck/adapters/skill_md.py b/src/skilldeck/adapters/skill_md.py index 64fc357..9d76d7d 100644 --- a/src/skilldeck/adapters/skill_md.py +++ b/src/skilldeck/adapters/skill_md.py @@ -13,7 +13,7 @@ from pathlib import Path from ..registry import Skill -from .base import Adapter, yaml_frontmatter +from .base import Adapter, rendered_body, yaml_frontmatter class SkillMdAdapter(Adapter): @@ -28,4 +28,4 @@ def render(self, skill: Skill) -> str: "name": skill.name, "description": skill.description, } - return f"{yaml_frontmatter(fields)}\n{skill.body}" + return f"{yaml_frontmatter(fields)}\n{rendered_body(skill)}" diff --git a/src/skilldeck/capabilities.py b/src/skilldeck/capabilities.py new file mode 100644 index 0000000..210671b --- /dev/null +++ b/src/skilldeck/capabilities.py @@ -0,0 +1,428 @@ +"""Skill capability declarations. + +Every skill's ``meta.yaml`` carries a ``capabilities`` block saying what the +skill may ask an agent to do beyond following its text: the files it reads and +edits, the commands it may run, what it contacts over the network and why, the +credentials it handles, the agent tools it needs beyond reading files and +running those commands, and the files it may create. ``schema`` versions the +block's format; this module reads schema 1. + +A declaration is for review, not enforcement. Skilldeck cannot sandbox the +agents it installs into, and no metadata makes a malicious instruction safe. +Anything a skill does not declare, it does not request. +``docs/authoring-skills.md`` documents the format. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import TypedDict + +#: the capability schema version this module reads and writes +CAPABILITY_SCHEMA = 1 + +#: every key of the ``capabilities`` block, all required +FIELDS = ("schema", "files", "commands", "network", "credentials", "tools", "artifacts") +FILE_FIELDS = ("read", "write") +#: how much of the project the skill reads: nothing, the changed files, or any +#: file in the repository +READ_SCOPES = ("none", "diff", "repo") +#: whether it edits files already in the project +WRITE_SCOPES = ("none", "repo") +#: the list fields, in the order summaries show them +LIST_FIELDS = ("commands", "network", "credentials", "tools", "artifacts") +MAX_ENTRY_LENGTH = 200 +#: read-only git commands a review needs; a skill that asks for no more than +#: these (and the git remote they contact) and reading files is rendered +#: without a notice +GIT_BASELINE = frozenset( + { + "git fetch", + "git diff", + "git ls-files", + "git log", + "git show", + "git status", + "git blame", + } +) +#: suffixes of files a shell, an interpreter or the OS runs as a program +SCRIPT_SUFFIXES = frozenset( + { + ".app", ".bash", ".bat", ".bin", ".cjs", ".cmd", ".com", ".command", + ".csh", ".dll", ".dylib", ".exe", ".fish", ".jar", ".js", ".ksh", + ".lua", ".mjs", ".msi", ".php", ".pl", ".ps1", ".psm1", ".py", ".pyw", + ".rb", ".scr", ".sh", ".so", ".ts", ".vbs", ".wsf", ".zsh", + } +) # fmt: skip +# Interpreters: declaring one with a script (a path, or a file with a script +# suffix) or inline code (-c, -e, ...) would run code the skill cannot ship +# or the declaration does not show. +_INTERPRETERS = frozenset( + { + "bash", "dash", "deno", "fish", "ksh", "node", "perl", "php", + "powershell", "pwsh", "python", "python3", "ruby", "sh", "zsh", + } +) # fmt: skip +_INLINE_CODE_FLAGS = frozenset( + {"--command", "--eval", "--print", "-command", "-encodedcommand", "-file"} +) + +# A command names a program on PATH (never a path to a file: a skill ships no +# scripts) followed by its subcommand or arguments, single-spaced. A +# placeholder in angle brackets stands for a command the project defines. +_PROGRAM_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]*") +_PLACEHOLDER_RE = re.compile(r"<[^<>`]+>") +# A declared command is one simple command: a pipeline, a redirection or a +# substitution would hide what actually runs. +_SHELL_CHARS = frozenset(";|&$<>()`") +# One component of a declared path: no separators, and not "." or "..". +_SEGMENT_RE = re.compile(r"[A-Za-z0-9._-]+") +_DRIVE_RE = re.compile(r"[A-Za-z]:") + +_SHAPE = ( + "capabilities must be a mapping of schema (1), files (read, write), " + "commands, network, credentials, tools and artifacts; see " + "docs/authoring-skills.md" +) +_READ_TEXT = { + "none": "reads no files", + "diff": "reads the changed files", + "repo": "reads the repository", +} +_WRITE_TEXT = { + "none": "edits no files", + "repo": "may edit files in the repository", +} +# (label, field) of each list the notice shows, in order; commands and paths +# are shown as code +_CODE_FIELDS = frozenset({"commands", "artifacts"}) +# How the notice opens, by read scope, and the verb it gives each description +# list. The notice speaks to the agent. +_NOTICE_LEAD = { + "repo": "Beyond reading the repository, this skill asks you to:", + "diff": "Beyond reading the changed files, this skill asks you to:", + "none": "This skill reads none of your files. It asks you to:", +} +_NOTICE_VERBS = ( + ("network", "contact"), + ("credentials", "use these credentials:"), + ("tools", "use these agent tools:"), +) + + +class CapabilityError(ValueError): + """A ``capabilities`` block that breaks the schema.""" + + +class FileCapabilities(TypedDict): + read: str + write: str + + +class CapabilityRecord(TypedDict): + """A declaration as plain data, for the catalog.""" + + schema: int + files: FileCapabilities + commands: list[str] + network: list[str] + credentials: list[str] + tools: list[str] + artifacts: list[str] + + +@dataclass(frozen=True) +class Capabilities: + """What a skill may ask an agent to do; the default requests nothing.""" + + read: str = "none" + write: str = "none" + #: commands it may ask the agent to run, e.g. ``git diff`` + commands: tuple[str, ...] = () + #: what it may contact, and why + network: tuple[str, ...] = () + #: secrets it asks the agent to read, pass on or send + credentials: tuple[str, ...] = () + #: agent tools it needs beyond reading files and running ``commands`` + tools: tuple[str, ...] = () + #: project-relative paths of files it may create + artifacts: tuple[str, ...] = () + + @property + def beyond_review_baseline(self) -> bool: + """Whether it asks for more than a read-only review: reading files and + running :data:`GIT_BASELINE` commands, with the git remote they reach. + + That is, an edit, a credential, an agent tool, a new file, or any + other command. Network use alone doesn't count: without another + command or tool, the git remote is the only thing a skill can reach. + """ + return ( + self.write != "none" + or bool(self.credentials or self.tools or self.artifacts) + or any(command not in GIT_BASELINE for command in self.commands) + ) + + def record(self) -> CapabilityRecord: + return { + "schema": CAPABILITY_SCHEMA, + "files": {"read": self.read, "write": self.write}, + "commands": list(self.commands), + "network": list(self.network), + "credentials": list(self.credentials), + "tools": list(self.tools), + "artifacts": list(self.artifacts), + } + + +def parse_capabilities(raw: object) -> Capabilities: + """Validate a decoded ``capabilities`` block; raise :class:`CapabilityError`. + + Every key is required, so each skill states each capability, if only as + ``[]`` or ``none``; an unknown key is an error rather than ignored. + """ + if not isinstance(raw, dict): + raise CapabilityError(_SHAPE) + _exact_keys(raw, FIELDS, "capabilities") + schema = raw["schema"] + if type(schema) is not int or schema != CAPABILITY_SCHEMA: + raise CapabilityError( + f"capabilities.schema must be {CAPABILITY_SCHEMA}, the capability " + f"schema this skilldeck reads, not {schema!r}" + ) + files = raw["files"] + if not isinstance(files, dict): + raise CapabilityError( + "capabilities.files must be a mapping with read " + f"({' | '.join(READ_SCOPES)}) and write ({' | '.join(WRITE_SCOPES)})" + ) + _exact_keys(files, FILE_FIELDS, "capabilities.files") + read = _choice(files["read"], READ_SCOPES, "capabilities.files.read") + write = _choice(files["write"], WRITE_SCOPES, "capabilities.files.write") + return Capabilities( + read=read, + write=write, + commands=_entries(raw, "commands", _check_command), + network=_entries(raw, "network", _check_text), + credentials=_entries(raw, "credentials", _check_text), + tools=_entries(raw, "tools", _check_text), + artifacts=_entries(raw, "artifacts", _check_path), + ) + + +def _exact_keys(raw: dict[object, object], fields: tuple[str, ...], where: str) -> None: + unknown = sorted(str(key) for key in raw if key not in fields) + if unknown: + raise CapabilityError( + f"{where} has unknown field(s): {', '.join(unknown)}; " + f"the fields are {', '.join(fields)}" + ) + missing = [field for field in fields if field not in raw] + if missing: + raise CapabilityError( + f"{where} missing field(s): {', '.join(missing)}; declare each " + "one, as [] or none when the skill does not need it" + ) + + +def _choice(value: object, choices: tuple[str, ...], where: str) -> str: + if not isinstance(value, str) or value not in choices: + raise CapabilityError( + f"{where} must be one of {', '.join(choices)}, not {value!r}" + ) + return value + + +def _entries( + raw: dict[object, object], field: str, check: Callable[[str, str], None] +) -> tuple[str, ...]: + where = f"capabilities.{field}" + value = raw[field] + if not isinstance(value, list): + raise CapabilityError( + f"{where} must be a list ([] when the skill needs none), not {value!r}" + ) + for entry in value: + if not isinstance(entry, str): + raise CapabilityError(f"{where} entries must be strings, not {entry!r}") + check(entry, where) + duplicates = sorted({entry for entry in value if value.count(entry) > 1}) + if duplicates: + raise CapabilityError( + f"{where} lists entries more than once: {', '.join(duplicates)}" + ) + return tuple(value) + + +def _check_text(entry: str, where: str) -> None: + """A short, single line of printable text with no outer whitespace.""" + if not entry.strip(): + raise CapabilityError(f"{where} entries must not be empty") + if ( + entry != entry.strip() + or entry.splitlines() != [entry] + or not entry.isprintable() + ): + raise CapabilityError( + f"{where} entry {entry!r} must be one line of printable text " + "without leading or trailing spaces" + ) + if len(entry) > MAX_ENTRY_LENGTH: + raise CapabilityError( + f"{where} entry is {len(entry)} characters; the limit is {MAX_ENTRY_LENGTH}" + ) + + +def _check_command(entry: str, where: str) -> None: + _check_text(entry, where) + if _PLACEHOLDER_RE.fullmatch(entry): + return + program = entry.split(" ", 1)[0] + if " ".join(entry.split()) != entry or _SHELL_CHARS.intersection(entry): + raise CapabilityError( + f"{where} entry {entry!r} must be one command such as `git diff`, " + "single-spaced, without shell operators, redirections or " + "substitutions (; | & $ < > ( ) and backticks)" + ) + if "/" in program or "\\" in program: + raise CapabilityError( + f"{where} entry {entry!r} runs a file by its path; a command must " + "name a program on PATH, since a skill ships no scripts" + ) + if program in _INTERPRETERS and any( + _runs_code(argument) for argument in entry.split(" ")[1:] + ): + raise CapabilityError( + f"{where} entry {entry!r} has {program} run a script or inline " + "code; a command must name a program on PATH, since a skill ships " + "no scripts. Declare a command the project defines as a " + "" + ) + if not _PROGRAM_RE.fullmatch(program): + raise CapabilityError( + f"{where} entry {entry!r} must start with a program name " + "(letters, digits, '.', '_', '+' and '-'), or be a " + "for a command the project defines" + ) + + +def _runs_code(argument: str) -> bool: + """Whether an interpreter's ``argument`` names a script or passes code: + a path, a file with a script suffix, or an inline-code flag (``-c``, + ``-e``, ``-E``, also inside a cluster such as ``-lc``).""" + if "/" in argument or "\\" in argument: + return True + if any(argument.lower().endswith(suffix) for suffix in SCRIPT_SUFFIXES): + return True + if argument.lower() in _INLINE_CODE_FLAGS: + return True + cluster = argument[1:] + return ( + argument.startswith("-") + and not argument.startswith("--") + and cluster.isalpha() + and any(flag in cluster for flag in "ceE") + and argument.lower() not in {"-version", "-help"} + ) + + +def _check_path(entry: str, where: str) -> None: + """A relative POSIX path that stays inside the project.""" + _check_text(entry, where) + reason = None + if "\\" in entry: + reason = "use / as the separator, not \\" + elif entry.startswith("/"): + reason = "it is absolute" + elif _DRIVE_RE.match(entry): + reason = "it names a drive" + elif entry.startswith("~"): + reason = "it names a home directory" + else: + segments = entry.split("/") + if any(segment in ("", ".", "..") for segment in segments): + reason = "it has an empty, '.' or '..' component" + elif not all(_SEGMENT_RE.fullmatch(segment) for segment in segments): + reason = "use only letters, digits, '.', '_', '-' and '/'" + if reason: + raise CapabilityError( + f"{where} entry {entry!r} must be a relative path inside the " + f"project: {reason}" + ) + + +def files_text(capabilities: Capabilities) -> str: + """The ``files`` declaration in words.""" + return f"{_READ_TEXT[capabilities.read]}; {_WRITE_TEXT[capabilities.write]}" + + +def summary(capabilities: Capabilities) -> list[tuple[str, tuple[str, ...]]]: + """``(label, lines)`` for each capability, for a human-readable summary. + + Commands and paths share one line, comma-separated; each description gets + a line of its own. A capability the skill does not request reads "none". + """ + rows: list[tuple[str, tuple[str, ...]]] = [("files", (files_text(capabilities),))] + for field in LIST_FIELDS: + entries: tuple[str, ...] = getattr(capabilities, field) + if not entries: + rows.append((field, ("none",))) + elif field in _CODE_FIELDS: + rows.append((field, (", ".join(entries),))) + else: + rows.append((field, entries)) + return rows + + +def notice(capabilities: Capabilities) -> str: + """The Markdown section adapters append to a skill that asks for more + than a read-only review (:attr:`Capabilities.beyond_review_baseline`); + empty for one that doesn't. + + It speaks to the agent: everything the skill asks for beyond reading + files, every command (the git baseline included) and every network use, + then that it asks for nothing else. Commands and paths are code, listed + inline; descriptions get an item each once there are several. It adds no + instruction of its own. + """ + if not capabilities.beyond_review_baseline: + return "" + items: list[str] = [] + if capabilities.commands: + commands = ", ".join(f"`{command}`" for command in capabilities.commands) + items.append(f"- run {commands}") + if capabilities.write == "repo": + items.append("- edit files in the repository") + if capabilities.artifacts: + created = ", ".join(f"`{path}`" for path in capabilities.artifacts) + items.append(f"- create {created}") + for field, verb in _NOTICE_VERBS: + entries: tuple[str, ...] = getattr(capabilities, field) + if len(entries) == 1: + items.append(f"- {verb} {entries[0]}") + elif entries: + items.append(f"- {verb.rstrip(':')}:") + items.extend(f" - {entry}" for entry in entries) + lines = [ + "## Declared capabilities", + "", + _NOTICE_LEAD[capabilities.read], + "", + *items, + "", + "It asks for nothing else.", + ] + return "\n".join(lines) + "\n" + + +def with_notice(body: str, capabilities: Capabilities) -> str: + """``body`` as adapters render it: followed by :func:`notice`, if any.""" + text = notice(capabilities) + if not text: + return body + if not body.endswith("\n"): + body += "\n" + return f"{body}\n{text}" diff --git a/src/skilldeck/catalog.py b/src/skilldeck/catalog.py index 87c31b2..0a82836 100644 --- a/src/skilldeck/catalog.py +++ b/src/skilldeck/catalog.py @@ -7,6 +7,7 @@ from the skill files and must equal the packaged content manifest's record, the digest ``skilldeck provenance --verify`` checks; ``rendered_sha256`` gives, per native agent, the ``hash=`` an install stamp records for that agent's file. +``capabilities`` is the skill's capability declaration (see ``capabilities``). """ from __future__ import annotations @@ -16,6 +17,7 @@ from typing import TypedDict from .adapters import ADAPTERS +from .capabilities import CapabilityRecord from .provenance import ( REPOSITORY_URL, ContentManifest, @@ -55,6 +57,7 @@ class CatalogSkill(TypedDict): rendered_sha256: dict[str, str] source: CatalogSource deprecated: CatalogDeprecation | None + capabilities: CapabilityRecord class Catalog(TypedDict): @@ -96,6 +99,7 @@ def _entry(skill: Skill, digest: str) -> CatalogSkill: "path": f"{SKILLS_SOURCE_PATH}/{skill.name}", }, "deprecated": deprecated, + "capabilities": skill.capabilities.record(), } diff --git a/src/skilldeck/catalog.schema.json b/src/skilldeck/catalog.schema.json index 9d309de..3d8a903 100644 --- a/src/skilldeck/catalog.schema.json +++ b/src/skilldeck/catalog.schema.json @@ -72,7 +72,8 @@ "canonical_sha256", "rendered_sha256", "source", - "deprecated" + "deprecated", + "capabilities" ], "properties": { "name": {"$ref": "#/$defs/skill_name"}, @@ -128,6 +129,71 @@ }, "reason": {"type": "string", "minLength": 1} } + }, + "capabilities": {"$ref": "#/$defs/capabilities"} + } + }, + "capability_entries": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "minLength": 1, "maxLength": 200} + }, + "capabilities": { + "description": "What the skill declares it may ask an agent to do (docs/authoring-skills.md#capabilities). A declaration for review that nothing enforces; anything not listed is not requested.", + "type": "object", + "required": [ + "schema", + "files", + "commands", + "network", + "credentials", + "tools", + "artifacts" + ], + "properties": { + "schema": { + "description": "The capability schema of the declaration. A new capability schema is a breaking catalog change: it bumps schema_version (see docs/catalog.md).", + "const": 1 + }, + "files": { + "type": "object", + "required": ["read", "write"], + "properties": { + "read": { + "description": "none, diff (the changed files) or repo (any file in the repository).", + "enum": ["none", "diff", "repo"] + }, + "write": { + "description": "none, or repo: the skill may edit files in the repository.", + "enum": ["none", "repo"] + } + } + }, + "commands": { + "description": "Commands the skill may ask the agent to run, e.g. \"git diff\" (with any arguments); \"<...>\" stands for a command the project defines.", + "$ref": "#/$defs/capability_entries" + }, + "network": { + "description": "What the skill may contact, and why.", + "$ref": "#/$defs/capability_entries" + }, + "credentials": { + "description": "Secrets the skill asks the agent to read, pass on or send.", + "$ref": "#/$defs/capability_entries" + }, + "tools": { + "description": "Agent tools the skill needs beyond reading files and running its commands.", + "$ref": "#/$defs/capability_entries" + }, + "artifacts": { + "description": "Files the skill may create, as POSIX paths relative to the project root.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(?:(?!\\.\\.?/)[A-Za-z0-9._-]+/)*(?!\\.\\.?$)[A-Za-z0-9._-]+$", + "maxLength": 200 + } } } } diff --git a/src/skilldeck/cli.py b/src/skilldeck/cli.py index eeb43af..ed47f30 100644 --- a/src/skilldeck/cli.py +++ b/src/skilldeck/cli.py @@ -17,8 +17,23 @@ InstallState, LegacyAdapter, ) -from .catalog import CatalogError, build_catalog, catalog_schema_text, filter_catalog -from .provenance import canonical_json, distribution_provenance, verify_bundled_skills +from .capabilities import summary as capability_summary +from .catalog import ( + SKILLS_SOURCE_PATH, + CatalogError, + build_catalog, + catalog_schema_text, + filter_catalog, +) +from .provenance import ( + REPOSITORY_URL, + canonical_json, + canonical_skill_digest, + distribution_provenance, + load_build_metadata, + load_content_manifest, + verify_bundled_skills, +) from .registry import Skill, SkillError, discover_skills from .stamp import read as read_stamp from .targets import Scope @@ -279,6 +294,68 @@ def _warn_deprecated(skills: Iterable[Skill]) -> None: ) +def _built_from() -> str: + """Where this package's build metadata says it was built from.""" + try: + build = load_build_metadata() + except ValueError as exc: + return f"unknown ({exc})" + if build["source_ref"] is None: + return "a development build, with no release tag or commit" + return f"{build['source_ref']}, commit {build['source_commit']}" + + +def _digest_status(skill: Skill) -> str: + """``skill``'s canonical digest, and whether it matches the content + manifest this package shipped with (a claim of the package itself).""" + try: + meta_text = (skill.path / "meta.yaml").read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + return f"unavailable (cannot read meta.yaml: {exc})" + digest = canonical_skill_digest(meta_text, skill.body) + try: + records = load_content_manifest()["skills"] + except ValueError as exc: + return f"{digest} (unverified: {exc})" + recorded = {record["name"]: record["canonical_sha256"] for record in records} + expected = recorded.get(skill.name) + if expected is None: + return f"{digest} (NOT in the content manifest shipped in this package)" + if digest != expected: + return ( + f"{digest} (does NOT match the content manifest shipped in this " + f"package: {expected})" + ) + return f"{digest} (matches the content manifest shipped in this package)" + + +def _summary_lines(skill: Skill) -> list[str]: + """What ``skill`` is, where it comes from, and what it declares it may ask + an agent to do: the preview ``show --summary`` and ``install --dry-run`` + print.""" + deprecated = "no" + if skill.deprecated is not None: + note = _deprecation_note(skill.deprecated.since, skill.deprecated.replacement) + deprecated = f"{note} ({skill.deprecated.reason})" + lines = [ + f"{skill.name} {skill.version} ({skill.category})", + f" {skill.description}", + f" source: {REPOSITORY_URL}, {SKILLS_SOURCE_PATH}/{skill.name}", + f" built from: {_built_from()} (recorded at build)", + f" digest: {_digest_status(skill)}", + " verify: both lines above are the package's own records; " + "skilldeck provenance --verify re-hashes the installed skills, and " + "docs/verifying-releases.md checks the package itself", + f" deprecated: {deprecated}", + " capabilities (declared for review, not enforced; anything not listed " + "is not requested):", + ] + for label, entries in capability_summary(skill.capabilities): + lines.append(f" {label + ':':<13}{entries[0]}") + lines.extend(f" {'':<13}{entry}" for entry in entries[1:]) + return lines + + def _echo_json(data: object) -> None: """Print ``data`` as canonical JSON, as UTF-8 bytes with ``\\n`` newlines. @@ -395,17 +472,28 @@ def catalog( is_flag=True, help="Overwrite locally modified or unmanaged destination files.", ) +@click.option( + "--dry-run", + is_flag=True, + help="Write nothing: preview each skill's source, digest and declared " + "capabilities, and what installing it would do.", +) def install( names: tuple[str, ...], install_all: bool, agents: tuple[str, ...], scope: str, force: bool, + dry_run: bool, ) -> None: """Install one or more skills for the chosen agent(s).""" scope_enum = Scope(scope) skills = _resolve_skills(names, install_all) adapters, failed = _resolve_adapters(agents, scope_enum, installing=True) + if dry_run: + if not _preview_install(skills, adapters, scope_enum, force) or failed: + raise SystemExit(1) + return installed: set[Skill] = set() for adapter in adapters: for skill in skills: @@ -427,6 +515,51 @@ def install( raise SystemExit(1) +def _preview_install( + skills: list[Skill], adapters: list[Adapter], scope: Scope, force: bool +) -> bool: + """Print what ``install`` would do, writing nothing; return whether every + install would succeed. + + Each skill's summary comes first, then one line per adapter. The checks + are a real install's (``Adapter.install`` with ``dry_run``), with the + same errors on stderr, except that instead of creating the destination's + folder it checks the folder could be created; a failure only writing + reveals, such as a full disk, still shows up only in a real install. + """ + ok = True + for index, skill in enumerate(skills): + if index: + click.echo() + for line in _summary_lines(skill): + click.echo(line) + for adapter in adapters: + if not adapter.supports(skill): + click.echo( + f"skip {skill.name}: not supported by {adapter.name}", err=True + ) + continue + try: + state, found = adapter.inspect(skill, scope) + dest = adapter.install(skill, scope, force=force, dry_run=True) + except SkillError as exc: + click.echo(f"error: {exc}", err=True) + ok = False + continue + action = "would install" + if state is InstallState.STALE and found is not None: + action = f"would update ({found.version} -> {skill.version})" + elif state is InstallState.CURRENT: + action = "would rewrite (up to date)" + elif state is InstallState.MODIFIED: + action = "would overwrite local modifications" + elif state is InstallState.UNMANAGED: + action = "would overwrite a file without a skilldeck stamp" + click.echo(f" {adapter.name}: {action} -> {dest}") + click.echo("dry run: nothing was written") + return ok + + @cli.command() @click.argument("names", nargs=-1) @click.option("--all", "uninstall_all", is_flag=True, help="Uninstall every skill.") @@ -473,12 +606,24 @@ def uninstall( default=None, help="Preview the rendered output for this agent instead of the raw body.", ) -def show(name: str, agent: str | None) -> None: - """Print a skill's body, or its rendered per-agent output.""" +@click.option( + "--summary", + is_flag=True, + help="Print the skill's source, digest and declared capabilities instead.", +) +def show(name: str, agent: str | None, summary: bool) -> None: + """Print a skill's body, its rendered per-agent output, or a summary of + where it comes from and what it may ask an agent to do.""" + if summary and agent is not None: + raise click.UsageError("give --summary or --agent, not both") by_name = {skill.name: skill for skill in _all_skills()} if name not in by_name: raise SkillError(f"unknown skill: {name}") skill = by_name[name] + if summary: + for line in _summary_lines(skill): + click.echo(line) + return if agent is None: text = skill.body else: diff --git a/src/skilldeck/provenance.py b/src/skilldeck/provenance.py index bbbc636..2f66e75 100644 --- a/src/skilldeck/provenance.py +++ b/src/skilldeck/provenance.py @@ -16,7 +16,14 @@ from . import __version__ from .adapters import ADAPTERS -from .registry import DEFAULT_SKILLS_DIR, Skill, discover_skills +from .registry import ( + BUNDLE_FILES, + DEFAULT_SKILLS_DIR, + Skill, + discover_skills, + is_link, + link_kind, +) SCHEMA_VERSION = 1 PACKAGE_NAME = "skilldeck" @@ -279,9 +286,12 @@ def verify_bundled_skills(skills_dir: Path | None = None) -> list[str]: Returns one message per problem: a skill whose ``meta.yaml`` or ``skill.md`` no longer hashes to the packaged content manifest, a skill - that is missing or unreadable, and any file or skill directory the - manifest does not list. An empty list means the installed skills are - exactly the ones the manifest records. + that is missing or unreadable, any skill directory the manifest does not + list, any other entry in a skill directory (OS and editor leftovers + included: this is the release-integrity check, stricter than loading a + skill), and a skill directory, ``meta.yaml`` or ``skill.md`` that is a + symlink or junction. An empty list means the installed skills are exactly + the ones the manifest records. """ root = skills_dir or DEFAULT_SKILLS_DIR records = {record["name"]: record for record in load_content_manifest()["skills"]} @@ -304,9 +314,16 @@ def verify_bundled_skills(skills_dir: Path | None = None) -> list[str]: except (OSError, UnicodeDecodeError) as exc: problems.append(f"{name}: cannot read the bundled skill: {exc}") continue - extras = [entry for entry in entries if entry not in {"meta.yaml", "skill.md"}] + extras = [entry for entry in entries if entry not in BUNDLE_FILES] if extras: problems.append(f"{name}: unexpected file(s): {', '.join(extras)}") + if is_link(skill_dir): + problems.append(f"{name}: the skill directory is a {link_kind(skill_dir)}") + problems.extend( + f"{name}: {filename} is a {link_kind(skill_dir / filename)}" + for filename in BUNDLE_FILES + if is_link(skill_dir / filename) + ) if canonical_skill_digest(meta_text, body_text) != record["canonical_sha256"]: problems.append( f"{name}: installed files do not match canonical digest " diff --git a/src/skilldeck/registry.py b/src/skilldeck/registry.py index fce7659..b53c9c2 100644 --- a/src/skilldeck/registry.py +++ b/src/skilldeck/registry.py @@ -1,18 +1,23 @@ """Discovery and loading of canonical skills. -A skill lives in ``skills//`` and is made of two files: +A skill lives in ``skills//`` and is made of exactly two files: * ``meta.yaml`` -- metadata (name, description, category, version, supported - agents, and an optional ``deprecated`` record) + agents, capabilities, and an optional ``deprecated`` record) * ``skill.md`` -- the agent-neutral skill body / prompt This module turns those into :class:`Skill` objects. Adapters consume them to render agent-specific output; nothing here knows about a particular agent. +Loading also enforces the bundle rules: nothing else in the directory (no +scripts, assets or symlinks), and no link in ``skill.md`` to a file the skill +would need to ship. """ from __future__ import annotations +import os import re +import stat from collections.abc import Collection from dataclasses import dataclass from pathlib import Path @@ -20,12 +25,26 @@ import yaml +from .capabilities import ( + SCRIPT_SUFFIXES, + Capabilities, + CapabilityError, + parse_capabilities, +) + # Bundled ``skills/`` directory, co-located with this module inside the package. # Resolving relative to ``__file__`` works identically for an editable checkout # and an installed wheel, since hatchling ships the skill files alongside the code. DEFAULT_SKILLS_DIR = Path(__file__).resolve().parent / "skills" -REQUIRED_FIELDS = ("name", "description", "category", "version", "supported-agents") +REQUIRED_FIELDS = ( + "name", + "description", + "category", + "version", + "supported-agents", + "capabilities", +) # Every key meta.yaml may carry; anything else (say, a misspelt ``depreciated``) # is an error rather than silently ignored. ALLOWED_FIELDS = (*REQUIRED_FIELDS, "deprecated") @@ -46,6 +65,48 @@ # ``deprecated`` is optional; absent means the skill is not deprecated. DEPRECATION_FIELDS = ("since", "reason", "replacement") +#: everything a skill directory may hold. Skilldeck installs one file per +#: skill, so a bundle has no way to carry a script, an asset or a link, and a +#: skill's metadata cannot declare one. +BUNDLE_FILES = ("meta.yaml", "skill.md") +# Files an OS or editor leaves next to the ones you edit: macOS Finder and +# AppleDouble files, Windows thumbnail and folder settings, Python bytecode, +# and Emacs and Vim backup, lock, autosave and swap files. Loading a skill +# ignores them, so one stray file doesn't break every command; +# ``provenance --verify`` (the release-integrity check) still reports them. +_JUNK_NAMES = frozenset({".DS_Store", "Thumbs.db", "desktop.ini", "__pycache__"}) +_JUNK_RE = re.compile(r"\._.*|\.#.*|.*~|#.*#|\..+\.sw[a-p]", re.S) +# Leading bytes of native binaries: ELF, PE (MZ) and Mach-O (32/64-bit, both +# byte orders, and universal). +_BINARY_MAGIC = ( + b"\x7fELF", + b"MZ", + b"\xfe\xed\xfa\xce", + b"\xfe\xed\xfa\xcf", + b"\xce\xfa\xed\xfe", + b"\xcf\xfa\xed\xfe", + b"\xca\xfe\xba\xbe", +) +# Markdown the link check must skip: fenced and indented code blocks, and +# code spans. +_FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})") +_LIST_ITEM_RE = re.compile(r"(?:[-*+]|[0-9]{1,9}[.)])(?: |$)") +_CODE_SPAN_RE = re.compile(r"(?]*)"), + re.compile(r"^ {0,3}\[[^\]]+\]:\s*]+)", re.M), + re.compile(r"<([A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*)>"), +) +_HTML_TAG_RE = re.compile(r"<[A-Za-z][A-Za-z0-9-]*\s[^<>]*>") +_HTML_LINK_ATTR_RE = re.compile( + r"""(?]+)""", re.I +) +_SCHEME_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:") +#: link schemes that resolve wherever the installed file ends up +WEB_SCHEMES = ("http", "https", "mailto") + class SkillError(Exception): """Raised when a skill directory is malformed.""" @@ -70,6 +131,8 @@ class Skill: body: str path: Path deprecated: Deprecation | None = None + #: what the skill may ask an agent to do; the default requests nothing + capabilities: Capabilities = Capabilities() def load_skill(skill_dir: Path, known_agents: Collection[str] | None = None) -> Skill: @@ -82,6 +145,18 @@ def load_skill(skill_dir: Path, known_agents: Collection[str] | None = None) -> meta_path = skill_dir / "meta.yaml" body_path = skill_dir / "skill.md" + if is_link(skill_dir): + raise SkillError( + f"{skill_dir}: the skill directory is a {link_kind(skill_dir)}" + ) + problems = bundle_problems(skill_dir) if skill_dir.is_dir() else [] + if problems: + raise SkillError( + f"{skill_dir}: {'; '.join(problems)}. A skill directory holds only " + "meta.yaml and skill.md, as regular files: skilldeck installs one " + "file per skill, so a skill cannot ship (or declare) scripts, " + "assets or links" + ) if not meta_path.is_file(): raise SkillError(f"{skill_dir}: missing meta.yaml") if not body_path.is_file(): @@ -163,10 +238,23 @@ def load_skill(skill_dir: Path, known_agents: Collection[str] | None = None) -> else None ) + try: + capabilities = parse_capabilities(meta["capabilities"]) + except CapabilityError as exc: + raise SkillError(f"{skill_dir}: meta.yaml {exc}") from exc + try: body = body_path.read_text(encoding="utf-8") except UnicodeDecodeError as exc: raise SkillError(f"{skill_dir}: skill.md is not valid UTF-8: {exc}") from exc + missing_assets = local_links(body) + if missing_assets: + raise SkillError( + f"{skill_dir}: skill.md links to {', '.join(missing_assets)}, which " + "the skill cannot ship: a skill is only meta.yaml and skill.md, so " + "a relative or file link has nothing to resolve to once installed. " + f"Link to a web page ({', '.join(WEB_SCHEMES)}) or a #heading instead" + ) return Skill( name=name, @@ -177,9 +265,158 @@ def load_skill(skill_dir: Path, known_agents: Collection[str] | None = None) -> body=body, path=skill_dir, deprecated=deprecated, + capabilities=capabilities, ) +def is_junk(name: str) -> bool: + """Whether ``name`` is a file an OS or editor leaves behind, which loading + a skill ignores (``provenance --verify`` does not).""" + return name in _JUNK_NAMES or bool(_JUNK_RE.fullmatch(name)) + + +def is_link(path: Path) -> bool: + """Whether ``path`` is a symlink or, on Windows, a directory junction; + neither is followed.""" + isjunction = getattr(os.path, "isjunction", None) # Python 3.12+ + return path.is_symlink() or bool(isjunction and isjunction(path)) + + +def link_kind(path: Path) -> str: + return "symlink" if path.is_symlink() else "junction" + + +def bundle_problems(skill_dir: Path) -> list[str]: + """What in ``skill_dir`` breaks the bundle rules; ``[]`` if nothing does. + + One message per offending entry: a symlink or junction (never followed), + a directory or other non-regular file, or any file besides + :data:`BUNDLE_FILES`, which the message calls executable when its suffix, + execute bit (not on Windows, which has none) or first bytes say it is a + program. Every such file is refused, since a skill cannot ship or declare + one. OS and editor leftovers (:func:`is_junk`) are skipped, unless one is + a link: only an Emacs lock file (``.#name``) is a symlink by nature. + """ + try: + entries = sorted(skill_dir.iterdir(), key=lambda entry: entry.name) + except OSError as exc: + return [f"cannot list the skill directory: {exc}"] + problems: list[str] = [] + for entry in entries: + try: + mode = entry.lstat().st_mode + except OSError as exc: + problems.append(f"cannot inspect {entry.name}: {exc}") + continue + link = stat.S_ISLNK(mode) or is_link(entry) + if is_junk(entry.name) and (not link or entry.name.startswith(".#")): + continue + if link: + problems.append(f"{entry.name} is a {link_kind(entry)}") + elif stat.S_ISDIR(mode): + problems.append(f"{entry.name} is a directory") + elif not stat.S_ISREG(mode): + problems.append(f"{entry.name} is not a regular file") + elif entry.name not in BUNDLE_FILES: + why = _executable(entry, mode) + if why: + problems.append(f"{entry.name} is an undeclared executable ({why})") + else: + problems.append(f"{entry.name} is not meta.yaml or skill.md") + return problems + + +def _executable(path: Path, mode: int) -> str | None: + """Why the regular file at ``path`` is a program; None if nothing says so.""" + suffix = path.suffix.lower() + if suffix in SCRIPT_SUFFIXES: + return f"a {suffix} file" + # Windows reports execute bits from the file's suffix, covered above + if os.name != "nt" and mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH): + return "its execute bit is set" + try: + with path.open("rb") as handle: + head = handle.read(4) + except OSError: + return None + if head.startswith(b"#!"): + return "it starts with #!" + if head.startswith(_BINARY_MAGIC): + return "a native binary" + return None + + +def local_links(body: str) -> list[str]: + """Link targets in ``body`` that name a file rather than a web page. + + Markdown links, images, reference definitions and autolinks, and the + ``src`` and ``href`` attributes of HTML tags, outside code blocks (fenced + or indented) and code spans. A target counts unless it is a ``#heading`` + anchor or uses one of :data:`WEB_SCHEMES` (or is scheme-relative, + ``//host/...``); a relative path, an absolute one, a ``file:`` URL and any + other scheme all count. Sorted, each once. + """ + text = _CODE_SPAN_RE.sub("", "\n".join(_prose_lines(body))) + targets = [ + target for pattern in _LINK_TARGET_RES for target in pattern.findall(text) + ] + for tag in _HTML_TAG_RE.findall(text): + targets.extend(_HTML_LINK_ATTR_RE.findall(tag)) + found: set[str] = set() + for target in targets: + if not target or target.startswith(("#", "//")): + continue + scheme = _SCHEME_RE.match(target) + if scheme and scheme.group(0)[:-1].lower() in WEB_SCHEMES: + continue + found.add(target) + return sorted(found) + + +def _prose_lines(body: str) -> list[str]: + """``body``'s lines outside fenced and indented code blocks. + + An indented code block is a run of lines indented four or more columns + that starts after a blank line, outside a list: inside one, that + indentation continues a list item. A list lasts until a line starts at + column 0 after a blank line, or a heading. + """ + prose: list[str] = [] + fence: str | None = None + in_list = in_code = False + after_blank = True + for line in body.splitlines(): + fence_match = _FENCE_RE.match(line) + if fence is not None: + closer = fence_match.group(1) if fence_match else "" + if closer[:1] == fence[0] and len(closer) >= len(fence): + fence = None + continue + if fence_match: + fence = fence_match.group(1) + in_code = False + continue + expanded = line.expandtabs(4) + content = expanded.lstrip(" ") + indent = len(expanded) - len(content) + if not content: + after_blank = True + prose.append("") + continue + if indent >= 4 and not in_list and (after_blank or in_code): + in_code = True + after_blank = False + continue + in_code = False + if indent < 4 and _LIST_ITEM_RE.match(content): + in_list = True + elif indent == 0 and (after_blank or content.startswith("#")): + in_list = False + after_blank = False + prose.append(line) + return prose + + def _require_str(skill_dir: Path, meta: dict[Any, Any], field: str) -> str: """Return ``meta[field]`` if it is a non-blank string, else fail loudly.""" value = meta[field] @@ -283,11 +520,15 @@ def discover_skills( if not root.is_dir(): raise SkillError(f"skills directory not found: {root}") - skills = [ - load_skill(child, known_agents) - for child in sorted(root.iterdir()) - if child.is_dir() and not child.name.startswith(".") - ] + skills = [] + for child in sorted(root.iterdir()): + if child.name.startswith(".") or is_junk(child.name): + continue + # checked before is_dir(), which follows the link + if is_link(child): + raise SkillError(f"{child}: the skill directory is a {link_kind(child)}") + if child.is_dir(): + skills.append(load_skill(child, known_agents)) _check_replacements(skills) return skills diff --git a/src/skilldeck/skills/authentication-review/meta.yaml b/src/skilldeck/skills/authentication-review/meta.yaml index 35ebed9..d7d33f1 100644 --- a/src/skilldeck/skills/authentication-review/meta.yaml +++ b/src/skilldeck/skills/authentication-review/meta.yaml @@ -1,10 +1,24 @@ name: authentication-review description: Review authentication changes — passwords, MFA, sessions, tokens, OAuth/OIDC, SAML, and LDAP sign-in — for login-bypass and account-takeover defects, aligned to OWASP ASVS 5.0. category: security -version: 0.2.0 +version: 0.2.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/ci-workflow-review/meta.yaml b/src/skilldeck/skills/ci-workflow-review/meta.yaml index 9486068..f67e958 100644 --- a/src/skilldeck/skills/ci-workflow-review/meta.yaml +++ b/src/skilldeck/skills/ci-workflow-review/meta.yaml @@ -1,10 +1,24 @@ name: ci-workflow-review description: Review CI/CD pipeline changes for injection, credential exposure, and supply-chain risk, aligned to the OWASP Top 10 CI/CD Security Risks. category: security -version: 0.4.0 +version: 0.4.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/code-smells/meta.yaml b/src/skilldeck/skills/code-smells/meta.yaml index 8000513..f851c16 100644 --- a/src/skilldeck/skills/code-smells/meta.yaml +++ b/src/skilldeck/skills/code-smells/meta.yaml @@ -1,10 +1,24 @@ name: code-smells description: Review pending changes for code smells that signal a need for refactoring. category: review -version: 0.3.0 +version: 0.3.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/dependency-review/meta.yaml b/src/skilldeck/skills/dependency-review/meta.yaml index 02c9173..b4969fe 100644 --- a/src/skilldeck/skills/dependency-review/meta.yaml +++ b/src/skilldeck/skills/dependency-review/meta.yaml @@ -1,10 +1,35 @@ name: dependency-review description: Review dependency changes in the diff for known vulnerabilities and supply-chain risk. category: security -version: 0.4.0 +version: 0.5.0 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + - npm audit + - pip-audit --disable-pip + - osv-scanner + - govulncheck + - cargo audit + - gh api + network: + - the git remote, via git fetch, to bring the base branch up to date + - vulnerability databases and package registries, queried by the audit commands + - GitHub's advisory API, via gh api with its existing login + - advisory pages, fetched to verify an advisory ID before citing it + - package registry pages, fetched for a package's publish dates, maintainers and provenance + credentials: [] + tools: + - web fetch, to read advisory and package registry pages + artifacts: [] diff --git a/src/skilldeck/skills/dependency-review/skill.md b/src/skilldeck/skills/dependency-review/skill.md index 95b54bc..d1af84f 100644 --- a/src/skilldeck/skills/dependency-review/skill.md +++ b/src/skilldeck/skills/dependency-review/skill.md @@ -31,7 +31,14 @@ rest of the application surface. 3. Diff old vs new versions to see exactly what changed. If automated tooling is available (`npm audit`, `pip-audit`, `osv-scanner`, `govulncheck`, `cargo audit`, `gh` advisory APIs), run it and cite the results; otherwise - reason from the version changes and known advisories. + reason from the version changes and known advisories. `pip-audit -r ` + resolves the requirements as `pip install -r` would, so run `pip-audit` only + on a fully pinned file without resolving it: + `pip-audit --disable-pip --require-hashes -r `, or `--no-deps` in place + of `--require-hashes` when the pins carry no hashes; skip it for anything + else ([pip-audit security model](https://github.com/pypa/pip-audit#security-model)). + For a package's publish dates, maintainers, and provenance, read its + registry page. 4. This skill owns package manifests and lockfiles; how CI steps and images are pinned belongs to `ci-workflow-review`, and IaC images and modules to `iac-review`. If the owner runs in the same review, leave its area to it; diff --git a/src/skilldeck/skills/frontend-security-review/meta.yaml b/src/skilldeck/skills/frontend-security-review/meta.yaml index 2aad497..936fbb4 100644 --- a/src/skilldeck/skills/frontend-security-review/meta.yaml +++ b/src/skilldeck/skills/frontend-security-review/meta.yaml @@ -1,10 +1,24 @@ name: frontend-security-review description: Review browser-side changes — React, Vue, Angular, Svelte, and plain JavaScript templates, HTML, and CSP and header config — for XSS sinks, weakened CSP, unchecked postMessage, tokens and secrets exposed to the browser, and other client-side defects, aligned to OWASP ASVS 5.0 V3 Web Frontend Security. category: security -version: 0.1.0 +version: 0.1.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/iac-review/meta.yaml b/src/skilldeck/skills/iac-review/meta.yaml index e8e2c75..d80395b 100644 --- a/src/skilldeck/skills/iac-review/meta.yaml +++ b/src/skilldeck/skills/iac-review/meta.yaml @@ -1,10 +1,24 @@ name: iac-review description: Review infrastructure-as-code changes for security misconfigurations, aligned to CIS benchmark baselines and the Kubernetes Pod Security Standards. category: security -version: 0.3.0 +version: 0.3.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/llm-integration-review/meta.yaml b/src/skilldeck/skills/llm-integration-review/meta.yaml index fa46a06..abcc67a 100644 --- a/src/skilldeck/skills/llm-integration-review/meta.yaml +++ b/src/skilldeck/skills/llm-integration-review/meta.yaml @@ -1,10 +1,24 @@ name: llm-integration-review description: Review changes that integrate LLMs or AI agents — prompts, tool calling, agent loops, RAG, MCP servers, and model loading — for prompt-injection, output-handling, and excessive-agency defects, aligned to the OWASP Top 10 for LLM Applications 2026. category: security -version: 0.1.0 +version: 0.1.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/logging/meta.yaml b/src/skilldeck/skills/logging/meta.yaml index 13bd5da..64b9f4b 100644 --- a/src/skilldeck/skills/logging/meta.yaml +++ b/src/skilldeck/skills/logging/meta.yaml @@ -1,10 +1,24 @@ name: logging description: Add or review application logging following OWASP security best practices. category: security -version: 0.3.0 +version: 0.3.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: repo + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/migration-review/meta.yaml b/src/skilldeck/skills/migration-review/meta.yaml index df6e299..8462401 100644 --- a/src/skilldeck/skills/migration-review/meta.yaml +++ b/src/skilldeck/skills/migration-review/meta.yaml @@ -1,10 +1,24 @@ name: migration-review description: Review database schema and data migrations for safety under a live, rolling deploy. category: review -version: 0.4.0 +version: 0.4.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/privacy-review/meta.yaml b/src/skilldeck/skills/privacy-review/meta.yaml index 545596f..54e68ca 100644 --- a/src/skilldeck/skills/privacy-review/meta.yaml +++ b/src/skilldeck/skills/privacy-review/meta.yaml @@ -1,10 +1,24 @@ name: privacy-review description: Review changes that handle personal data for privacy defects — over-collection and over-broad responses, personal data sent to trackers without consent, missing retention and deletion paths, unencrypted sensitive data, and PII in URLs, caches, and client storage — aligned to OWASP ASVS 5.0 V14 Data Protection. category: security -version: 0.1.0 +version: 0.1.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/resilience-review/meta.yaml b/src/skilldeck/skills/resilience-review/meta.yaml index a08224f..771a60b 100644 --- a/src/skilldeck/skills/resilience-review/meta.yaml +++ b/src/skilldeck/skills/resilience-review/meta.yaml @@ -1,10 +1,24 @@ name: resilience-review description: Review pending changes for resilience and fault tolerance when dependencies fail, slow, or overload. category: review -version: 0.3.0 +version: 0.3.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/security-review/meta.yaml b/src/skilldeck/skills/security-review/meta.yaml index e2472c5..ac7e3a8 100644 --- a/src/skilldeck/skills/security-review/meta.yaml +++ b/src/skilldeck/skills/security-review/meta.yaml @@ -1,10 +1,24 @@ name: security-review description: Review pending changes on the current branch for security vulnerabilities, aligned to OWASP ASVS 5.0. category: security -version: 0.5.1 +version: 0.5.2 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/test-review/meta.yaml b/src/skilldeck/skills/test-review/meta.yaml index 2734fed..0d8ce1d 100644 --- a/src/skilldeck/skills/test-review/meta.yaml +++ b/src/skilldeck/skills/test-review/meta.yaml @@ -1,10 +1,27 @@ name: test-review description: Review pending changes for adequate, meaningful test coverage. category: review -version: 0.3.0 +version: 0.3.1 supported-agents: - claude - codex - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: repo + write: none + commands: + - git fetch + - git diff + - git ls-files + - git worktree add + - git worktree remove + - + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/src/skilldeck/skills/test-review/skill.md b/src/skilldeck/skills/test-review/skill.md index 65a1ef8..8d962a6 100644 --- a/src/skilldeck/skills/test-review/skill.md +++ b/src/skilldeck/skills/test-review/skill.md @@ -28,8 +28,14 @@ deterministic tests; coverage shows a line ran, not that it was checked), 3. Match the project's existing test conventions (framework, layout, naming); judge against them rather than imposing a different style. 4. For a bug fix, confirm the regression test would actually fail without the - fix — read the pre-change code (or run the test against it if cheap) rather - than assuming. + fix — read the pre-change code rather than assuming. If running the test is + cheap, run it against that code without touching the working tree: check + out the base in a temporary worktree outside the repository + (`git worktree add origin/`, or `HEAD` for uncommitted + changes), copy the new test into it, run it there with the project's test + command, then delete the worktree + (`git worktree remove --force `). Never stash, reset, or check out + in the working tree under review. ## What to look for diff --git a/tests/_schema.py b/tests/_schema.py index 9b1485f..36271ec 100644 --- a/tests/_schema.py +++ b/tests/_schema.py @@ -28,6 +28,7 @@ "maxLength", "minItems", "uniqueItems", + "enum", } _TYPES = { "object": lambda v: isinstance(v, dict), @@ -59,6 +60,8 @@ def check(value, node, path, exact): types = node["type"] if isinstance(node["type"], list) else [node["type"]] if not any(_TYPES[t](value) for t in types): return [f"{path}: {value!r} is not of type {types}"] + if "enum" in node and value not in node["enum"]: + errors.append(f"{path}: {value!r} is not one of {node['enum']}") if "const" in node and ( value != node["const"] or type(value) is not type(node["const"]) ): diff --git a/tests/fixtures/adapter-contracts/claude/SKILL.md b/tests/fixtures/adapter-contracts/claude/SKILL.md index 5ae88d0..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/claude/SKILL.md +++ b/tests/fixtures/adapter-contracts/claude/SKILL.md @@ -10,5 +10,14 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. - +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. + +## Declared capabilities + +Beyond reading the changed files, this skill asks you to: + +- run `git diff`, `` + +It asks for nothing else. + diff --git a/tests/fixtures/adapter-contracts/codex/SKILL.md b/tests/fixtures/adapter-contracts/codex/SKILL.md index 5ae88d0..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/codex/SKILL.md +++ b/tests/fixtures/adapter-contracts/codex/SKILL.md @@ -10,5 +10,14 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. - +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. + +## Declared capabilities + +Beyond reading the changed files, this skill asks you to: + +- run `git diff`, `` + +It asks for nothing else. + diff --git a/tests/fixtures/adapter-contracts/copilot-prompt/contract-demo.prompt.md b/tests/fixtures/adapter-contracts/copilot-prompt/contract-demo.prompt.md index 3c66f59..91f609f 100644 --- a/tests/fixtures/adapter-contracts/copilot-prompt/contract-demo.prompt.md +++ b/tests/fixtures/adapter-contracts/copilot-prompt/contract-demo.prompt.md @@ -10,5 +10,14 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. - +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. + +## Declared capabilities + +Beyond reading the changed files, this skill asks you to: + +- run `git diff`, `` + +It asks for nothing else. + diff --git a/tests/fixtures/adapter-contracts/copilot/SKILL.md b/tests/fixtures/adapter-contracts/copilot/SKILL.md index 5ae88d0..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/copilot/SKILL.md +++ b/tests/fixtures/adapter-contracts/copilot/SKILL.md @@ -10,5 +10,14 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. - +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. + +## Declared capabilities + +Beyond reading the changed files, this skill asks you to: + +- run `git diff`, `` + +It asks for nothing else. + diff --git a/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc b/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc index 3ae2d78..4d46493 100644 --- a/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc +++ b/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc @@ -9,5 +9,14 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. - +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. + +## Declared capabilities + +Beyond reading the changed files, this skill asks you to: + +- run `git diff`, `` + +It asks for nothing else. + diff --git a/tests/fixtures/adapter-contracts/cursor/SKILL.md b/tests/fixtures/adapter-contracts/cursor/SKILL.md index 5ae88d0..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/cursor/SKILL.md +++ b/tests/fixtures/adapter-contracts/cursor/SKILL.md @@ -10,5 +10,14 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. - +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. + +## Declared capabilities + +Beyond reading the changed files, this skill asks you to: + +- run `git diff`, `` + +It asks for nothing else. + diff --git a/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md b/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md index 8e33a1c..34d3247 100644 --- a/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md +++ b/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md @@ -8,5 +8,14 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. - +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. + +## Declared capabilities + +Beyond reading the changed files, this skill asks you to: + +- run `git diff`, `` + +It asks for nothing else. + diff --git a/tests/fixtures/adapter-contracts/kiro/SKILL.md b/tests/fixtures/adapter-contracts/kiro/SKILL.md index 5ae88d0..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/kiro/SKILL.md +++ b/tests/fixtures/adapter-contracts/kiro/SKILL.md @@ -10,5 +10,14 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. - +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. + +## Declared capabilities + +Beyond reading the changed files, this skill asks you to: + +- run `git diff`, `` + +It asks for nothing else. + diff --git a/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml b/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml index 45f71ed..ae2764e 100644 --- a/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml +++ b/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml @@ -8,3 +8,15 @@ supported-agents: - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: diff + write: none + commands: + - git diff + - + network: [] + credentials: [] + tools: [] + artifacts: [] diff --git a/tests/fixtures/adapter-contracts/skill/contract-demo/skill.md b/tests/fixtures/adapter-contracts/skill/contract-demo/skill.md index bfc8667..c6eeae4 100644 --- a/tests/fixtures/adapter-contracts/skill/contract-demo/skill.md +++ b/tests/fixtures/adapter-contracts/skill/contract-demo/skill.md @@ -4,4 +4,5 @@ A synthetic skill for the adapter contract tests — it is never installed for real. Non-ASCII text (naïve café, 検査) must reach every agent as UTF-8. 1. Run `git diff` and read the changed files. -2. Report each finding with its file and line. +2. Run `` to see whether the change breaks a test. +3. Report each finding with its file and line. diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 5affc9c..37fd793 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -315,7 +315,9 @@ def test_legacy_names_are_not_agents_a_skill_can_list(tmp_path): skill_dir.mkdir() (skill_dir / "meta.yaml").write_text( "name: demo\ndescription: d\ncategory: c\nversion: 0.1.0\n" - "supported-agents: [copilot-prompt]\n", + "supported-agents: [copilot-prompt]\n" + "capabilities: {schema: 1, files: {read: repo, write: none}, commands: []," + " network: [], credentials: [], tools: [], artifacts: []}\n", encoding="utf-8", ) (skill_dir / "skill.md").write_text("body\n", encoding="utf-8") diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 0000000..a1c18f2 --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,845 @@ +"""Skill capability declarations and bundle validation (#73). + +Covers the ``capabilities`` schema, the notice adapters render from it, the +bundle rules the registry enforces (built as adversarial skill directories in +``tmp_path``, never committed), and the install preview. +""" + +import os +import shutil +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from skilldeck import provenance, registry +from skilldeck.adapters import ADAPTERS, ALL_ADAPTERS +from skilldeck.adapters.base import rendered_body +from skilldeck.capabilities import ( + GIT_BASELINE, + Capabilities, + CapabilityError, + notice, + parse_capabilities, + summary, + with_notice, +) +from skilldeck.cli import cli +from skilldeck.provenance import verify_bundled_skills +from skilldeck.registry import ( + DEFAULT_SKILLS_DIR, + Skill, + SkillError, + discover_skills, + load_skill, + local_links, +) +from skilldeck.stamp import stamp +from skilldeck.targets import Scope + + +def _raw(**overrides): + """A decoded capability block that requests only reading the repository.""" + raw = { + "schema": 1, + "files": {"read": "repo", "write": "none"}, + "commands": [], + "network": [], + "credentials": [], + "tools": [], + "artifacts": [], + } + raw.update(overrides) + return raw + + +FULL = Capabilities( + read="diff", + write="repo", + commands=("git diff", ""), + network=("the git remote, to fetch", "an advisory database"), + credentials=("a registry token from NPM_TOKEN",), + tools=("web fetch",), + artifacts=("reports/review.md",), +) + + +# --- the schema --------------------------------------------------------------- + + +def test_parse_reads_every_field(): + raw = _raw( + files={"read": "diff", "write": "repo"}, + commands=["git diff", ""], + network=["the git remote, to fetch", "an advisory database"], + credentials=["a registry token from NPM_TOKEN"], + tools=["web fetch"], + artifacts=["reports/review.md"], + ) + assert parse_capabilities(raw) == FULL + assert FULL.record() == {**raw, "schema": 1} + + +BASELINE_REVIEW = Capabilities( + read="repo", + commands=tuple(sorted(GIT_BASELINE)), + network=("the git remote, via git fetch",), +) + + +def test_a_read_only_review_stays_within_the_baseline(): + caps = parse_capabilities(_raw()) + assert caps == Capabilities(read="repo") + assert not caps.beyond_review_baseline + assert not Capabilities().beyond_review_baseline + # read-only git commands and the git remote they reach are the baseline + assert not BASELINE_REVIEW.beyond_review_baseline + assert not Capabilities(network=("the git remote",)).beyond_review_baseline + for field, value in ( + ("write", "repo"), + ("commands", ("git diff", "git push")), + ("commands", ("git worktree add",)), + ("commands", ("",)), + ("commands", ("npm audit",)), + ("credentials", ("x",)), + ("tools", ("x",)), + ("artifacts", ("x.md",)), + ): + caps = Capabilities(read="repo", **{field: value}) + assert caps.beyond_review_baseline, (field, value) + + +@pytest.mark.parametrize( + ("raw", "message"), + [ + (None, "must be a mapping"), + (["git diff"], "must be a mapping"), + (_raw(network_access=True), "unknown field\\(s\\): network_access"), + ({k: v for k, v in _raw().items() if k != "tools"}, "missing field.*tools"), + ({k: v for k, v in _raw().items() if k != "schema"}, "missing.*schema"), + (_raw(schema=2), "schema must be 1"), + (_raw(schema="1"), "schema must be 1"), + (_raw(schema=True), "schema must be 1"), + (_raw(files="repo"), "files must be a mapping"), + (_raw(files={"read": "repo"}), "files missing field.*write"), + (_raw(files={"read": "repo", "write": "none", "x": 1}), "unknown field"), + (_raw(files={"read": "all", "write": "none"}), "read must be one of"), + (_raw(files={"read": "repo", "write": "diff"}), "write must be one of"), + (_raw(files={"read": True, "write": "none"}), "read must be one of"), + (_raw(commands="git diff"), "commands must be a list"), + (_raw(commands=None), "commands must be a list"), + (_raw(network=[1]), "entries must be strings"), + (_raw(network=[""]), "must not be empty"), + (_raw(network=["a\nb"]), "one line"), + (_raw(network=["a\u2028b"]), "one line"), + (_raw(network=[" padded"]), "leading or trailing"), + (_raw(network=["x" * 201]), "limit is 200"), + (_raw(tools=["web", "web"]), "more than once: web"), + (_raw(commands=["`git diff`"]), "without shell operators"), + (_raw(commands=["git diff"]), "single-spaced"), + (_raw(commands=["git diff | sh"]), "without shell operators"), + (_raw(commands=["git diff; rm -rf ~"]), "without shell operators"), + (_raw(commands=["git diff > out.txt"]), "without shell operators"), + (_raw(commands=["git diff $(id)"]), "without shell operators"), + (_raw(commands=["npm audit && npm ci"]), "without shell operators"), + (_raw(commands=[" "]), "without shell operators"), + (_raw(commands=["./run.sh"]), "by its path"), + (_raw(commands=["scripts/check.sh --all"]), "by its path"), + (_raw(commands=["C:\\tools\\x.exe"]), "by its path"), + (_raw(commands=["-rf"]), "must start with a program name"), + (_raw(commands=["sh ./check.sh"]), "run a script or inline code"), + (_raw(commands=["sh check.sh"]), "run a script or inline code"), + (_raw(commands=["python ../x.py"]), "run a script or inline code"), + (_raw(commands=["python manage.py test"]), "run a script or inline code"), + (_raw(commands=["python3 -c pass"]), "run a script or inline code"), + (_raw(commands=["bash -c make"]), "run a script or inline code"), + (_raw(commands=["bash -lc make"]), "run a script or inline code"), + (_raw(commands=["node -e x"]), "run a script or inline code"), + (_raw(commands=["node --eval x"]), "run a script or inline code"), + (_raw(commands=["ruby app.rb"]), "run a script or inline code"), + (_raw(commands=["perl -E say"]), "run a script or inline code"), + (_raw(commands=["pwsh -Command Get-Item"]), "run a script or inline code"), + (_raw(commands=["pwsh -File x"]), "run a script or inline code"), + (_raw(commands=["zsh scripts\\x"]), "run a script or inline code"), + (_raw(commands=["+x"]), "must start with a program name"), + ], +) +def test_malformed_capabilities_are_rejected(raw, message): + with pytest.raises(CapabilityError, match=message): + parse_capabilities(raw) + + +@pytest.mark.parametrize( + ("path", "reason"), + [ + ("../outside.md", "'..'"), + ("reports/../../outside.md", "'..'"), + ("./report.md", "'.'"), + ("reports//review.md", "empty"), + ("reports/", "empty"), + ("/etc/passwd", "absolute"), + ("C:/Windows/x.md", "drive"), + ("c:x.md", "drive"), + ("reports\\review.md", "separator"), + ("..\\outside.md", "separator"), + ("~/.ssh/config", "home"), + ("my report.md", "only letters"), + ], +) +def test_artifact_paths_must_stay_inside_the_project(path, reason): + with pytest.raises(CapabilityError, match=f"inside the project: .*{reason}"): + parse_capabilities(_raw(artifacts=[path])) + + +@pytest.mark.parametrize( + "command", + ["python -m pytest", "python3 -m pip_audit", "node --version", "git diff ./src"], +) +def test_interpreters_may_run_modules_and_other_programs_take_paths(command): + assert parse_capabilities(_raw(commands=[command])).commands == (command,) + + +@pytest.mark.parametrize("path", ["review.md", "reports/review.md", ".skilldeck/r.md"]) +def test_relative_artifact_paths_are_accepted(path): + assert parse_capabilities(_raw(artifacts=[path])).artifacts == (path,) + + +# --- the notice adapters render ------------------------------------------------- + + +def test_a_read_only_review_gets_no_notice(): + for caps in (Capabilities(read="repo"), BASELINE_REVIEW): + assert notice(caps) == "" + assert with_notice("body", caps) == "body" + + +def test_notice_tells_the_agent_everything_the_skill_asks_for(): + assert with_notice("# Demo\n", FULL) == ( + "# Demo\n" + "\n" + "## Declared capabilities\n" + "\n" + "Beyond reading the changed files, this skill asks you to:\n" + "\n" + "- run `git diff`, ``\n" + "- edit files in the repository\n" + "- create `reports/review.md`\n" + "- contact:\n" + " - the git remote, to fetch\n" + " - an advisory database\n" + "- use these credentials: a registry token from NPM_TOKEN\n" + "- use these agent tools: web fetch\n" + "\n" + "It asks for nothing else.\n" + ) + # a body without a final newline still gets a blank line before the notice + assert with_notice("# Demo", FULL).startswith("# Demo\n\n## Declared") + + +def test_notice_lists_baseline_commands_and_network_once_it_renders(): + caps = Capabilities( + read="repo", + write="repo", + commands=("git fetch", "git diff"), + network=("the git remote, via git fetch",), + ) + assert notice(caps) == ( + "## Declared capabilities\n" + "\n" + "Beyond reading the repository, this skill asks you to:\n" + "\n" + "- run `git fetch`, `git diff`\n" + "- edit files in the repository\n" + "- contact the git remote, via git fetch\n" + "\n" + "It asks for nothing else.\n" + ) + assert notice(Capabilities(tools=("web fetch", "web search"))) == ( + "## Declared capabilities\n" + "\n" + "This skill reads none of your files. It asks you to:\n" + "\n" + "- use these agent tools:\n" + " - web fetch\n" + " - web search\n" + "\n" + "It asks for nothing else.\n" + ) + + +def test_notice_speaks_to_the_agent_not_about_skilldeck(): + text = notice(FULL) + for phrase in ("skilldeck", "schema", "enforce", "refuse"): + assert phrase not in text.lower(), phrase + + +def _skill(capabilities, body="# Demo\n\nDo the review.\n"): + return Skill( + name="demo", + description="A demo skill.", + category="testing", + version="0.1.0", + supported_agents=tuple(ADAPTERS), + body=body, + path=Path("/nowhere"), + capabilities=capabilities, + ) + + +@pytest.mark.parametrize("name", sorted(ALL_ADAPTERS)) +def test_every_adapter_carries_the_notice(name): + adapter = ALL_ADAPTERS[name] + for caps in ( + Capabilities(read="repo", commands=("git diff", "npm audit")), + Capabilities(read="repo", write="repo"), + Capabilities(read="repo", credentials=("a token from GH_TOKEN",)), + Capabilities(read="repo", tools=("web fetch",)), + Capabilities(read="repo", artifacts=("review.md",)), + FULL, + ): + rendered = adapter.render(_skill(caps)) + assert rendered.endswith(with_notice("# Demo\n\nDo the review.\n", caps)) + assert "\n## Declared capabilities\n" in rendered + # a read-only review renders its body unchanged + for caps in (Capabilities(read="repo"), BASELINE_REVIEW): + plain = adapter.render(_skill(caps)) + assert plain.endswith("# Demo\n\nDo the review.\n") + assert "Declared capabilities" not in plain + + +def test_installed_file_keeps_the_notice_above_the_stamp(tmp_path): + adapter = ADAPTERS["claude"] + dest = adapter.install(_skill(FULL), Scope.PROJECT, project_root=tmp_path) + text = dest.read_text(encoding="utf-8") + assert text == stamp(adapter.render(_skill(FULL)), "demo", "0.1.0") + assert "It asks for nothing else.\n