The runtime gate
skillsvault gate is the enforcement point. It evaluates a single invocation — a skill, a subagent, or a typed command — against the locally-synced, signed bundle and returns allow, warn, or block, computed in-process with no network call and no model in the decision. That is what keeps enforcement deterministic and working offline.
The decision is local
The gate reads ~/.skillsvault/bundle.json (written by login, sync or try) and decides from it alone. It answers first and only then sends the audit event, for at most 1.5 seconds; an event that doesn't get through is queued on the machine and sent later. A slow or unreachable control plane never delays or changes the decision (CLI 0.6.0 and later).
Evaluation
Given a skill name, the gate checks it against your organisation's approved catalogue and every policy that applies, and returns a single decision:
- allow — the skill is approved for use at this version.
- warn — usable, but flagged (for example, deprecated); the reason is printed.
- block — do not use it; the reason names the policy and, where one is set, the approved replacement.
When several rules apply to the same skill, the most restrictive outcome wins — a ban is never downgraded by a broader rule. Skills that aren't in the catalogue but live in a managed namespace get the managed-missing-manifest default (warn), so sideloaded copies don't pass silently. A name outside every managed namespace is unmanaged and gets the bundle's unmanaged default, allow. Both defaults are set by skillsvault in every bundle, not per organisation; to act on an unregistered skill, write a policy that matches it. Skills in the reserved skillsvault/ namespace (the onboarding skill) are always allowed.
Commands warn, they don't block
A slash command is matched from the text a person typed, not from a structured tool call, so the gate caps every decision about an artifact of kind command at warn: a ban or a Block policy against a command prints a warning and lets it run, and the audit event records warned. Skills and subagents are blocked as usual. If a typed /name matches both a skill and a command, the skill's Block still wins.
The gate keeps its bundle fresh
When the bundle is more than an hour old (or half its stale_after_s, if that is shorter), the gate decides on the bundle it has, answers, and then starts skillsvault sync --quiet in the background, detached, so the next call sees current policy. The refresh never delays a decision and never writes to the hook's output. A lock file (~/.skillsvault/refresh.lock) lets at most one gate start a refresh every five minutes, however many run at once — which also keeps an unreachable control plane from costing a sync per call. A machine without credentials (logout --keep-bundle) doesn't refresh. (CLI 0.6.0 and later.)
A bundle that can't be refreshed warns
The bundle carries a staleness rule: stale_after_s (24 hours) and stale_action (warn). Like the unmanaged default, these are skillsvault defaults written into every bundle, not per-organisation settings. On a connected machine it applies once the bundle is older than stale_after_s and refreshing has been failing for that long too — a laptop that was simply closed over the weekend refreshes on its first call and doesn't warn. Without credentials, nothing can refresh, so age alone decides. A stale decision is at least stale_action: an allow becomes a warning with the reason policy bundle is 30 hours old — run skillsvault sync, and a block stays a block. A stale bundle never blocks on its own (the gate would honour a stale_action of halt, but no bundle sets one today). A successful skillsvault sync, in the foreground or the background, clears it.
Decisions are deterministic: the same skill, the same policy set and the same bundle freshness always produce the same verdict, on every machine. The console's policy builder previews "this matches N skills" using the same rules the gate applies. See Policies for how rules are written.
Two modes
gate behaves differently depending on whether a human ran it or a harness hook piped a payload to it.
Human / CI mode
Pass --skill. The decision is rendered and the exit code is the protocol:
skillsvault gate --skill acme/hubspot-crm-entry
# ✓ ALLOW acme/hubspot-crm-entry@1.3.0 (exit 0)
skillsvault gate --skill acme/onboard-customer
# ⚠ WARN acme/onboard-customer — Skill is deprecated. (exit 0, stderr)
skillsvault gate --skill acme/teamleader-crm-entry
# ⛔ BLOCKED acme/teamleader-crm-entry
# policy: Teamleader retired
# Use acme/hubspot-crm-entry instead. (exit 2)
# need it? ask for an exception: https://skillsvault.io/acme/requests/exception?policy=…&skill=acme%2Fteamleader-crm-entry
| Decision | Stream | Exit |
|---|---|---|
| allow | stdout (suppressed with --quiet) | 0 |
| warn | stderr | 0 |
| block | stderr | 2 |
A non-zero exit is what a generic hook treats as a veto. --quiet suppresses the allow line so a passing gate is silent.
Claude Code hook mode
When no --skill is given, the gate reads the harness's PreToolUse payload from stdin and emits a structured permission decision instead of relying on the exit code:
echo '{"tool_name":"Skill","tool_input":{"skill":"acme/teamleader-crm-entry"}}' | skillsvault gate
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "skillsvault blocked this skill — policy: Teamleader retired. Use acme/hubspot-crm-entry instead. Need it? Ask for an exception: https://skillsvault.io/acme/requests/exception?policy=…&skill=acme%2Fteamleader-crm-entry"
}
}
In hook mode the deny is the veto, not the exit code
Hook mode always exits 0. A block is signalled by permissionDecision: "deny" with the org's reason (surfaced to the agent); a warn prints to stderr; an allow emits nothing. This is deliberate — Claude Code reads the JSON, not the exit status, for PreToolUse. Wire it with skillsvault install-hook.
A typed /name arrives as a UserPromptExpansion payload instead, and a block there is answered the way that event expects: {"decision": "block", "reason": "…"}. A subagent launch arrives as a PreToolUse call to the Agent tool (Task before Claude Code 2.1.63) and is denied like a skill.
The hook fails open, and says so: if the machine has no bundle yet, the gate cannot decide anything, and blocking every invocation would leave the agent unusable until someone found the hook. So it answers with valid JSON that carries no decision — the call goes ahead under Claude Code's normal permissions — and a systemMessage telling the user why:
{ "systemMessage": "skillsvault: not configured on this machine — run `skillsvault login`. This call was not checked against your organisation's policy." }
On a machine that is connected but never synced, the message points at skillsvault sync instead. A machine signed out with logout --keep-bundle still has a bundle, so it keeps gating; it just has nowhere to send audit events. Before CLI 0.6.0 an unconfigured gate exited 1, which Claude Code also treats as a non-blocking error.
How an invoked name is resolved
In hook mode the gate reads the invoked name from the key Claude Code documents for each payload — tool_input.skill for the Skill tool, tool_input.subagent_type for the Agent (formerly Task) tool, command_name for a typed /name (an MCP server's prompt is ignored). Claude Code invokes what pull installed by its bare name, never acme/deploy, so the gate maps the name back to your registry (CLI 0.5.0 and later for bare names, 0.5.1 for the other forms):
| Invoked as | Resolves to |
|---|---|
acme/deploy | acme/deploy |
deploy, /deploy, Deploy, /deploy staging | every registry artifact named deploy that this path can reach, and every Specific skill policy naming <ns>/deploy — the most restrictive decision wins |
acme:deploy | acme/deploy, when acme is a namespace your org manages |
apps/web:deploy | a skill in a nested .claude/skills directory, resolved by its last segment like deploy |
other-plugin:deploy | nothing — another plugin's skill, evaluated as unmanaged |
a subagent's or skill's frontmatter name | the artifact pull installed with that name (recorded in ~/.skillsvault/names.json) |
Case, a leading /, and anything after the first space are ignored. A subagent name is only matched against subagents, and a skill or command name against skills and commands, so banning the skill acme/plan doesn't block Claude Code's built-in Plan subagent. If a payload contains no recognisable name, the gate does not block; it stays out of the way. gate --skill accepts the same forms and matches any kind.
The byte-clean contract
The gate's output is a protocol, so presentation never corrupts it: ANSI color is emitted only to a real terminal and is suppressed by --no-color, NO_COLOR, or TERM=dumb. Styling never alters a decision, an exit code, the stdout-vs-stderr split, or the hook JSON. Piped and hook output is always plain bytes.
Audit
After answering, the gate ships one audit event to POST /api/audit/ingest with the resolved skill name, version, harness, machine, decision (allowed / warned / blocked), reason, matched policy, and an event_id generated on the machine. In hook mode the JSON answer is written and stdout closed before any network call.
The attempt is bounded by 1.5 seconds. An event that doesn't get through — the control plane is unreachable, slow, rate-limits the request, rejects the key, or fails with a server error — is appended to ~/.skillsvault/audit-spool.jsonl (mode 0600, capped at 1 MB; past the cap new events are dropped). The next gate, sync, pull, try or login sends the queue first. A batch the server rejects as invalid (400, 413 or 422) is dropped, not queued, since sending it again would never succeed and would hold up every event behind it. The server records each event_id once, so an event that did arrive but answered late isn't counted twice. Its occurred_at is the server's receive time, so an event sent from the queue shows when it was delivered, not when the gate decided. See Audit log.