# Skills: the gate

> 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.

## The verdict

`SkillVerdict` is `safe`, `caution` or `block`, and it is **not** the quality score:

- Any critical finding blocks. Warnings, or a scan that could not read everything, are `caution`.
- The score never makes up for a finding. A skill can score 90 and be blocked.
- A model raises a verdict and never lowers one (`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`).
- A model's failure is not a pass. `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.

## What the scanner reads

- `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`.
- A rule that spans lines takes `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.

## An acceptance

`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.
