From 3c48f120ec5f2210927636c8b80b0bc610e52322 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 02:59:14 +0000 Subject: [PATCH 1/3] Define and enforce a skill capability manifest Every skill's meta.yaml now carries a required, versioned `capabilities` block (schema 1): files read (none/diff/repo) and edited (none/repo), commands it may run, network use and why, credentials, agent tools, and files it may create. Anything undeclared is not requested; the declaration is for review, not enforcement. - skilldeck.capabilities validates the block (unknown or missing keys, other schema numbers, commands given as paths or with shell operators, artifact paths that are absolute, name a drive or home, use backslashes or have ./.. components). - The registry enforces the bundle rules: exactly regular meta.yaml and skill.md, rejecting symlinks (including a symlinked skill directory), subdirectories, undeclared executables (script suffix, execute bit, #!, binary headers) and any other file, plus skill.md links to files the skill cannot ship. provenance --verify and catalog apply the same rules. - Every adapter appends a "Declared capabilities" section to a skill that asks for more than reading files; the adapter contract skill declares its `git diff`, so the fixtures pin that section (contract sha256:d8b7d4463e84). - `skilldeck show --summary` and `skilldeck install --dry-run` preview a skill's source, build, digest check, deprecation and declared capabilities; the dry run also reports what installing would do and writes nothing. - `skilldeck catalog --json` reports `capabilities` (additive; schema_version stays 1). - All 13 bundled skills declare what their instructions ask for, with patch version bumps; plugin tree and content manifests regenerated. Closes #73 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HtiCGzpikMrkDYBkfQG5CX --- CHANGELOG.md | 54 +- CLAUDE.md | 17 +- CONTRIBUTING.md | 3 +- README.md | 18 +- claude-plugin/.claude-plugin/plugin.json | 2 +- .../.skilldeck/content-manifest.json | 104 +-- .../skills/authentication-review/SKILL.md | 10 + .../skills/ci-workflow-review/SKILL.md | 10 + claude-plugin/skills/code-smells/SKILL.md | 10 + .../skills/dependency-review/SKILL.md | 15 + .../skills/frontend-security-review/SKILL.md | 10 + claude-plugin/skills/iac-review/SKILL.md | 10 + .../skills/llm-integration-review/SKILL.md | 10 + claude-plugin/skills/logging/SKILL.md | 10 + .../skills/migration-review/SKILL.md | 10 + claude-plugin/skills/privacy-review/SKILL.md | 10 + .../skills/resilience-review/SKILL.md | 10 + claude-plugin/skills/security-review/SKILL.md | 10 + claude-plugin/skills/test-review/SKILL.md | 10 + docs/adapters.md | 7 + docs/authoring-skills.md | 102 ++- docs/catalog.md | 31 +- docs/compatibility.md | 6 +- docs/verifying-releases.md | 2 +- scripts/verify_distribution_identity.py | 9 + src/skilldeck/_content_manifest.json | 104 +-- src/skilldeck/adapters/base.py | 20 + src/skilldeck/adapters/legacy.py | 9 +- src/skilldeck/adapters/skill_md.py | 4 +- src/skilldeck/capabilities.py | 346 ++++++++++ src/skilldeck/catalog.py | 4 + src/skilldeck/catalog.schema.json | 68 +- src/skilldeck/cli.py | 145 +++- src/skilldeck/provenance.py | 15 +- src/skilldeck/registry.py | 191 +++++- .../skills/authentication-review/meta.yaml | 16 +- .../skills/ci-workflow-review/meta.yaml | 16 +- src/skilldeck/skills/code-smells/meta.yaml | 16 +- .../skills/dependency-review/meta.yaml | 26 +- .../skills/frontend-security-review/meta.yaml | 16 +- src/skilldeck/skills/iac-review/meta.yaml | 16 +- .../skills/llm-integration-review/meta.yaml | 16 +- src/skilldeck/skills/logging/meta.yaml | 16 +- .../skills/migration-review/meta.yaml | 16 +- src/skilldeck/skills/privacy-review/meta.yaml | 16 +- .../skills/resilience-review/meta.yaml | 16 +- .../skills/security-review/meta.yaml | 16 +- src/skilldeck/skills/test-review/meta.yaml | 17 +- .../adapter-contracts/claude/SKILL.md | 11 +- .../fixtures/adapter-contracts/codex/SKILL.md | 11 +- .../copilot-prompt/contract-demo.prompt.md | 11 +- .../adapter-contracts/copilot/SKILL.md | 11 +- .../cursor-rule/contract-demo.mdc | 11 +- .../adapter-contracts/cursor/SKILL.md | 11 +- .../kiro-steering/contract-demo.md | 11 +- .../fixtures/adapter-contracts/kiro/SKILL.md | 11 +- .../skill/contract-demo/meta.yaml | 11 + tests/test_adapters.py | 4 +- tests/test_capabilities.py | 623 ++++++++++++++++++ tests/test_catalog.py | 60 +- tests/test_plugin_build.py | 12 +- tests/test_provenance.py | 6 +- tests/test_registry.py | 14 +- tests/test_skill_structure.py | 54 ++ 64 files changed, 2311 insertions(+), 176 deletions(-) create mode 100644 src/skilldeck/capabilities.py create mode 100644 tests/test_capabilities.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dd37c62..45ee78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,13 +149,29 @@ All notable changes to this project are documented here. The format is based on ### Changed +- Every bundled skill's patch version is bumped for its new capability + declaration (#73): `authentication-review` 0.2.1, `ci-workflow-review` + 0.4.1, `code-smells` 0.3.1, `dependency-review` 0.4.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 + and `test-review` 0.3.1. Their instructions are unchanged, but each now + asks for more than reading files (at least `git fetch`, `git diff` and + `git ls-files`, and the git remote), so its installed file gains a + `## Declared capabilities` section: run `skilldeck update` to refresh + installed copies. +- Every adapter appends a `## Declared capabilities` section to a skill that + declares anything beyond reading files (#73). The adapter contract + fixtures now pin that section, since the synthetic contract skill declares + the `git diff` it runs (contract `sha256:d8b7d4463e84`); skills that only + read render unchanged. - 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). @@ -516,6 +532,40 @@ All notable changes to this project are documented here. The format is based on ### Added +- 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, 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, build, + canonical digest (and whether the packaged content manifest vouches for + it), deprecation state and declared capabilities. + - `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), 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`, `osv-scanner`, `govulncheck`, + `cargo audit` and `gh api`, query advisory databases and fetch advisory + pages; `test-review` may run the project's own tests; `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 (and + `provenance --verify` and `catalog`) rejects a symlink (including a + symlinked skill directory), a subdirectory, 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, and a `skill.md` link to a + relative path, absolute path or `file:` URL, 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 076c5d7..02f73af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,14 @@ 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 or symlinks), 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 reading files, 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 @@ -105,7 +112,13 @@ 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). Undeclared means not + requested; it is a declaration for review, never presented as + enforcement. 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 0db531a..fca29eb 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 24e84ef..1e68a4e 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,16 @@ 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, and a skill that asks for more than reading +files carries it in its installed `SKILL.md` as a "Declared capabilities" +section. 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 +221,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 diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 7487e7d..8fcd61d 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-9bb9bbbd2a2c", "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..0ccc2dd 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", - "claude_rendered_sha256": "sha256:43aa9cb65459b19211fa2349e5b4f9362f90638f23884ec159e709cbdd3109e4", - "meta_sha256": "sha256:faf1d1d2f3f630a6efce7f5df11e3290b9f4124ab9562d96a980fe89dfc242ab", + "canonical_sha256": "sha256:348085527ac5aaa2cfb61114357ad2eb5eba688d70a0a6cc10f103a89736025e", + "claude_rendered_sha256": "sha256:ed7cbed96fbbc1f6e9c250a18dc7b2b05aa51e7054403eee1dddd9d4e125a4a0", + "meta_sha256": "sha256:2e0f9a777255b57f101c51c94a14c9caea8557f581cb8db9f1b46937aa81f7b9", "name": "authentication-review", - "version": "0.2.0" + "version": "0.2.1" }, { "body_sha256": "sha256:e80a382e92dd367d577a3bf2251be01713f7ffc9405008857609bcfdd5f387c9", - "canonical_sha256": "sha256:fb28b8bc8aeae01b9983f2b91f1019767416e8ef4c56feb91cb7ad418fd64e34", - "claude_rendered_sha256": "sha256:e7f770e9e6f9b552bb0474364bc132887920c880fbbe3b2bba659b3448b6adc7", - "meta_sha256": "sha256:121aef136ba48413dd05dac91934798580ea762b99f01d349898469918a625a8", + "canonical_sha256": "sha256:20ed849d325c711ee35ce6bc46125ce6bb3d240fc4891791634ee4f8e8caa035", + "claude_rendered_sha256": "sha256:d298c20e392f9d161950748abc7c8eb683cc00ac781e37cded82173412dd1fb6", + "meta_sha256": "sha256:216940dcd5591f170760ab0350a4975ba55fbf50518a836cf594362719dd4477", "name": "ci-workflow-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:bf082cd5c84b729d6c3412fb83d4d771e7dd4d1e30bba11bdb2b58fc3208dd3f", - "canonical_sha256": "sha256:e4dcbee8ff34424923afdfb40cf0966c6cf2f388e086142d4e3208b7ce8e5e7d", - "claude_rendered_sha256": "sha256:aa4f2b00ce2a7d44f66689064a5acfdde873a2d45d292a8d50bf53ef9d2033df", - "meta_sha256": "sha256:d289c63d3178f50e004fa6f1eec3489f2a3bd0236e37b778af45d086c327f32a", + "canonical_sha256": "sha256:16d3b7a55046e42c4fca537da00c1e7764d835671a9e98fb0660c732efeabb8b", + "claude_rendered_sha256": "sha256:1a94277d91107cbea38abb9ce14b01a1e700a73ba4678497833ef37f043b6860", + "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", + "canonical_sha256": "sha256:2e11c0727244de8ecd65f88002129bef53052af4f785448cd0ae47b7b8f5935c", + "claude_rendered_sha256": "sha256:09ac5ed5f310c8162fb16ed95c17eff0b294ea980cf36dd8e5a175c4fa7abfa3", + "meta_sha256": "sha256:70f1847d50c29492801c318115436a5c18686e033beeb6a910f99a8038321765", "name": "dependency-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:94ab468a0837179a8fa7470be404884cfc475f05f8be2314bff6e409ecffc5e5", - "canonical_sha256": "sha256:c3c2bde57bbb2c220da2f44c775e1d2bb71956915572094acd0fcf8a1c411186", - "claude_rendered_sha256": "sha256:11d8bdbf3419cb7c4040bc02e19b502d5b64ba77e65aa2190e1cd51dc826d76a", - "meta_sha256": "sha256:6337828e91940730210bd7fea7690573610f9413d484d40a2596772c387b94fb", + "canonical_sha256": "sha256:74930194c26657d0107d59520a6baa33e6ccdb3c3915773f137f0f11940ceb2f", + "claude_rendered_sha256": "sha256:bc8c6161a56b7a33b890b03f9ca2e796f2bcfe9d2ed5ea322b4bb86318f31798", + "meta_sha256": "sha256:d2dc89cdda1beaeff5a10b911488e89a00a4bd4d3408cb7a41d58a0b92833cd2", "name": "frontend-security-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:5503c0d7457247cfa17b81e7485e4ea2d8bdcc00c244f77e32d9df5ffc24b67b", - "canonical_sha256": "sha256:d3b1a7aedb82eb14724253a447c1b67d9d3e8f5928f9b3555c7c17a9975bf529", - "claude_rendered_sha256": "sha256:0e2496faefe746b3e1db50e470c509f1eaea8cb29c4e273f958088cdbd2589a7", - "meta_sha256": "sha256:8888d07def4742b7c284f44392a5b4cbdb3bf551736e4a3b93e71cc36cf76913", + "canonical_sha256": "sha256:92e44ffd9f514cf5b12fe4c97ce955d50a1167ff63b317549b5a81ca89850870", + "claude_rendered_sha256": "sha256:d1549e3ed3b9957bee79ee4df79cac83cae3775c00aa3ae0cb7242fcfa286fda", + "meta_sha256": "sha256:38ddbca88c334c54c4c22832018635394e8e5feed57ef129c749d90057bb28bc", "name": "iac-review", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:14be89e51271a75e43d0fcf7104bf18ca9d7516cf6da63d063e3af7cb4fa8b4f", - "canonical_sha256": "sha256:cbf0af07dc478897b976c9ecb15f708f4f94de73dd2999b9433a7d2a374f80df", - "claude_rendered_sha256": "sha256:e1f93c5321cd7bdb6eeff97c5f8888a67d27294e2abbfe491fac260db0e7ea60", - "meta_sha256": "sha256:a90e3c80901dc33337063e09d9cfcd2d39d25e0266acf0a6988fe5a941ad4e08", + "canonical_sha256": "sha256:e36515eb30b2eec5c54f42e2846514b564dfafc5b530168439321d327721fea2", + "claude_rendered_sha256": "sha256:5cd7317daf5a33bc4a574974ccbce76d75748a03a1f976dc953770c1e88f7147", + "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:5a144238b23a2387b4029c984132ff48a08f18e88ebe38d99f124200a0e59427", + "meta_sha256": "sha256:75c39abf8f7ae9599ceaaf65bcc79776e04ccc0595f54949c52af1dcf204c730", "name": "logging", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:3e869f7883e118db18e8c8002f049d8ecf2895e49aa7c76f1d7a39a6ee842c8b", - "canonical_sha256": "sha256:2a90dcac4e099baf9018049929e4cd803b236afb4219da70f9e84ddc501d2189", - "claude_rendered_sha256": "sha256:4dfff856963c37785deb71d8385783df137a6bfe7bc692877d71a9bb5d3bc6f9", - "meta_sha256": "sha256:006759e87ea326c310ad454d4da8f1459c909a0ac25905a38c37edb8d8b743db", + "canonical_sha256": "sha256:1a4c38c4b51a52b60330cd09de25aa7f1b30458fb23c2cb3dd1bdb9b705fc6a9", + "claude_rendered_sha256": "sha256:3452801c9da837de0dc64c87229bacb294597344492f2194b8233f530289a839", + "meta_sha256": "sha256:d891c2343b62c6881a84d338f873021c550e159c0cad06efc2e6d7059862a463", "name": "migration-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:063c8b0a474515dce78dac4196f49e2a0a25912e0d794a65c64387a9033448f5", - "canonical_sha256": "sha256:e499fd8757af810839c4421ee5affbfbb43bb72d9ff542fb40849583fb3e81c8", - "claude_rendered_sha256": "sha256:1f9838f8b4d1e7def32d9eb7f3d2a6f6b8a9bdabbc1572870dae68bc843d341c", - "meta_sha256": "sha256:d934b1fa74fcce6b2825c6e4c7744a4a57d1fbcd7d15577f864a4c72941d029c", + "canonical_sha256": "sha256:2c47acd249f207d5264d9e321d71c1c09fecd1877384c7557468dc45a8c57dc6", + "claude_rendered_sha256": "sha256:94b2048205fd22cfe567cebb69480a0ec20019348da50fc16fec0b8d8f1d4927", + "meta_sha256": "sha256:d98573ef1c6b622a49fec659de9f9c7171f47ece73f8775536810f2e6abd1380", "name": "privacy-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:3a063e1f8e93f442e622c6da68eeb0196c33d16d1a7b114b85ae18d48e4222bc", - "canonical_sha256": "sha256:2c1e1682efcb2e62296711cd1e0a143a33228600498d70a6fee5a20fdb0f5a9c", - "claude_rendered_sha256": "sha256:8df93b79fceab51ff41d49996253e07c3b9c50c0ea132487e32a233dc5bc770d", - "meta_sha256": "sha256:33ef3b93f0720a661cc686854b5a9f60e67cd5195a706aee97af125fb06e02a2", + "canonical_sha256": "sha256:8bb47653d96e9a754e49fffa9ca57b10d56a7a285fada21f41e84f93b43ae7eb", + "claude_rendered_sha256": "sha256:d6c87053dc0b9abb17244e49f1192812ed201e8314ef19bfe00f500f39ab4d32", + "meta_sha256": "sha256:4c7cd02a783821b8452c14e2dcdd966c1cbca52f54b3e45042e9347b386073aa", "name": "resilience-review", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:b11892bc3f625828f52c4d90a33b68e1df581cea5bd0cf984d1e56f88efff7be", - "canonical_sha256": "sha256:f7fb3e489ba1dff5324dda4682166d60b745379401a26505e29efb8261f4f46c", - "claude_rendered_sha256": "sha256:5250d9e6f57d0830a16a2ead1b505b77d61e0a4edc82ce6e3b878a59f040e710", - "meta_sha256": "sha256:60a12438cf365b8d31db1c15a22bec93acf3b9067b791933405ff038a54ef447", + "canonical_sha256": "sha256:5a8fb1996e46d2b3e587d2b179a1b6d72b61dcc99e488a8769de742204d6b256", + "claude_rendered_sha256": "sha256:838688b04df58f5340fbd2ab89d196a793009e045a882a40ebf8b05a0f9fd793", + "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", + "canonical_sha256": "sha256:2515efd80f16e99f8f9c2c032720a31c425849ce58d83ea5d1c9991d531e702e", + "claude_rendered_sha256": "sha256:49815f06f83aabaed319c083fe1d41fb0d21200ee6df993825b24c18985596fd", + "meta_sha256": "sha256:7f2d57267060589f2a765469872047c2998240988937f4631a1f5aff7234707e", "name": "test-review", - "version": "0.3.0" + "version": "0.3.1" } ] } diff --git a/claude-plugin/skills/authentication-review/SKILL.md b/claude-plugin/skills/authentication-review/SKILL.md index db3cf87..18fcc67 100644 --- a/claude-plugin/skills/authentication-review/SKILL.md +++ b/claude-plugin/skills/authentication-review/SKILL.md @@ -293,3 +293,13 @@ Open the report with one line stating what was reviewed and the outcome, e.g. `Reviewed origin/main...HEAD (3 files): 2 findings, worst critical.` If the diff touches no authentication code, say so and stop. If the authentication changes are sound, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/ci-workflow-review/SKILL.md b/claude-plugin/skills/ci-workflow-review/SKILL.md index 1c3b322..cb7960c 100644 --- a/claude-plugin/skills/ci-workflow-review/SKILL.md +++ b/claude-plugin/skills/ci-workflow-review/SKILL.md @@ -256,3 +256,13 @@ Open the report with one line stating what was reviewed and the outcome, e.g. `Reviewed origin/main...HEAD (2 workflows): 1 finding, critical.` If the diff touches no pipeline configuration, say so and stop. If the pipeline changes are sound, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/code-smells/SKILL.md b/claude-plugin/skills/code-smells/SKILL.md index 9cc5bfc..c7706b5 100644 --- a/claude-plugin/skills/code-smells/SKILL.md +++ b/claude-plugin/skills/code-smells/SKILL.md @@ -121,3 +121,13 @@ Open the report with one line stating what was reviewed and the outcome, e.g. `Reviewed origin/main...HEAD (4 files): 3 findings, worst high.` If the diff touches no code (e.g. docs or config only), say so and stop. If the change is clean, say so rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/dependency-review/SKILL.md b/claude-plugin/skills/dependency-review/SKILL.md index 596c4ca..fc33160 100644 --- a/claude-plugin/skills/dependency-review/SKILL.md +++ b/claude-plugin/skills/dependency-review/SKILL.md @@ -133,3 +133,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 + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files`, `npm audit`, `pip-audit`, `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 +- Agent tools: web fetch, to read advisory pages diff --git a/claude-plugin/skills/frontend-security-review/SKILL.md b/claude-plugin/skills/frontend-security-review/SKILL.md index 8e30f70..c46a983 100644 --- a/claude-plugin/skills/frontend-security-review/SKILL.md +++ b/claude-plugin/skills/frontend-security-review/SKILL.md @@ -160,3 +160,13 @@ survive, report the ones worth a human's time and summarize the rest in a line. Open the report with one line stating what was reviewed and the outcome, e.g. `Reviewed origin/main...HEAD (3 files): 2 findings, worst high.` If the change is sound, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/iac-review/SKILL.md b/claude-plugin/skills/iac-review/SKILL.md index b3ee169..14a8b74 100644 --- a/claude-plugin/skills/iac-review/SKILL.md +++ b/claude-plugin/skills/iac-review/SKILL.md @@ -180,3 +180,13 @@ Open the report with one line stating what was reviewed and the outcome, e.g. `Reviewed origin/main...HEAD (3 files): 1 finding, critical.` If the diff touches no infrastructure code, say so and stop. If the infrastructure changes are sound, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/llm-integration-review/SKILL.md b/claude-plugin/skills/llm-integration-review/SKILL.md index 1865b58..afa9dbd 100644 --- a/claude-plugin/skills/llm-integration-review/SKILL.md +++ b/claude-plugin/skills/llm-integration-review/SKILL.md @@ -182,3 +182,13 @@ report the ones worth a human's time and summarize the rest in a line. Open the report with one line stating what was reviewed and the outcome, e.g. `Reviewed origin/main...HEAD (3 files): 2 findings, worst critical.` If the integration is sound, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/logging/SKILL.md b/claude-plugin/skills/logging/SKILL.md index c0a7275..2fbfc9f 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 + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; may edit files in the repository +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/migration-review/SKILL.md b/claude-plugin/skills/migration-review/SKILL.md index 78d7937..0786807 100644 --- a/claude-plugin/skills/migration-review/SKILL.md +++ b/claude-plugin/skills/migration-review/SKILL.md @@ -166,3 +166,13 @@ Open the report with one line stating what was reviewed and the outcome, e.g. contains no schema or data migration, say so and stop. If the migrations are safe for the project's deploy model, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/privacy-review/SKILL.md b/claude-plugin/skills/privacy-review/SKILL.md index 18bb777..2bdc2ed 100644 --- a/claude-plugin/skills/privacy-review/SKILL.md +++ b/claude-plugin/skills/privacy-review/SKILL.md @@ -156,3 +156,13 @@ Open the report with one line stating what was reviewed and the outcome, e.g. `Reviewed origin/main...HEAD (4 files): 2 findings, worst high.` If the change handles personal data soundly, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/resilience-review/SKILL.md b/claude-plugin/skills/resilience-review/SKILL.md index f7d38a0..d96e5b0 100644 --- a/claude-plugin/skills/resilience-review/SKILL.md +++ b/claude-plugin/skills/resilience-review/SKILL.md @@ -146,3 +146,13 @@ Open the report with one line stating what was reviewed and the outcome, e.g. doesn't cross a failure boundary (pure logic, local computation, docs/config), say so and stop. If it introduces no new failure modes, say so explicitly rather than manufacturing findings. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/security-review/SKILL.md b/claude-plugin/skills/security-review/SKILL.md index bc054c4..e2d9a8e 100644 --- a/claude-plugin/skills/security-review/SKILL.md +++ b/claude-plugin/skills/security-review/SKILL.md @@ -180,3 +180,13 @@ checked them for pasted credentials), say so and stop. If no security-relevant issues are found, say the change is clean explicitly rather than padding the report. Do not flag stylistic issues — that is the job of code review. + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/claude-plugin/skills/test-review/SKILL.md b/claude-plugin/skills/test-review/SKILL.md index 4e473bc..7d02f6a 100644 --- a/claude-plugin/skills/test-review/SKILL.md +++ b/claude-plugin/skills/test-review/SKILL.md @@ -118,3 +118,13 @@ 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 + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the repository; edits no files +- Commands: `git fetch`, `git diff`, `git ls-files`, `` +- Network: the git remote, via git fetch, to bring the base branch up to date diff --git a/docs/adapters.md b/docs/adapters.md index 2874031..82a5c2c 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 anything beyond reading files (commands, network +access, credentials, agent tools, edits or new files), every adapter, legacy +formats included, appends a `## Declared capabilities` section to the body, +listing the skill's [capability declaration](authoring-skills.md#capabilities) +so it travels with the installed file. `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 64fcd58..c187965 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. @@ -79,6 +97,85 @@ that agent would have nothing to move to). `skilldeck list` and they write one, and `skilldeck catalog --json` reports the record to tools (see [the skill catalog](catalog.md)). +### 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 (as `logging` does when it adds logging). Files outside the repository are never covered. | +| `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: a skill cannot ship scripts. | +| `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, 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. + +**Anything not declared is not requested.** An agent, or a person reviewing +what it did, should treat an attempt to go beyond the declaration (another +command, another host, a file outside the repository) as not coming from the +skill, and refuse it or review it by hand. 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 write, writing nothing. +- **In the installed file**: a skill that asks for anything beyond reading + files (a command, network access, credentials, agent tools, edits or new + files) gets a `## Declared capabilities` section appended to its body in + every adapter's output, so the declaration travels with the file. A skill + that only reads is rendered unchanged. +- **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 program some skill +declares (`git diff origin/...HEAD`) must start with a command the +skill declares, and every declared command must appear in the body. + +### 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, even one pointing at a file with the right content, and a skill + directory that is itself a symlink; +- 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. + +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 or a `file:` URL +(in a Markdown link or image, a reference definition, or an HTML `src` or +`href`) names a file the skill can't ship, so the loader rejects it as a +missing asset. Code spans and fenced code blocks are not checked, so examples +stay possible. `skilldeck provenance --verify` and `skilldeck catalog` apply +the same bundle rules to the installed package. + ## `skill.md` The agent-neutral body of the skill — the actual instructions/prompt. Write it @@ -110,6 +207,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 ee5b835..9d75f62 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": [] + } } ] } @@ -78,12 +89,22 @@ from it, rather than trusting a second file that could drift from it. first carried the deprecation), `replacement` (the skill to use instead, or `null`) and `reason`. See [Deprecating a skill](authoring-skills.md#deprecating-a-skill). +- `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). A new capability schema + number is a catalog change like any other, under the rules below. `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, or breaks +the [bundle rules](authoring-skills.md#what-a-skill-directory-may-hold) (a +file besides `meta.yaml` and `skill.md`, or a symlink; 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. 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 diff --git a/docs/compatibility.md b/docs/compatibility.md index 1e55179..044225c 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,9 @@ 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 (`git diff`), 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/verifying-releases.md b/docs/verifying-releases.md index c35611a..d7f3abe 100644 --- a/docs/verifying-releases.md +++ b/docs/verifying-releases.md @@ -91,7 +91,7 @@ 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 +or has unexpected files or symlinks 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 steps above) before installing it. 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..0ccc2dd 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", - "claude_rendered_sha256": "sha256:43aa9cb65459b19211fa2349e5b4f9362f90638f23884ec159e709cbdd3109e4", - "meta_sha256": "sha256:faf1d1d2f3f630a6efce7f5df11e3290b9f4124ab9562d96a980fe89dfc242ab", + "canonical_sha256": "sha256:348085527ac5aaa2cfb61114357ad2eb5eba688d70a0a6cc10f103a89736025e", + "claude_rendered_sha256": "sha256:ed7cbed96fbbc1f6e9c250a18dc7b2b05aa51e7054403eee1dddd9d4e125a4a0", + "meta_sha256": "sha256:2e0f9a777255b57f101c51c94a14c9caea8557f581cb8db9f1b46937aa81f7b9", "name": "authentication-review", - "version": "0.2.0" + "version": "0.2.1" }, { "body_sha256": "sha256:e80a382e92dd367d577a3bf2251be01713f7ffc9405008857609bcfdd5f387c9", - "canonical_sha256": "sha256:fb28b8bc8aeae01b9983f2b91f1019767416e8ef4c56feb91cb7ad418fd64e34", - "claude_rendered_sha256": "sha256:e7f770e9e6f9b552bb0474364bc132887920c880fbbe3b2bba659b3448b6adc7", - "meta_sha256": "sha256:121aef136ba48413dd05dac91934798580ea762b99f01d349898469918a625a8", + "canonical_sha256": "sha256:20ed849d325c711ee35ce6bc46125ce6bb3d240fc4891791634ee4f8e8caa035", + "claude_rendered_sha256": "sha256:d298c20e392f9d161950748abc7c8eb683cc00ac781e37cded82173412dd1fb6", + "meta_sha256": "sha256:216940dcd5591f170760ab0350a4975ba55fbf50518a836cf594362719dd4477", "name": "ci-workflow-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:bf082cd5c84b729d6c3412fb83d4d771e7dd4d1e30bba11bdb2b58fc3208dd3f", - "canonical_sha256": "sha256:e4dcbee8ff34424923afdfb40cf0966c6cf2f388e086142d4e3208b7ce8e5e7d", - "claude_rendered_sha256": "sha256:aa4f2b00ce2a7d44f66689064a5acfdde873a2d45d292a8d50bf53ef9d2033df", - "meta_sha256": "sha256:d289c63d3178f50e004fa6f1eec3489f2a3bd0236e37b778af45d086c327f32a", + "canonical_sha256": "sha256:16d3b7a55046e42c4fca537da00c1e7764d835671a9e98fb0660c732efeabb8b", + "claude_rendered_sha256": "sha256:1a94277d91107cbea38abb9ce14b01a1e700a73ba4678497833ef37f043b6860", + "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", + "canonical_sha256": "sha256:2e11c0727244de8ecd65f88002129bef53052af4f785448cd0ae47b7b8f5935c", + "claude_rendered_sha256": "sha256:09ac5ed5f310c8162fb16ed95c17eff0b294ea980cf36dd8e5a175c4fa7abfa3", + "meta_sha256": "sha256:70f1847d50c29492801c318115436a5c18686e033beeb6a910f99a8038321765", "name": "dependency-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:94ab468a0837179a8fa7470be404884cfc475f05f8be2314bff6e409ecffc5e5", - "canonical_sha256": "sha256:c3c2bde57bbb2c220da2f44c775e1d2bb71956915572094acd0fcf8a1c411186", - "claude_rendered_sha256": "sha256:11d8bdbf3419cb7c4040bc02e19b502d5b64ba77e65aa2190e1cd51dc826d76a", - "meta_sha256": "sha256:6337828e91940730210bd7fea7690573610f9413d484d40a2596772c387b94fb", + "canonical_sha256": "sha256:74930194c26657d0107d59520a6baa33e6ccdb3c3915773f137f0f11940ceb2f", + "claude_rendered_sha256": "sha256:bc8c6161a56b7a33b890b03f9ca2e796f2bcfe9d2ed5ea322b4bb86318f31798", + "meta_sha256": "sha256:d2dc89cdda1beaeff5a10b911488e89a00a4bd4d3408cb7a41d58a0b92833cd2", "name": "frontend-security-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:5503c0d7457247cfa17b81e7485e4ea2d8bdcc00c244f77e32d9df5ffc24b67b", - "canonical_sha256": "sha256:d3b1a7aedb82eb14724253a447c1b67d9d3e8f5928f9b3555c7c17a9975bf529", - "claude_rendered_sha256": "sha256:0e2496faefe746b3e1db50e470c509f1eaea8cb29c4e273f958088cdbd2589a7", - "meta_sha256": "sha256:8888d07def4742b7c284f44392a5b4cbdb3bf551736e4a3b93e71cc36cf76913", + "canonical_sha256": "sha256:92e44ffd9f514cf5b12fe4c97ce955d50a1167ff63b317549b5a81ca89850870", + "claude_rendered_sha256": "sha256:d1549e3ed3b9957bee79ee4df79cac83cae3775c00aa3ae0cb7242fcfa286fda", + "meta_sha256": "sha256:38ddbca88c334c54c4c22832018635394e8e5feed57ef129c749d90057bb28bc", "name": "iac-review", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:14be89e51271a75e43d0fcf7104bf18ca9d7516cf6da63d063e3af7cb4fa8b4f", - "canonical_sha256": "sha256:cbf0af07dc478897b976c9ecb15f708f4f94de73dd2999b9433a7d2a374f80df", - "claude_rendered_sha256": "sha256:e1f93c5321cd7bdb6eeff97c5f8888a67d27294e2abbfe491fac260db0e7ea60", - "meta_sha256": "sha256:a90e3c80901dc33337063e09d9cfcd2d39d25e0266acf0a6988fe5a941ad4e08", + "canonical_sha256": "sha256:e36515eb30b2eec5c54f42e2846514b564dfafc5b530168439321d327721fea2", + "claude_rendered_sha256": "sha256:5cd7317daf5a33bc4a574974ccbce76d75748a03a1f976dc953770c1e88f7147", + "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:5a144238b23a2387b4029c984132ff48a08f18e88ebe38d99f124200a0e59427", + "meta_sha256": "sha256:75c39abf8f7ae9599ceaaf65bcc79776e04ccc0595f54949c52af1dcf204c730", "name": "logging", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:3e869f7883e118db18e8c8002f049d8ecf2895e49aa7c76f1d7a39a6ee842c8b", - "canonical_sha256": "sha256:2a90dcac4e099baf9018049929e4cd803b236afb4219da70f9e84ddc501d2189", - "claude_rendered_sha256": "sha256:4dfff856963c37785deb71d8385783df137a6bfe7bc692877d71a9bb5d3bc6f9", - "meta_sha256": "sha256:006759e87ea326c310ad454d4da8f1459c909a0ac25905a38c37edb8d8b743db", + "canonical_sha256": "sha256:1a4c38c4b51a52b60330cd09de25aa7f1b30458fb23c2cb3dd1bdb9b705fc6a9", + "claude_rendered_sha256": "sha256:3452801c9da837de0dc64c87229bacb294597344492f2194b8233f530289a839", + "meta_sha256": "sha256:d891c2343b62c6881a84d338f873021c550e159c0cad06efc2e6d7059862a463", "name": "migration-review", - "version": "0.4.0" + "version": "0.4.1" }, { "body_sha256": "sha256:063c8b0a474515dce78dac4196f49e2a0a25912e0d794a65c64387a9033448f5", - "canonical_sha256": "sha256:e499fd8757af810839c4421ee5affbfbb43bb72d9ff542fb40849583fb3e81c8", - "claude_rendered_sha256": "sha256:1f9838f8b4d1e7def32d9eb7f3d2a6f6b8a9bdabbc1572870dae68bc843d341c", - "meta_sha256": "sha256:d934b1fa74fcce6b2825c6e4c7744a4a57d1fbcd7d15577f864a4c72941d029c", + "canonical_sha256": "sha256:2c47acd249f207d5264d9e321d71c1c09fecd1877384c7557468dc45a8c57dc6", + "claude_rendered_sha256": "sha256:94b2048205fd22cfe567cebb69480a0ec20019348da50fc16fec0b8d8f1d4927", + "meta_sha256": "sha256:d98573ef1c6b622a49fec659de9f9c7171f47ece73f8775536810f2e6abd1380", "name": "privacy-review", - "version": "0.1.0" + "version": "0.1.1" }, { "body_sha256": "sha256:3a063e1f8e93f442e622c6da68eeb0196c33d16d1a7b114b85ae18d48e4222bc", - "canonical_sha256": "sha256:2c1e1682efcb2e62296711cd1e0a143a33228600498d70a6fee5a20fdb0f5a9c", - "claude_rendered_sha256": "sha256:8df93b79fceab51ff41d49996253e07c3b9c50c0ea132487e32a233dc5bc770d", - "meta_sha256": "sha256:33ef3b93f0720a661cc686854b5a9f60e67cd5195a706aee97af125fb06e02a2", + "canonical_sha256": "sha256:8bb47653d96e9a754e49fffa9ca57b10d56a7a285fada21f41e84f93b43ae7eb", + "claude_rendered_sha256": "sha256:d6c87053dc0b9abb17244e49f1192812ed201e8314ef19bfe00f500f39ab4d32", + "meta_sha256": "sha256:4c7cd02a783821b8452c14e2dcdd966c1cbca52f54b3e45042e9347b386073aa", "name": "resilience-review", - "version": "0.3.0" + "version": "0.3.1" }, { "body_sha256": "sha256:b11892bc3f625828f52c4d90a33b68e1df581cea5bd0cf984d1e56f88efff7be", - "canonical_sha256": "sha256:f7fb3e489ba1dff5324dda4682166d60b745379401a26505e29efb8261f4f46c", - "claude_rendered_sha256": "sha256:5250d9e6f57d0830a16a2ead1b505b77d61e0a4edc82ce6e3b878a59f040e710", - "meta_sha256": "sha256:60a12438cf365b8d31db1c15a22bec93acf3b9067b791933405ff038a54ef447", + "canonical_sha256": "sha256:5a8fb1996e46d2b3e587d2b179a1b6d72b61dcc99e488a8769de742204d6b256", + "claude_rendered_sha256": "sha256:838688b04df58f5340fbd2ab89d196a793009e045a882a40ebf8b05a0f9fd793", + "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", + "canonical_sha256": "sha256:2515efd80f16e99f8f9c2c032720a31c425849ce58d83ea5d1c9991d531e702e", + "claude_rendered_sha256": "sha256:49815f06f83aabaed319c083fe1d41fb0d21200ee6df993825b24c18985596fd", + "meta_sha256": "sha256:7f2d57267060589f2a765469872047c2998240988937f4631a1f5aff7234707e", "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..484da71 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. @@ -241,7 +253,13 @@ 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 every check a real install makes, raising the same + errors, then returns without writing anything. + """ self.check_scope(scope, installing=True) dest = self.destination(skill, scope, project_root) mode = _entry_mode(dest) @@ -273,6 +291,8 @@ def install( f"{dest} has local modifications; " "re-run with --force to overwrite them" ) + if dry_run: + 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..db02360 --- /dev/null +++ b/src/skilldeck/capabilities.py @@ -0,0 +1,346 @@ +"""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: an attempt to do it +comes from somewhere other than the skill, to refuse or review by hand. +``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 + +# 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 +_NOTICE_LABELS = ( + ("Commands", "commands"), + ("Network", "network"), + ("Credentials", "credentials"), + ("Agent tools", "tools"), + ("Creates", "artifacts"), +) +_CODE_FIELDS = frozenset({"commands", "artifacts"}) + + +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_reading(self) -> bool: + """Whether it asks for anything but reading files.""" + return self.write != "none" or any( + getattr(self, field) for field in LIST_FIELDS + ) + + 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 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 _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 reading files; empty for one that doesn't. + + It travels with the installed file, so whoever reads it (the agent, or a + person reviewing what was installed) sees the declaration. Commands and + paths are code, listed inline; descriptions get an item each once there + are several. + """ + if not capabilities.beyond_reading: + return "" + lines = [ + "## Declared capabilities", + "", + "What this skill may ask for, as declared in its skilldeck metadata", + f"(capability schema {CAPABILITY_SCHEMA}). The declaration is for " + "review: nothing enforces it.", + "Anything not listed here is not requested by this skill.", + "", + f"- Files: {files_text(capabilities)}", + ] + for label, field in _NOTICE_LABELS: + entries: tuple[str, ...] = getattr(capabilities, field) + if not entries: + continue + if field in _CODE_FIELDS: + lines.append(f"- {label}: " + ", ".join(f"`{e}`" for e in entries)) + elif len(entries) == 1: + lines.append(f"- {label}: {entries[0]}") + else: + lines.append(f"- {label}:") + lines.extend(f" - {entry}" for entry in entries) + 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..0a29bfc 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. Changing it is a catalog change like any other (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..81b9500 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,62 @@ def _warn_deprecated(skills: Iterable[Skill]) -> None: ) +def _built_from() -> str: + """Where this package says it was built from, for a summary.""" + try: + build = load_build_metadata() + except ValueError as exc: + return f"unknown ({exc})" + if build["source_ref"] is None: + return "a development build (no release tag or commit recorded)" + return f"{build['source_ref']}, commit {build['source_commit']}" + + +def _digest_status(skill: Skill) -> str: + """``skill``'s canonical digest, and whether the content manifest recorded + when the package was built vouches for it.""" + 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)" + if digest != expected: + return f"{digest} (does NOT match the content manifest's {expected})" + return f"{digest} (matches the content manifest)" + + +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()}", + f" digest: {_digest_status(skill)}", + 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 +466,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 +509,49 @@ 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 same + checks as a real install (``Adapter.install`` with ``dry_run``), and the + same errors on stderr. + """ + 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 +598,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..052cef6 100644 --- a/src/skilldeck/provenance.py +++ b/src/skilldeck/provenance.py @@ -16,7 +16,7 @@ from . import __version__ from .adapters import ADAPTERS -from .registry import DEFAULT_SKILLS_DIR, Skill, discover_skills +from .registry import DEFAULT_SKILLS_DIR, Skill, bundle_problems, discover_skills SCHEMA_VERSION = 1 PACKAGE_NAME = "skilldeck" @@ -279,8 +279,10 @@ 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 + that is missing or unreadable, any skill directory the manifest does not + list, and anything in a skill directory that breaks the bundle rules + (:func:`~skilldeck.registry.bundle_problems`: an extra file, an + executable, a symlink). An empty list means the installed skills are exactly the ones the manifest records. """ root = skills_dir or DEFAULT_SKILLS_DIR @@ -298,15 +300,14 @@ def verify_bundled_skills(skills_dir: Path | None = None) -> list[str]: for name, record in sorted(records.items()): skill_dir = root / name try: - entries = sorted(child.name for child in skill_dir.iterdir()) meta_text = (skill_dir / "meta.yaml").read_text(encoding="utf-8") body_text = (skill_dir / "skill.md").read_text(encoding="utf-8") 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"}] - if extras: - problems.append(f"{name}: unexpected file(s): {', '.join(extras)}") + if skill_dir.is_symlink(): + problems.append(f"{name}: the skill directory is a symlink") + problems.extend(f"{name}: {problem}" for problem in bundle_problems(skill_dir)) 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..1e5fda1 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,21 @@ import yaml +from .capabilities import 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 +60,45 @@ # ``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") +# Suffixes of files a shell, an interpreter or the OS runs as a program; only +# used to say why an extra file is refused (every extra file is). +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 +# 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 code blocks and code spans. +_FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})") +_CODE_SPAN_RE = re.compile(r"(?]*)"), + re.compile(r"^ {0,3}\[[^\]]+\]:\s*]+)", re.M), + re.compile(r"""\b(?:src|href)\s*=\s*["']?([^"'\s>]+)""", 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 +123,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 +137,16 @@ 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 skill_dir.is_symlink(): + raise SkillError(f"{skill_dir}: the skill directory is a symlink") + 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 +228,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 +255,102 @@ def load_skill(skill_dir: Path, known_agents: Collection[str] | None = None) -> body=body, path=skill_dir, deprecated=deprecated, + capabilities=capabilities, ) +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 (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. + """ + 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 + if stat.S_ISLNK(mode): + problems.append(f"{entry.name} is a symlink") + 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 and reference definitions, and HTML ``src`` and + ``href`` attributes, outside code blocks 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. + """ + prose: list[str] = [] + fence: str | None = None + for line in body.splitlines(): + match = _FENCE_RE.match(line) + if fence is None: + if match: + fence = match.group(1) + continue + prose.append(line) + elif ( + match + and match.group(1)[0] == fence[0] + and len(match.group(1)) >= len(fence) + ): + fence = None + text = _CODE_SPAN_RE.sub("", "\n".join(prose)) + found: set[str] = set() + for pattern in _LINK_TARGET_RES: + for target in pattern.findall(text): + 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 _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 +454,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("."): + continue + # checked before is_dir(), which follows the link + if child.is_symlink(): + raise SkillError(f"{child}: the skill directory is a symlink") + 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..fdd8301 100644 --- a/src/skilldeck/skills/dependency-review/meta.yaml +++ b/src/skilldeck/skills/dependency-review/meta.yaml @@ -1,10 +1,34 @@ 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.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 + - npm audit + - pip-audit + - 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 + credentials: [] + tools: + - web fetch, to read advisory pages + artifacts: [] 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..97e37c7 100644 --- a/src/skilldeck/skills/test-review/meta.yaml +++ b/src/skilldeck/skills/test-review/meta.yaml @@ -1,10 +1,25 @@ 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 + - + network: + - the git remote, via git fetch, to bring the base branch up to date + credentials: [] + tools: [] + artifacts: [] diff --git a/tests/fixtures/adapter-contracts/claude/SKILL.md b/tests/fixtures/adapter-contracts/claude/SKILL.md index 5ae88d0..fd30b0f 100644 --- a/tests/fixtures/adapter-contracts/claude/SKILL.md +++ b/tests/fixtures/adapter-contracts/claude/SKILL.md @@ -11,4 +11,13 @@ 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. - + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the changed files; edits no files +- Commands: `git diff` + diff --git a/tests/fixtures/adapter-contracts/codex/SKILL.md b/tests/fixtures/adapter-contracts/codex/SKILL.md index 5ae88d0..fd30b0f 100644 --- a/tests/fixtures/adapter-contracts/codex/SKILL.md +++ b/tests/fixtures/adapter-contracts/codex/SKILL.md @@ -11,4 +11,13 @@ 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. - + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the changed files; edits no files +- Commands: `git diff` + 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..153ce7b 100644 --- a/tests/fixtures/adapter-contracts/copilot-prompt/contract-demo.prompt.md +++ b/tests/fixtures/adapter-contracts/copilot-prompt/contract-demo.prompt.md @@ -11,4 +11,13 @@ 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. - + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the changed files; edits no files +- Commands: `git diff` + diff --git a/tests/fixtures/adapter-contracts/copilot/SKILL.md b/tests/fixtures/adapter-contracts/copilot/SKILL.md index 5ae88d0..fd30b0f 100644 --- a/tests/fixtures/adapter-contracts/copilot/SKILL.md +++ b/tests/fixtures/adapter-contracts/copilot/SKILL.md @@ -11,4 +11,13 @@ 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. - + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the changed files; edits no files +- Commands: `git diff` + diff --git a/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc b/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc index 3ae2d78..8faec96 100644 --- a/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc +++ b/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc @@ -10,4 +10,13 @@ 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. - + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the changed files; edits no files +- Commands: `git diff` + diff --git a/tests/fixtures/adapter-contracts/cursor/SKILL.md b/tests/fixtures/adapter-contracts/cursor/SKILL.md index 5ae88d0..fd30b0f 100644 --- a/tests/fixtures/adapter-contracts/cursor/SKILL.md +++ b/tests/fixtures/adapter-contracts/cursor/SKILL.md @@ -11,4 +11,13 @@ 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. - + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the changed files; edits no files +- Commands: `git diff` + diff --git a/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md b/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md index 8e33a1c..18d70d8 100644 --- a/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md +++ b/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md @@ -9,4 +9,13 @@ 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. - + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the changed files; edits no files +- Commands: `git diff` + diff --git a/tests/fixtures/adapter-contracts/kiro/SKILL.md b/tests/fixtures/adapter-contracts/kiro/SKILL.md index 5ae88d0..fd30b0f 100644 --- a/tests/fixtures/adapter-contracts/kiro/SKILL.md +++ b/tests/fixtures/adapter-contracts/kiro/SKILL.md @@ -11,4 +11,13 @@ 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. - + +## Declared capabilities + +What this skill may ask for, as declared in its skilldeck metadata +(capability schema 1). The declaration is for review: nothing enforces it. +Anything not listed here is not requested by this skill. + +- Files: reads the changed files; edits no files +- Commands: `git diff` + diff --git a/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml b/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml index 45f71ed..0bfcd0a 100644 --- a/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml +++ b/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml @@ -8,3 +8,14 @@ supported-agents: - copilot - cursor - kiro +capabilities: + schema: 1 + files: + read: diff + write: none + commands: + - git diff + network: [] + credentials: [] + tools: [] + artifacts: [] 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..7f5e2aa --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,623 @@ +"""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 ( + 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} + + +def test_read_only_declaration_requests_nothing_beyond_reading(): + caps = parse_capabilities(_raw()) + assert caps == Capabilities(read="repo") + assert not caps.beyond_reading + assert not Capabilities().beyond_reading + for field, value in ( + ("write", "repo"), + ("commands", ("git diff",)), + ("network", ("x",)), + ("credentials", ("x",)), + ("tools", ("x",)), + ("artifacts", ("x.md",)), + ): + assert Capabilities(**{field: value}).beyond_reading, field + + +@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=["+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("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_read_only_skills_get_no_notice(): + assert notice(Capabilities(read="repo")) == "" + assert with_notice("body", Capabilities(read="repo")) == "body" + + +def test_notice_lists_what_the_skill_declares(): + assert with_notice("# Demo\n", FULL) == ( + "# Demo\n" + "\n" + "## Declared capabilities\n" + "\n" + "What this skill may ask for, as declared in its skilldeck metadata\n" + "(capability schema 1). The declaration is for review: nothing enforces it.\n" + "Anything not listed here is not requested by this skill.\n" + "\n" + "- Files: reads the changed files; may edit files in the repository\n" + "- Commands: `git diff`, ``\n" + "- Network:\n" + " - the git remote, to fetch\n" + " - an advisory database\n" + "- Credentials: a registry token from NPM_TOKEN\n" + "- Agent tools: web fetch\n" + "- Creates: `reports/review.md`\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 _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",)), + Capabilities(read="repo", network=("an advisory database",)), + Capabilities(read="repo", credentials=("a token from GH_TOKEN",)), + 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 skill that only reads renders its body unchanged + plain = adapter.render(_skill(Capabilities(read="repo"))) + 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 "- Creates: `reports/review.md`\n + 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, @@ -226,7 +226,8 @@ The full list of sources, with paths and line numbers, is in - `skill/contract-demo/` is a small synthetic skill. Its description needs YAML quoting and folding, and its body has non-ASCII text. It declares a - command (`git diff`), so every expected file also pins the + 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 diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index d7f3abe..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 or symlinks 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/src/skilldeck/_content_manifest.json b/src/skilldeck/_content_manifest.json index 0ccc2dd..3407b53 100644 --- a/src/skilldeck/_content_manifest.json +++ b/src/skilldeck/_content_manifest.json @@ -5,7 +5,7 @@ { "body_sha256": "sha256:366ffcaa3183fd009c3c59953b1e571ab88bdc426f277584b60198b2d8d47ab8", "canonical_sha256": "sha256:348085527ac5aaa2cfb61114357ad2eb5eba688d70a0a6cc10f103a89736025e", - "claude_rendered_sha256": "sha256:ed7cbed96fbbc1f6e9c250a18dc7b2b05aa51e7054403eee1dddd9d4e125a4a0", + "claude_rendered_sha256": "sha256:43aa9cb65459b19211fa2349e5b4f9362f90638f23884ec159e709cbdd3109e4", "meta_sha256": "sha256:2e0f9a777255b57f101c51c94a14c9caea8557f581cb8db9f1b46937aa81f7b9", "name": "authentication-review", "version": "0.2.1" @@ -13,7 +13,7 @@ { "body_sha256": "sha256:e80a382e92dd367d577a3bf2251be01713f7ffc9405008857609bcfdd5f387c9", "canonical_sha256": "sha256:20ed849d325c711ee35ce6bc46125ce6bb3d240fc4891791634ee4f8e8caa035", - "claude_rendered_sha256": "sha256:d298c20e392f9d161950748abc7c8eb683cc00ac781e37cded82173412dd1fb6", + "claude_rendered_sha256": "sha256:e7f770e9e6f9b552bb0474364bc132887920c880fbbe3b2bba659b3448b6adc7", "meta_sha256": "sha256:216940dcd5591f170760ab0350a4975ba55fbf50518a836cf594362719dd4477", "name": "ci-workflow-review", "version": "0.4.1" @@ -21,23 +21,23 @@ { "body_sha256": "sha256:bf082cd5c84b729d6c3412fb83d4d771e7dd4d1e30bba11bdb2b58fc3208dd3f", "canonical_sha256": "sha256:16d3b7a55046e42c4fca537da00c1e7764d835671a9e98fb0660c732efeabb8b", - "claude_rendered_sha256": "sha256:1a94277d91107cbea38abb9ce14b01a1e700a73ba4678497833ef37f043b6860", + "claude_rendered_sha256": "sha256:aa4f2b00ce2a7d44f66689064a5acfdde873a2d45d292a8d50bf53ef9d2033df", "meta_sha256": "sha256:e97b0202b8caf9d7f8b008665a37ff0ad4b9fe259cf5e50e83739434df9ceabb", "name": "code-smells", "version": "0.3.1" }, { - "body_sha256": "sha256:d7081d47124427a0bf25ae6eb56c527efe63063b17781da4ee34fc3a118be2b7", - "canonical_sha256": "sha256:2e11c0727244de8ecd65f88002129bef53052af4f785448cd0ae47b7b8f5935c", - "claude_rendered_sha256": "sha256:09ac5ed5f310c8162fb16ed95c17eff0b294ea980cf36dd8e5a175c4fa7abfa3", - "meta_sha256": "sha256:70f1847d50c29492801c318115436a5c18686e033beeb6a910f99a8038321765", + "body_sha256": "sha256:45fe7888314ff411831fff7591dcda21a2fb99bbc54fbed8ca35a347bd030ce9", + "canonical_sha256": "sha256:c0f2adab979b206e8aa72e1b2cdddf66984bf0a0277a6e06293e73b99e035947", + "claude_rendered_sha256": "sha256:07723f0640d98090d95a0d88424eb12b7f99a297be85396fba35eeaf4bd97807", + "meta_sha256": "sha256:dc0d6fe9bd291eea0ebc41937d1618dafe989e838f6116c95fd0e8bf0e52fb52", "name": "dependency-review", "version": "0.4.1" }, { "body_sha256": "sha256:94ab468a0837179a8fa7470be404884cfc475f05f8be2314bff6e409ecffc5e5", "canonical_sha256": "sha256:74930194c26657d0107d59520a6baa33e6ccdb3c3915773f137f0f11940ceb2f", - "claude_rendered_sha256": "sha256:bc8c6161a56b7a33b890b03f9ca2e796f2bcfe9d2ed5ea322b4bb86318f31798", + "claude_rendered_sha256": "sha256:11d8bdbf3419cb7c4040bc02e19b502d5b64ba77e65aa2190e1cd51dc826d76a", "meta_sha256": "sha256:d2dc89cdda1beaeff5a10b911488e89a00a4bd4d3408cb7a41d58a0b92833cd2", "name": "frontend-security-review", "version": "0.1.1" @@ -45,7 +45,7 @@ { "body_sha256": "sha256:5503c0d7457247cfa17b81e7485e4ea2d8bdcc00c244f77e32d9df5ffc24b67b", "canonical_sha256": "sha256:92e44ffd9f514cf5b12fe4c97ce955d50a1167ff63b317549b5a81ca89850870", - "claude_rendered_sha256": "sha256:d1549e3ed3b9957bee79ee4df79cac83cae3775c00aa3ae0cb7242fcfa286fda", + "claude_rendered_sha256": "sha256:0e2496faefe746b3e1db50e470c509f1eaea8cb29c4e273f958088cdbd2589a7", "meta_sha256": "sha256:38ddbca88c334c54c4c22832018635394e8e5feed57ef129c749d90057bb28bc", "name": "iac-review", "version": "0.3.1" @@ -53,7 +53,7 @@ { "body_sha256": "sha256:14be89e51271a75e43d0fcf7104bf18ca9d7516cf6da63d063e3af7cb4fa8b4f", "canonical_sha256": "sha256:e36515eb30b2eec5c54f42e2846514b564dfafc5b530168439321d327721fea2", - "claude_rendered_sha256": "sha256:5cd7317daf5a33bc4a574974ccbce76d75748a03a1f976dc953770c1e88f7147", + "claude_rendered_sha256": "sha256:e1f93c5321cd7bdb6eeff97c5f8888a67d27294e2abbfe491fac260db0e7ea60", "meta_sha256": "sha256:268509e2a047c432df346db1f7833d028f28566813b62658c8fb1b60176bfb22", "name": "llm-integration-review", "version": "0.1.1" @@ -61,7 +61,7 @@ { "body_sha256": "sha256:7e407631107275697b6c090de6a68af8389fd8b4141bd0253a47e9688098c4b9", "canonical_sha256": "sha256:5c6f68d8ff18c50b8660874b5117e4bb307c0cc8b1fea7daf8e06b4906d12966", - "claude_rendered_sha256": "sha256:5a144238b23a2387b4029c984132ff48a08f18e88ebe38d99f124200a0e59427", + "claude_rendered_sha256": "sha256:8ba9fba7ddc27c986f41621f80b5b3726960044a69968ecca80777917ccde03c", "meta_sha256": "sha256:75c39abf8f7ae9599ceaaf65bcc79776e04ccc0595f54949c52af1dcf204c730", "name": "logging", "version": "0.3.1" @@ -69,7 +69,7 @@ { "body_sha256": "sha256:3e869f7883e118db18e8c8002f049d8ecf2895e49aa7c76f1d7a39a6ee842c8b", "canonical_sha256": "sha256:1a4c38c4b51a52b60330cd09de25aa7f1b30458fb23c2cb3dd1bdb9b705fc6a9", - "claude_rendered_sha256": "sha256:3452801c9da837de0dc64c87229bacb294597344492f2194b8233f530289a839", + "claude_rendered_sha256": "sha256:4dfff856963c37785deb71d8385783df137a6bfe7bc692877d71a9bb5d3bc6f9", "meta_sha256": "sha256:d891c2343b62c6881a84d338f873021c550e159c0cad06efc2e6d7059862a463", "name": "migration-review", "version": "0.4.1" @@ -77,7 +77,7 @@ { "body_sha256": "sha256:063c8b0a474515dce78dac4196f49e2a0a25912e0d794a65c64387a9033448f5", "canonical_sha256": "sha256:2c47acd249f207d5264d9e321d71c1c09fecd1877384c7557468dc45a8c57dc6", - "claude_rendered_sha256": "sha256:94b2048205fd22cfe567cebb69480a0ec20019348da50fc16fec0b8d8f1d4927", + "claude_rendered_sha256": "sha256:1f9838f8b4d1e7def32d9eb7f3d2a6f6b8a9bdabbc1572870dae68bc843d341c", "meta_sha256": "sha256:d98573ef1c6b622a49fec659de9f9c7171f47ece73f8775536810f2e6abd1380", "name": "privacy-review", "version": "0.1.1" @@ -85,7 +85,7 @@ { "body_sha256": "sha256:3a063e1f8e93f442e622c6da68eeb0196c33d16d1a7b114b85ae18d48e4222bc", "canonical_sha256": "sha256:8bb47653d96e9a754e49fffa9ca57b10d56a7a285fada21f41e84f93b43ae7eb", - "claude_rendered_sha256": "sha256:d6c87053dc0b9abb17244e49f1192812ed201e8314ef19bfe00f500f39ab4d32", + "claude_rendered_sha256": "sha256:8df93b79fceab51ff41d49996253e07c3b9c50c0ea132487e32a233dc5bc770d", "meta_sha256": "sha256:4c7cd02a783821b8452c14e2dcdd966c1cbca52f54b3e45042e9347b386073aa", "name": "resilience-review", "version": "0.3.1" @@ -93,16 +93,16 @@ { "body_sha256": "sha256:b11892bc3f625828f52c4d90a33b68e1df581cea5bd0cf984d1e56f88efff7be", "canonical_sha256": "sha256:5a8fb1996e46d2b3e587d2b179a1b6d72b61dcc99e488a8769de742204d6b256", - "claude_rendered_sha256": "sha256:838688b04df58f5340fbd2ab89d196a793009e045a882a40ebf8b05a0f9fd793", + "claude_rendered_sha256": "sha256:5250d9e6f57d0830a16a2ead1b505b77d61e0a4edc82ce6e3b878a59f040e710", "meta_sha256": "sha256:065932102ec3a6ef7ba965ed7977a52f3dd1bc49a83c8869850fd4f3b1f412ee", "name": "security-review", "version": "0.5.2" }, { - "body_sha256": "sha256:49215350ea63412ec0c5cc0aad38d74546002014abaeb0327f60f65f8082a4af", - "canonical_sha256": "sha256:2515efd80f16e99f8f9c2c032720a31c425849ce58d83ea5d1c9991d531e702e", - "claude_rendered_sha256": "sha256:49815f06f83aabaed319c083fe1d41fb0d21200ee6df993825b24c18985596fd", - "meta_sha256": "sha256:7f2d57267060589f2a765469872047c2998240988937f4631a1f5aff7234707e", + "body_sha256": "sha256:d0bb69d296ac3ea2e6f3e42667a75656927d46bb50359d0e4bfe3a769b8a77e7", + "canonical_sha256": "sha256:f3ceba12771a669de0dac3b4860f426582562bc56f52a6ce716656666d5868be", + "claude_rendered_sha256": "sha256:655b92dc6c7d2a0d188a417d0a484abc9fc2cec8a7dffcc178f178c89f98a1e8", + "meta_sha256": "sha256:72306a4ab3cf5c20ad4493877d06dbcce3d6809ecb99f357b42e317053416327", "name": "test-review", "version": "0.3.1" } diff --git a/src/skilldeck/adapters/base.py b/src/skilldeck/adapters/base.py index 484da71..587b667 100644 --- a/src/skilldeck/adapters/base.py +++ b/src/skilldeck/adapters/base.py @@ -99,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" @@ -257,8 +273,11 @@ def install( ) -> Path: """Write ``skill`` to its destination; return the destination. - ``dry_run`` makes every check a real install makes, raising the same - errors, then returns without writing anything. + ``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) @@ -292,6 +311,7 @@ def install( "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) diff --git a/src/skilldeck/capabilities.py b/src/skilldeck/capabilities.py index db02360..210671b 100644 --- a/src/skilldeck/capabilities.py +++ b/src/skilldeck/capabilities.py @@ -9,8 +9,7 @@ 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: an attempt to do it -comes from somewhere other than the skill, to refuse or review by hand. +Anything a skill does not declare, it does not request. ``docs/authoring-skills.md`` documents the format. """ @@ -35,6 +34,41 @@ #: 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 @@ -64,14 +98,19 @@ } # (label, field) of each list the notice shows, in order; commands and paths # are shown as code -_NOTICE_LABELS = ( - ("Commands", "commands"), - ("Network", "network"), - ("Credentials", "credentials"), - ("Agent tools", "tools"), - ("Creates", "artifacts"), -) _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): @@ -113,10 +152,18 @@ class Capabilities: artifacts: tuple[str, ...] = () @property - def beyond_reading(self) -> bool: - """Whether it asks for anything but reading files.""" - return self.write != "none" or any( - getattr(self, field) for field in LIST_FIELDS + 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: @@ -245,6 +292,15 @@ def _check_command(entry: str, where: str) -> None: 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 " @@ -253,6 +309,26 @@ def _check_command(entry: str, where: str) -> None: ) +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) @@ -303,36 +379,42 @@ def summary(capabilities: Capabilities) -> list[tuple[str, tuple[str, ...]]]: def notice(capabilities: Capabilities) -> str: """The Markdown section adapters append to a skill that asks for more - than reading files; empty for one that doesn't. - - It travels with the installed file, so whoever reads it (the agent, or a - person reviewing what was installed) sees the declaration. Commands and - paths are code, listed inline; descriptions get an item each once there - are several. + 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_reading: + 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", "", - "What this skill may ask for, as declared in its skilldeck metadata", - f"(capability schema {CAPABILITY_SCHEMA}). The declaration is for " - "review: nothing enforces it.", - "Anything not listed here is not requested by this skill.", + _NOTICE_LEAD[capabilities.read], "", - f"- Files: {files_text(capabilities)}", + *items, + "", + "It asks for nothing else.", ] - for label, field in _NOTICE_LABELS: - entries: tuple[str, ...] = getattr(capabilities, field) - if not entries: - continue - if field in _CODE_FIELDS: - lines.append(f"- {label}: " + ", ".join(f"`{e}`" for e in entries)) - elif len(entries) == 1: - lines.append(f"- {label}: {entries[0]}") - else: - lines.append(f"- {label}:") - lines.extend(f" - {entry}" for entry in entries) return "\n".join(lines) + "\n" diff --git a/src/skilldeck/catalog.schema.json b/src/skilldeck/catalog.schema.json index 0a29bfc..3d8a903 100644 --- a/src/skilldeck/catalog.schema.json +++ b/src/skilldeck/catalog.schema.json @@ -152,7 +152,7 @@ ], "properties": { "schema": { - "description": "The capability schema of the declaration. Changing it is a catalog change like any other (see docs/catalog.md).", + "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": { diff --git a/src/skilldeck/cli.py b/src/skilldeck/cli.py index 81b9500..ed47f30 100644 --- a/src/skilldeck/cli.py +++ b/src/skilldeck/cli.py @@ -295,19 +295,19 @@ def _warn_deprecated(skills: Iterable[Skill]) -> None: def _built_from() -> str: - """Where this package says it was built from, for a summary.""" + """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 (no release tag or commit recorded)" + 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 the content manifest recorded - when the package was built vouches for it.""" + """``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: @@ -320,10 +320,13 @@ def _digest_status(skill: Skill) -> str: 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)" + return f"{digest} (NOT in the content manifest shipped in this package)" if digest != expected: - return f"{digest} (does NOT match the content manifest's {expected})" - return f"{digest} (matches the content manifest)" + 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]: @@ -338,8 +341,11 @@ def _summary_lines(skill: Skill) -> list[str]: f"{skill.name} {skill.version} ({skill.category})", f" {skill.description}", f" source: {REPOSITORY_URL}, {SKILLS_SOURCE_PATH}/{skill.name}", - f" built from: {_built_from()}", + 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):", @@ -515,9 +521,11 @@ def _preview_install( """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 same - checks as a real install (``Adapter.install`` with ``dry_run``), and the - same errors on stderr. + 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): diff --git a/src/skilldeck/provenance.py b/src/skilldeck/provenance.py index 052cef6..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, bundle_problems, discover_skills +from .registry import ( + BUNDLE_FILES, + DEFAULT_SKILLS_DIR, + Skill, + discover_skills, + is_link, + link_kind, +) SCHEMA_VERSION = 1 PACKAGE_NAME = "skilldeck" @@ -280,10 +287,11 @@ 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, any skill directory the manifest does not - list, and anything in a skill directory that breaks the bundle rules - (:func:`~skilldeck.registry.bundle_problems`: an extra file, an - executable, a symlink). An empty list means the installed skills are - exactly the ones the manifest records. + 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"]} @@ -300,14 +308,22 @@ def verify_bundled_skills(skills_dir: Path | None = None) -> list[str]: for name, record in sorted(records.items()): skill_dir = root / name try: + entries = sorted(child.name for child in skill_dir.iterdir()) meta_text = (skill_dir / "meta.yaml").read_text(encoding="utf-8") body_text = (skill_dir / "skill.md").read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as exc: problems.append(f"{name}: cannot read the bundled skill: {exc}") continue - if skill_dir.is_symlink(): - problems.append(f"{name}: the skill directory is a symlink") - problems.extend(f"{name}: {problem}" for problem in bundle_problems(skill_dir)) + 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 1e5fda1..b53c9c2 100644 --- a/src/skilldeck/registry.py +++ b/src/skilldeck/registry.py @@ -25,7 +25,12 @@ import yaml -from .capabilities import Capabilities, CapabilityError, parse_capabilities +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 @@ -64,16 +69,13 @@ #: 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") -# Suffixes of files a shell, an interpreter or the OS runs as a program; only -# used to say why an extra file is refused (every extra file is). -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 +# 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 = ( @@ -85,15 +87,21 @@ b"\xcf\xfa\xed\xfe", b"\xca\xfe\xba\xbe", ) -# Markdown the link check must skip: fenced code blocks and code spans. +# 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"""\b(?:src|href)\s*=\s*["']?([^"'\s>]+)""", re.I), + 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 @@ -137,8 +145,10 @@ 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 skill_dir.is_symlink(): - raise SkillError(f"{skill_dir}: the skill directory is a symlink") + 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( @@ -259,14 +269,33 @@ def load_skill(skill_dir: Path, known_agents: Collection[str] | None = None) -> ) +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 (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. + 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) @@ -279,8 +308,11 @@ def bundle_problems(skill_dir: Path) -> list[str]: except OSError as exc: problems.append(f"cannot inspect {entry.name}: {exc}") continue - if stat.S_ISLNK(mode): - problems.append(f"{entry.name} is a symlink") + 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): @@ -317,38 +349,72 @@ def _executable(path: Path, mode: int) -> str | None: def local_links(body: str) -> list[str]: """Link targets in ``body`` that name a file rather than a web page. - Markdown links, images and reference definitions, and HTML ``src`` and - ``href`` attributes, outside code blocks 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. + 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(): - match = _FENCE_RE.match(line) - if fence is None: - if match: - fence = match.group(1) - continue - prose.append(line) - elif ( - match - and match.group(1)[0] == fence[0] - and len(match.group(1)) >= len(fence) - ): - fence = None - text = _CODE_SPAN_RE.sub("", "\n".join(prose)) - found: set[str] = set() - for pattern in _LINK_TARGET_RES: - for target in pattern.findall(text): - 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) + 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: @@ -456,11 +522,11 @@ def discover_skills( skills = [] for child in sorted(root.iterdir()): - if child.name.startswith("."): + if child.name.startswith(".") or is_junk(child.name): continue # checked before is_dir(), which follows the link - if child.is_symlink(): - raise SkillError(f"{child}: the skill directory is a symlink") + 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) diff --git a/src/skilldeck/skills/dependency-review/meta.yaml b/src/skilldeck/skills/dependency-review/meta.yaml index fdd8301..c59d5ed 100644 --- a/src/skilldeck/skills/dependency-review/meta.yaml +++ b/src/skilldeck/skills/dependency-review/meta.yaml @@ -18,7 +18,7 @@ capabilities: - git diff - git ls-files - npm audit - - pip-audit + - pip-audit --disable-pip - osv-scanner - govulncheck - cargo audit @@ -28,7 +28,8 @@ capabilities: - 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 pages + - 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/test-review/meta.yaml b/src/skilldeck/skills/test-review/meta.yaml index 97e37c7..0d8ce1d 100644 --- a/src/skilldeck/skills/test-review/meta.yaml +++ b/src/skilldeck/skills/test-review/meta.yaml @@ -17,6 +17,8 @@ capabilities: - 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 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/fixtures/adapter-contracts/claude/SKILL.md b/tests/fixtures/adapter-contracts/claude/SKILL.md index fd30b0f..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/claude/SKILL.md +++ b/tests/fixtures/adapter-contracts/claude/SKILL.md @@ -10,14 +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 -What this skill may ask for, as declared in its skilldeck metadata -(capability schema 1). The declaration is for review: nothing enforces it. -Anything not listed here is not requested by this skill. +Beyond reading the changed files, this skill asks you to: -- Files: reads the changed files; edits no files -- Commands: `git diff` - +- 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 fd30b0f..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/codex/SKILL.md +++ b/tests/fixtures/adapter-contracts/codex/SKILL.md @@ -10,14 +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 -What this skill may ask for, as declared in its skilldeck metadata -(capability schema 1). The declaration is for review: nothing enforces it. -Anything not listed here is not requested by this skill. +Beyond reading the changed files, this skill asks you to: -- Files: reads the changed files; edits no files -- Commands: `git diff` - +- 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 153ce7b..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,14 +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 -What this skill may ask for, as declared in its skilldeck metadata -(capability schema 1). The declaration is for review: nothing enforces it. -Anything not listed here is not requested by this skill. +Beyond reading the changed files, this skill asks you to: -- Files: reads the changed files; edits no files -- Commands: `git diff` - +- 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 fd30b0f..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/copilot/SKILL.md +++ b/tests/fixtures/adapter-contracts/copilot/SKILL.md @@ -10,14 +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 -What this skill may ask for, as declared in its skilldeck metadata -(capability schema 1). The declaration is for review: nothing enforces it. -Anything not listed here is not requested by this skill. +Beyond reading the changed files, this skill asks you to: -- Files: reads the changed files; edits no files -- Commands: `git diff` - +- 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 8faec96..4d46493 100644 --- a/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc +++ b/tests/fixtures/adapter-contracts/cursor-rule/contract-demo.mdc @@ -9,14 +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 -What this skill may ask for, as declared in its skilldeck metadata -(capability schema 1). The declaration is for review: nothing enforces it. -Anything not listed here is not requested by this skill. +Beyond reading the changed files, this skill asks you to: -- Files: reads the changed files; edits no files -- Commands: `git diff` - +- 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 fd30b0f..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/cursor/SKILL.md +++ b/tests/fixtures/adapter-contracts/cursor/SKILL.md @@ -10,14 +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 -What this skill may ask for, as declared in its skilldeck metadata -(capability schema 1). The declaration is for review: nothing enforces it. -Anything not listed here is not requested by this skill. +Beyond reading the changed files, this skill asks you to: -- Files: reads the changed files; edits no files -- Commands: `git diff` - +- 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 18d70d8..34d3247 100644 --- a/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md +++ b/tests/fixtures/adapter-contracts/kiro-steering/contract-demo.md @@ -8,14 +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 -What this skill may ask for, as declared in its skilldeck metadata -(capability schema 1). The declaration is for review: nothing enforces it. -Anything not listed here is not requested by this skill. +Beyond reading the changed files, this skill asks you to: -- Files: reads the changed files; edits no files -- Commands: `git diff` - +- 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 fd30b0f..d60bfb2 100644 --- a/tests/fixtures/adapter-contracts/kiro/SKILL.md +++ b/tests/fixtures/adapter-contracts/kiro/SKILL.md @@ -10,14 +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 -What this skill may ask for, as declared in its skilldeck metadata -(capability schema 1). The declaration is for review: nothing enforces it. -Anything not listed here is not requested by this skill. +Beyond reading the changed files, this skill asks you to: -- Files: reads the changed files; edits no files -- Commands: `git diff` - +- 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 0bfcd0a..ae2764e 100644 --- a/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml +++ b/tests/fixtures/adapter-contracts/skill/contract-demo/meta.yaml @@ -15,6 +15,7 @@ capabilities: write: none commands: - git diff + - network: [] credentials: [] tools: [] 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_capabilities.py b/tests/test_capabilities.py index 7f5e2aa..a1c18f2 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -17,6 +17,7 @@ from skilldeck.adapters import ADAPTERS, ALL_ADAPTERS from skilldeck.adapters.base import rendered_body from skilldeck.capabilities import ( + GIT_BASELINE, Capabilities, CapabilityError, notice, @@ -80,20 +81,33 @@ def test_parse_reads_every_field(): assert FULL.record() == {**raw, "schema": 1} -def test_read_only_declaration_requests_nothing_beyond_reading(): +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_reading - assert not Capabilities().beyond_reading + 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",)), - ("network", ("x",)), + ("commands", ("git diff", "git push")), + ("commands", ("git worktree add",)), + ("commands", ("",)), + ("commands", ("npm audit",)), ("credentials", ("x",)), ("tools", ("x",)), ("artifacts", ("x.md",)), ): - assert Capabilities(**{field: value}).beyond_reading, field + caps = Capabilities(read="repo", **{field: value}) + assert caps.beyond_review_baseline, (field, value) @pytest.mark.parametrize( @@ -134,6 +148,20 @@ def test_read_only_declaration_requests_nothing_beyond_reading(): (_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"), ], ) @@ -164,6 +192,14 @@ def test_artifact_paths_must_stay_inside_the_project(path, 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,) @@ -172,34 +208,72 @@ def test_relative_artifact_paths_are_accepted(path): # --- the notice adapters render ------------------------------------------------- -def test_read_only_skills_get_no_notice(): - assert notice(Capabilities(read="repo")) == "" - assert with_notice("body", Capabilities(read="repo")) == "body" +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_lists_what_the_skill_declares(): +def test_notice_tells_the_agent_everything_the_skill_asks_for(): assert with_notice("# Demo\n", FULL) == ( "# Demo\n" "\n" "## Declared capabilities\n" "\n" - "What this skill may ask for, as declared in its skilldeck metadata\n" - "(capability schema 1). The declaration is for review: nothing enforces it.\n" - "Anything not listed here is not requested by this skill.\n" + "Beyond reading the changed files, this skill asks you to:\n" "\n" - "- Files: reads the changed files; may edit files in the repository\n" - "- Commands: `git diff`, ``\n" - "- Network:\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" - "- Credentials: a registry token from NPM_TOKEN\n" - "- Agent tools: web fetch\n" - "- Creates: `reports/review.md`\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", @@ -217,18 +291,21 @@ def _skill(capabilities, body="# Demo\n\nDo the review.\n"): def test_every_adapter_carries_the_notice(name): adapter = ALL_ADAPTERS[name] for caps in ( - Capabilities(read="repo", commands=("git diff",)), - Capabilities(read="repo", network=("an advisory database",)), + 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 skill that only reads renders its body unchanged - plain = adapter.render(_skill(Capabilities(read="repo"))) - assert plain.endswith("# Demo\n\nDo the review.\n") - assert "Declared capabilities" not in plain + # 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): @@ -236,7 +313,7 @@ def test_installed_file_keeps_the_notice_above_the_stamp(tmp_path): 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 "- Creates: `reports/review.md`\n