The runtime gate

skillsvault gate is the enforcement point. It evaluates a single skill invocation against the locally-synced, signed bundle and returns allow, warn, or halt — entirely in-process, with no network call and no model on the path. That is what keeps enforcement instant and working offline.

The decision is local

The gate reads ~/.skillsvault/bundle.json (written by skillsvault sync) and decides from it alone. Shipping the audit event afterwards is best-effort and off the decision path — the decision never waits on the network.

Evaluation

Given a skill name, the gate:

Resolve the skill

Split namespace/name to get the namespace, and look the skill up in the bundle's manifest_index.

Walk every matching policy

Policies arrive priority-ordered (manifest_id → namespace → declared_metadata). For each policy whose matcher matches, the gate considers its action:

  • namespace matches when the policy's value equals the skill's namespace.
  • declared_metadata matches when the skill's targets contain the value (the part after : in e.g. target:teamleader).

Apply the status / compliance signal

If the skill is known: banned → halt, deprecated → warn. If it is unknown but its namespace is managed, apply the bundle's managed_missing_manifest default (the non-compliance floor for sideloaded or pre-skillsvault folders).

Take the most restrictive outcome

Across every candidate, the most restrictive action wins: halt > warn > allow. A ban is never downgraded by a broader warn. Among equally-restrictive candidates the first considered (highest matcher priority, then status) keeps the explanation — its policy name and reason.

The evaluation is fast and order-independent in its result: the same skill and the same bundle always produce the same decision. The console's policy builder previews "this matches N skills" using the same matching rules, so what you see when authoring a policy is what the gate enforces. See Policies for how those 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
# ⛔ HALT acme/teamleader-crm-entry
#    policy: Teamleader retired
#    Use acme/hubspot-crm-entry instead.        (exit 2)
DecisionStreamExit
allowstdout (suppressed with --quiet)0
warnstderr0
haltstderr2

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

In hook mode the deny is the veto, not the exit code

Hook mode always exits 0. A halt 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.

Skill extraction from the payload

In hook mode the gate extracts the skill name from tool_input, most-specific first:

  1. explicit keys, in order: skill, name, command, skill_name, skill_id — taken when the value matches the namespaced pattern ^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*$;
  2. otherwise, any value in tool_input that looks like a namespaced skill name;
  3. as a last resort, a bare skill / name / skill_name even if not namespaced.

If a payload is present but no skill can be extracted, the gate does not block — it exits 0. This robustness is covered by the e2e suite (an unknown field still resolves the skill and denies a banned one).

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 deciding, the gate ships one audit event to POST /api/audit/ingest with the skill, resolved version, harness, machine, decision (allowed / warned / blocked), reason, and matched policy. It is fire-and-forget: a failure to ship never changes or delays the decision. See Audit log.