Skip to content

feat(skills): withhold untrusted skills, and vouch for them by hash - #82

Merged
kemalelmizan merged 1 commit into
mainfrom
feat/skill-trust
Oct 17, 2026
Merged

kemalelmizan merged 1 commit into
mainfrom
feat/skill-trust

Conversation

@kemalelmizan

Copy link
Copy Markdown
Contributor

What

The trust half of #78 — the half that actually addresses "is this skill malicious". Containment (#81) bounds what a skill can reach; this decides whether the model is given it at all.

An untrusted skill is neither listed nor readable. Not listed-with-a-warning: the listing is itself a foothold, because only name and description are needed to steer a model and both are injected for every discovered skill whether or not the body is ever read. Withholding means unvetted text never enters the context window.

Trust is declared by the operator, never by the skill — a hostile SKILL.md would simply say it is trusted — so the record lives outside it, in $XDG_CONFIG_HOME/agent/skills.trust.json, keyed by the skill's content hash over its instructions and bundled files. An edit therefore un-vouches the skill: "I reviewed this" is worth nothing if the bytes can change underneath the review.

Decisions (worth a look in review)

Decision Why
Your own trees are trusted by default (--skills, project ./skills, the user shelf) Nothing arrives without you putting it there (no fetch), so a promotion step per skill would be pure friction. This is the option we picked in planning: trust is declared rather than inferred-from-absence.
--skills-untrusted <dir> is the quarantine One directory, named once, no per-skill ceremony.
A blocked verdict refuses a skill even inside a trusted directory A positive finding is not overridden by location. Nothing writes blocked yet — the scan step will — but the precedence is pinned by a test now.
A malformed record is an error, not an empty record Silently ignoring it would quietly un-block whatever it blocked.

Surface

  • --skills-untrusted <dir> (repeatable; also a config key skillsUntrusted)
  • --skills-trust <name> — records the hash, then exits
  • --skills-list — prints level, hash and trust status; needs no MCP server
  • A run withholds untrusted skills and says so on stderr with the reason

Tests

  • src/skill-trust.test.ts (12): hash stability and change (instructions and bundled files), default trust, the quarantine, vouch-by-hash including the edit case, blocked-over-trusted, unreadable-skill refusal, record round-trip, malformed records, entry replacement, path resolution.
  • CLI/e2e: --skills-list with no server; a marked directory withheld from both the prompt and the tool list with the reason on stderr; vouch-then-use.

pnpm build / typecheck / lint green; pnpm test 362 passed / 5 skipped; coverage 90.7 / 84.4 / 89.9 / 90.7.

Refs #78. The supply chain (fetch/cache/version) remains a decision and is untouched.

Containment bounds what a skill can *reach*; it does nothing about the threat that
matters, which is that a `SKILL.md` is instructions the model obeys. This adds the
other half — trust decides whether a skill is offered at all.

An untrusted skill is **neither listed nor readable**, not listed-with-a-warning. The
listing is itself a foothold: only `name` and `description` are needed to steer a
model, and both are injected for every discovered skill whether or not the body is
ever read. Withholding it means unvetted text never enters the context window.

Trust is **declared by the operator, never by the skill**. A `SKILL.md` cannot vouch
for itself — a hostile one would simply say it is trusted — so the record lives
outside it, in `$XDG_CONFIG_HOME/agent/skills.trust.json`, keyed by the skill's
**content hash** over its instructions and bundled files. "I reviewed this" is worth
nothing if the bytes can change underneath the review, so an edit un-vouches the
skill (pinned by a test).

Decisions, and why:

- The operator's own trees are trusted by default — `--skills`, the project's
  `./skills`, the user-level shelf. Nothing arrives here without them putting it
  there (there is no fetch), so a promotion step for every skill they wrote would be
  friction with no threat behind it.
- `--skills-untrusted <dir>` names a directory taken from elsewhere: that is the
  quarantine, and it needs no per-skill ceremony.
- A **blocked** verdict refuses a skill whatever else says — a positive finding is not
  overridden by a directory being trusted. Nothing writes `blocked` yet (the scan step
  will); the precedence is pinned by a test now.
- A record that cannot be parsed is an **error**, not an empty record: silently
  ignoring it would quietly un-block whatever it blocked.

`--skills-trust <name>` records a skill's hash and exits; `--skills-list` prints the
discovered skills with their level, hash and status, and needs no MCP server.

Refs #78 — this is the trust half; the supply chain (fetch/cache/version) stays a
decision.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant