Threat model
Last reviewed: 2026-11-09. Scope: the registry service (web UI, REST API, MCP endpoint, server console, storage) and the publishing clients (CLI, Claude Code). Test references are to files under tests/; core = unit/core.test.ts, publish = integration/publish.test.ts, tokens = integration/tokens.test.ts.
Items that depend on components still being built (CLI, MCP server, web UI) are marked "contract" and need test evidence added when those components land.
Assets
| Asset | Why it matters |
|---|---|
| Release archives and their metadata | Users and agents install them. Integrity is the product. |
| Namespace ownership | Decides who may publish under a name people trust. |
API tokens, session cookies, TOKEN_PEPPER |
Authority to publish or administer. |
| Owner account | Root of all administrative authority. |
| Audit history | Attribution and incident reconstruction. |
| Private and draft artifacts | Unreleased or confidential work. |
| Machines of people who install | The largest blast radius: a malicious package runs with the installer's privileges. |
| The server host | A research Mac that also runs other workloads. |
Actors
- Anonymous visitor or scraper.
- Registered publisher (honest, careless, or malicious).
- Thief holding a stolen publisher token.
- Platform administrator (trusted, can err).
- Operator with shell access (fully trusted; the root of trust).
- An AI agent consuming registry content or acting as a publishing client. It may be manipulated by content it reads.
- A third party impersonating the owner, including a model that says it is the owner's Claude Code.
Trust boundaries
- Internet → Caddy gateway (TLS) → application port over WireGuard.
- HTTP request →
handleApi(authentication, rate limits) → services (authorization) → PostgreSQL. - Uploaded bytes → in-memory parser and validator → blob store. Bytes never cross into an interpreter.
- Registry content → a reader's browser (rendered Markdown) or an agent's context (MCP and API output).
- Server console → database. Only reachable with a shell on the node.
- Owner's workstation → OS credential store → CLI process → registry.
Threats and mitigations
Malicious archives
| Threat | Mitigation | Evidence | Residual risk |
|---|---|---|---|
| Path traversal or absolute paths to write outside the store | Archives are never extracted on the server. Paths are validated (unsafePathReason); file content is stored as database rows keyed by exact path; blobs are written under a key derived from the hash |
core "path safety", "reports traversal paths verbatim"; publish "rejects path traversal and writes nothing outside the store" | A third-party extractor on a user's machine could still mishandle an archive; unsafe archives are rejected before publication, and the CLI re-validates before writing (contract) |
| Symlink or hardlink to read or overwrite host files | Reader rejects every non-regular entry type | core "rejects symlinks, hardlinks and device files"; publish "rejects symlinks, secrets, bombs, garbage and identity spoofing" | None known |
| Decompression bomb, huge file count | Capped gunzip output, per-file and total size limits, file-count limit, declared-size check on upload | core "stops decompression bombs and oversized archives"; publish (bomb case) | A 20 MiB archive is held in memory during finalize; many concurrent finalizes could pressure memory. Upload and publish are limited to 30 per hour per credential |
| Code execution during ingestion | No code path executes, imports or renders package content as HTML. YAML is parsed with the core schema; JSON with JSON.parse |
Design; core "SKILL.md" | A parser bug in zlib, yaml or the JSON engine |
| Overwriting another package's release | Manifest name and version must equal the target artifact and version; (artifact, version) is unique; releases are immutable by trigger; blob keys are content hashes |
core "binds the package to the artifact"; publish (spoof case), "refuses to republish or mutate an existing version" | None known |
| Bytes swapped between declaration and publication | Declared SHA-256 and size are checked when bytes arrive and again at finalize; uploads belong to the user that began them | publish "rejects bytes that differ from the declared checksum or size" | None known |
| Secrets published by mistake | Blocking credential patterns; forbidden filenames (.env, keys, .npmrc); findings never echo the secret |
core "blocks credentials and never echoes them"; publish (leaky case) | Unknown token formats and secrets in binaries pass. Publishers remain responsible |
| Malware that static checks miss | Risky patterns raise advisory findings; a defined subset quarantines the release for human review; opaque binaries are flagged; status is labelled advisory everywhere | core "flags risky behaviour as advisory"; publish "quarantines suspicious releases" | High. Obfuscated or novel malware will pass. The registry never calls a package safe. Reports, revocation and takedown are the response path |
Prompt injection through package content
| Threat | Mitigation | Evidence | Residual risk |
|---|---|---|---|
| A README, SKILL.md or tool description tells an agent to exfiltrate data or ignore its instructions | Heuristic phrases (prompt-override, prompt-concealment, prompt-exfiltration) quarantine the release; bidirectional control characters are flagged; docs tell agents and operators to treat registry output as data; this repository's CLAUDE.md makes it a non-negotiable |
core "flags risky behaviour as advisory" | High. Heuristics catch only crude attempts. A skill is instructions by nature; installing one is a decision to trust its author |
| Injection aimed at the publishing agent (Claude Code) while it prepares a release | Publishing workflow requires an explicitly selected directory, a dry-run report and owner approval before first public release; the publisher token cannot moderate, revoke, deprecate or mint broader tokens | tokens "enforces scopes on every write", "never lets a token mint something broader than itself" | A manipulated agent could still publish unwanted content into the owner's own namespace. Scope limits contain it; revocation undoes it |
| A publisher ships an "image" that is really script or markup (SVG, HTML, polyglot) to run it on the registry origin | Example images must be listed in the manifest, have a .png/.jpg/.jpeg/.webp extension and a matching, well-formed raster header (checked at validation and again when served); SVG is refused; the media route serves only listed paths with the sniffed Content-Type, nosniff, Content-Security-Policy: default-src 'none'; sandbox and Cross-Origin-Resource-Policy: same-origin; nothing is decoded or resized on the server |
core.test.ts "usage examples", publish.test.ts "usage examples" (unlisted paths 404, non-image refused at publication) |
Only the header is parsed: a file with a valid image header and trailing junk is served as an image. A screenshot can show misleading or private content; the page says screenshots are publisher-supplied and unverified, and a release can be revoked |
| Stored XSS through Markdown | Markdown rendered without raw HTML; URL schemes restricted; CSP; downloads served as attachments with nosniff |
tests/e2e/publisher.spec.ts (script tag and javascript: link in a README stay inert); nonce-based CSP without unsafe-inline scripts |
Styles still allow unsafe-inline |
Credentials
| Threat | Mitigation | Evidence | Residual risk |
|---|---|---|---|
| Stolen publisher token | Tokens are scoped to namespaces and scopes, expire (90 days by default), can carry an IP allowlist, and can be revoked or rotated; every use is attributable in the audit log; publisher tokens cannot revoke releases, change tokens or moderate | tokens "a revoked token cannot publish", "an expired token cannot publish", "rotation issues a new secret", "honours IP allowlists", "writes an attributable audit trail" | Until revoked, the thief can publish new versions in the owner's namespaces. They cannot alter existing releases |
| Token leaked through logs, database dump or UI | Only an HMAC verifier and a prefix are stored; audit details redact secret-looking keys and token-shaped strings; the token is returned once with no-store |
tokens "stores only a verifier and a prefix", "redacts secrets from audit details" | A token printed by the operator into a shared terminal or shell history is outside the service's control |
| Token brute force or stuffing | 256-bit secrets; constant-time comparison; 20 failures per 10 minutes lock the source IP out of bearer authentication | tokens "locks out repeated authentication failures per client" | Lockout is per process and per IP; an attacker sharing an IP with a legitimate user can deny that user service for up to 10 minutes |
| Privilege escalation through token minting | Subset rule for scopes and namespaces; no elevated tokens from tokens; elevated tokens limited to administrators and 24 hours | tokens "never lets a token mint something broader than itself", "restricts elevated tokens" | None known |
| Password guessing | argon2id; 8 attempts per 15 minutes per IP and email; uniform error and timing for unknown accounts | tokens "logs in, requires step-up … rejects bad passwords uniformly" | No second factor in V1 |
| Session theft | HttpOnly cookie (web UI contract), server-side sessions that can be revoked, 12-hour lifetime, step-up for token operations | tokens "human sessions" | A stolen live session can act as the user except for token creation after 15 minutes |
| CSRF on cookie-authenticated writes | API requires same-origin Origin and a per-session CSRF token; bearer-token requests carry no ambient authority; CORS allows only GET/HEAD/OPTIONS cross-origin |
tokens "requires same-origin and a CSRF token" | None known |
Identity and authority
| Threat | Mitigation | Evidence | Residual risk |
|---|---|---|---|
| An AI model or person claims to be the owner | Authority comes only from a credential. No endpoint grants a role from a claim. No bootstrap or user-creation endpoint exists over HTTP | tokens "has no bootstrap or admin-creation endpoint", "cannot be invoked by a network principal" | Social engineering of the operator |
| Bootstrap abuse (creating a second administrator) | Bootstrap is a local console command; it takes an advisory lock and refuses if any owner or administrator exists; a partial unique index allows one owner row | tokens "fails closed once an administrator exists", "is enforced by the database" | Anyone with shell and database access is already fully trusted |
| Namespace squatting | Namespaces are created only by the operator; a reserved list blocks product and vendor names | Design (RESERVED_NAMESPACES) |
Look-alike names (reserch) remain possible; the operator reviews requests |
| Impersonation of a known publisher | "Identity verified" is set only by the operator; "source verified" only by a moderator and is cleared when the repository URL changes; the two badges are separate and neither means "safe" | publish "accepts anonymous abuse reports …" (verify-source) | Verification is a manual judgement |
| Platform administrator publishes into someone else's namespace | Administrators have no implicit publish right; publishing always requires ownership or an organization role | Design (userCanPublishTo) |
An operator with database access can do anything; that is the root of trust |
Confidentiality
| Threat | Mitigation | Evidence | Residual risk |
|---|---|---|---|
| Enumeration of private or draft artifacts | Hidden artifacts return 404, not 403, to anyone without read access; listings include only public active artifacts; quarantined releases are invisible to the public | publish "prevents publishing into, or reading privately from, someone else's namespace" | Response-time differences are not measured |
| Leaking internals in errors | Unexpected errors return a generic message and a request id; details go to the server log only | publish "publishes a coherent OpenAPI 3.1 document and health probes" (404 shape) | None known |
| Reporter or user data exposure | Reports are visible to moderators only; public history shows display names and actions, never IPs | publish "accepts anonymous abuse reports and exposes them only to moderators" | None known |
Integrity of signals
| Threat | Mitigation | Evidence | Residual risk |
|---|---|---|---|
| Download-count manipulation | One count per release, visitor hash and day; common bot user agents ignored; counts labelled as estimates; never used as a trust signal | publish "counts downloads as deduplicated estimates and ignores bots" | A motivated actor can rotate IPs and user agents. The number is informational only |
| Fake compatibility claims | Claims are stored with verified = false and labelled as publisher claims |
publish (compatibility verified false) |
Users may still over-trust a claim |
| Audit tampering | audit_events is append-only by trigger |
publish "refuses to republish or mutate" (DELETE FROM audit_events rejected) |
A database superuser can disable triggers |
Availability and supply chain
| Threat | Mitigation | Evidence | Residual risk |
|---|---|---|---|
| Request floods | Per-bucket rate limits on read, search, download, login, upload, publish, reports | tokens (lockout); buckets in ratelimit.ts |
In-memory, single process. No protection against volumetric attacks beyond the gateway |
| Abuse-report spam | 5 reports per hour per IP; length limits | Design | Low |
| Compromised dependency of the registry | Committed lockfile; minimumReleaseAge of 24 hours for new package versions; build scripts allowed only for an explicit list; small dependency surface (own tar reader, no ORM) |
pnpm-workspace.yaml |
No automated dependency audit or CI yet (Planned) |
| Compromised host | Blobs read-only; secrets only in .env (mode 600); DATA_DIR outside the repository; backups |
docs/OPERATIONS.md |
The node runs other applications under the same user. A compromise of any of them reaches this one |
| Loss of the node | Nightly database dump and blob copy to another node (infra/backup.sh, rsync to M3U96a) |
infra/restore-drill.sh, run 2026-11-09 |
Up to one backup interval of data |
Out of scope
- Safety of what a package does once a user chooses to run it.
- Security of remote MCP endpoints listed by publishers. The registry lists them; it does not operate or proxy them.
- Denial of service at the network layer.