Read this before changing
skill_gate.go,skill_scanner.go,skill_text_view.go,skill_quality.go, or a way a skill reaches GoClaw.
A skill's text goes into the system prompt, ahead of the history, so it speaks with the agent's own authority.
The gate is the one security check for a skill, however it got here. Each way in is an adapter over it, as a
provider is over the agent loop, and skill_gate_conformance_test.go runs one set of skills through all of them.
| Way in | Check | When | On a block |
|---|---|---|---|
| Dashboard verify / save | EvaluateSkillContentQuality → inspectSkillText, then a model |
before the write | not saved, unless an admin accepts this text (the hash field of the save) |
Dashboard install (github:) |
InstallSkillFromSource → inspectSkillText |
before the write | not written, logged |
CLI skills install / add |
scanInstalledSkill → ScanSkillDir |
after the copy | removed; --force keeps it on disk |
Plugin skill, workspace/skills, a hand edit, a git pull |
LoadSkillsForContext → gateSkills |
every load | stays out of the prompt, logged once |
Every skill passes the last row, whichever row it came in by: it is the backstop for a file that changed after
it was checked. It drops a skill before selection, so a blocked skill wins no place in FilterSkillsFor or
keepSessionSkills, and one that turned bad mid-session is gone from the next turn. LoadSkills is the full list, for the admin's
views (Skills page, skills list, skills scan); nothing that builds a prompt may read it.
SkillVerdict is safe, caution or block, and it is not the quality score:
caution.SkillSecurity.raise). A skill the scanner blocks is not sent
to a model at all.caution loads. A save stops on it only when no model reviewed the skill (needsAcceptance).noteReview tells a refusal (errModelDeclined, a finding of its own) from a
cut answer (errSkillReviewCut) from a model that could not be reached. A clean skill saves on the static
score, as an install with no model always has; one with warnings waits for an admin.skillTextView holds each line as written, normalized (NFKC, invisible characters out, Unicode tag characters
read as the ASCII they spell, Cyrillic and Greek lookalikes folded to Latin), and the whole text as one line. A
finding always names the line and shows the text the author wrote. The normalized view makes Mongolian text
nonsense: a rule for Cyrillic text takes rawText.spansLines, and needs a bounded pattern: .* would read the whole skill as one
sentence.negatable rules pass over a prohibition (prohibited): "never run rm -rf /". The grammar is narrow on
purpose, a negation that governs the match directly with only scanBridgeWords between, and none in a clause
with "unless". A looser one is what an attacker would write to.ScanSkillDir reads every file, and what it does not read goes in Skipped with the reason. A skipped file
makes the scan partial, and a partial scan is never safe. Only inertExts, .git and __pycache__ are left out silently.gatedSkill reads SkillEntry.Content, the text the agent reads with references/ inlined, memoized by hash.
It runs no model: a turn cannot wait for one.Before tightening a rule to critical, remember it is enforced at load. Run it over skills-registry/ and the
built-in skills (TestBuiltinSkillsPassTheGate fails on a built-in), and prefer warn where a real skill could
say the same thing.
skills.accepted in config.yaml maps a skill's name to the hash of the text an admin read (AcceptSkill). It is
in config.yaml because no tool of the agent writes there. The hash (skillContentHash) covers the text, the
description and the tags: any change voids it, as it does a plugin item's approval. The dashboard sends the hash
the page showed, so nothing changed meanwhile is accepted unseen. skills.gate: warn turns the block at load into
a log line, for a first rollout. SkillSecurity.Withheld is what all of it comes to: models do not get this skill.