How I built advisory skill permissions for my desktop AI companion: a manifest the model can ignore
My desktop companion, Ankita, runs skills as plain instructions: a folder with a SKILL.md file, a short frontmatter block, then markdown steps the model follows. That worked until I had to answer an uncomfortable question about a skill: what is it allowed to do?
The honest answer was "whatever the model decides, subject to tool approvals." Skills already had a suggested-tools hint, and the skill tool says suggested tools are hints only. So in v2.7.0 I shipped a permission manifest for skills — deliberately as advice, not enforcement. This post is about that choice, and why I am still not sure it is right.
The problem: a hint that looks like a boundary
Skills are instructions, not code. Nothing stops a model that loaded a review skill from also calling a shell tool, because the skill text never had a way to say "I only need these tools." The catalogue line a model sees lists description and suggested tools with no statement of scope: a reader could not tell a read-only tour skill from one that touches network and shell.
The failure mode is quiet scope creep: a read-only skill gradually picks up a write, a network call, a command, because each step looks reasonable.
What I built: two frontmatter fields, parsed and validated
The manifest lives in the same frontmatter block, parsed by parseSkillFile in src/core/skills.mjs. Two fields:
---
name: commit-review
description: "Review git diffs and staged changes for this repo. Use when asked to review, check, or summarize changes."
allowed-tools: git, read_file
touches: files, network
---
Parsing is strict in cheap, honest ways. Each field is capped at 200 characters (PERMISSION_CHARS), a bound chosen to keep install prompts readable. Tool names must match ^[a-z][a-z0-9_-]*$; scopes must match ^[a-z][a-z0-9_.-]*$. Entries are deduplicated through a Set, touches values are lowercased, and an invalid field rejects the whole skill: loadSkills logs the reason and moves on. A malformed skill never breaks boot; it just does not load.
What the model sees comes from skillPromptLines: a catalogue line appending Permissions: allowed-tools: git, read_file; touches: files, network. to the skill's description.
And every prompt carries the standing instruction: "Skills are instructions only. Suggested tools are hints, not requirements. When allowed-tools is present, prefer those tools and ask before using others."
Note the wording — "prefer" and "ask", not "you may only use" — and that the manifest is surfaced, never fed into a filter.
The deliberate gap: enforcement exists elsewhere, but not here
This is the part to poke at. Enforcement machinery already exists, just not connected to skills: the agent accepts an allowedTools set, tool-host.mjs filters specs against it, and sub-agent presets run with a resolved tool set. The October trust-safety work even removed an unused isToolAllowedForSkill helper, so the code would not imply enforcement that does not exist. The release note says the same plainly: tool approvals remain the enforcement boundary.
So today: the manifest states intent, approvals enforce individual dangerous calls, and nothing in between stops a skill's model from reaching for an unlisted tool and getting it approved on its own merits.
Why not just wire it up? Enforcement has a failure mode of its own. A skill that legitimately needs an unlisted tool mid-task would either break or pester the user for an exception, and every exception prompt trains the user to click through the next one. Advisory manifests never break a task; they only make scope visible. Enforced manifests make scope real, but they also make every authoring mistake a broken workflow.
There is also an adoption fact that undercuts any grand claim: no bundled skill declares allowed-tools or touches at all. The parser, validation, catalogue line, and prompt instruction all shipped in v2.7.0 — and the format has zero in-tree users. A permission system nobody writes permissions for is documentation, not a boundary. The verification recorded for the trust-safety batch (11 trust-safety tests, 10 skill tests passing) covers the parser and catalogue rendering, not a single real manifest in use.
What I would change, and what I will not claim
What the manifest does buy, even unused, is a vocabulary. The next skill author can declare scope in a standard place instead of burying it in prose, and a typo in a tool name now fails at load with a clear message instead of silently meaning nothing.
What it does not buy is safety. If you read "skill permissions" in the v2.7.0 notes and pictured a sandbox, that picture is wrong: permissionSummary renders text, the prompt asks the model to prefer, and the approval gate does the actual stopping, one call at a time.
Tell me I'm wrong
Here is the decision, as falsifiably as I can state it: advisory manifests plus per-call approvals are the honest design, because a prompt-level hint can never be a security boundary, and pretending otherwise is worse than admitting it. Enforcement should wait for a routing layer that can deny a call by skill context without turning every edge case into an approval prompt.
Tell me I'm wrong — where exactly would it break first? If you wired skill.allowedTools into the agent's tool filter tomorrow, what is the first legitimate skill task that breaks, and is that breakage worth paying for a boundary the model cannot talk its way around? Ankita is open source (github.com/akyourowngames/A.N.K.I.T.A); the code is src/core/skills.mjs — I would rather hear the counterexample than keep shipping advice dressed as permission.
Top comments (0)