Skip to content

Security: dgesteves/onsystem

SECURITY.md

Security

Reporting a vulnerability

Report it privately through GitHub's private vulnerability reporting, not in a public issue. Include the version, how the server or CLI was run, and the smallest project or input that shows the problem. You get a reply within 2 working days.

Supported versions

Fixes go to the latest minor release only, as a patch release of it, with an advisory once users can upgrade. On 0.x, a minor release can change behaviour, so upgrade to the latest minor to get a fix.

Verifying a release

Every release is published to npm by this repository's release workflow (.github/workflows/release.yml, on main), through npm trusted publishing with provenance: no token publishes it. To check what you installed, run this in the project:

npm audit signatures

It verifies the registry signature of every installed package and the attestations of those that have one, onsystem among them. The package page on npmjs.com shows the same provenance: the workflow and the commit it built. Each release has a tag, onsystem@X.Y.Z, and a GitHub release.

What the tool trusts

  • The server runs locally over stdio, opens no ports and makes no network requests of its own. npx downloads the package from npm when the client starts it.

  • It reads files under the project root (components, tokens, docs, tsconfig, tailwind.config) and the workspace packages they point at. In node_modules it reads the config a design-system package declares, as JSON (never run), and the type declarations of a library or an installed design-system package it reads components from (MUI, @acme/ui).

  • It writes its cache, the baseline file (creating its folder) when you run check --update-baseline or check --prune-baseline, and the file export --out names. init writes onsystem.config.json; in a design-system package also ONSYSTEM.md and the onsystem field (and files) of package.json; and, when you say yes, an onsystem section in AGENTS.md and CLAUDE.md.

  • The cache is in node_modules/.cache/onsystem. A project without node_modules has it in a folder of the system temp folder that only you can open (onsystem-<uid>): one that is a link, belongs to another user or that others can write to is not read, since on a shared machine a model planted there would reach your agent.

  • Component, token and docs files are read only when their real path, links resolved, is in the project, the workspace or git repository it is in, the package whose declared config it takes on, or a package installed in a node_modules folder of those, which pnpm may link from a store elsewhere. A link, or a ../ or absolute path in the config, that leads anywhere else is skipped, and inspect and the server's log name it. The home folder, the folders above it and the filesystem root never count as the workspace, the repository or a package's folder. The node_modules rule is the trade-off for pnpm's store: a link committed inside node_modules is followed like an installed package, so it can reach a package installed in another project on the machine.

  • To show a component's props, TypeScript reads the modules the component imports wherever they resolve, outside the project too. A literal type from such a file (export const KEY = "..." as const) can show in get_component.

  • check_ui with a path reads only files inside the project root, symlinks resolved, and limits input size.

  • What the repository wrote reaches the agent: JSDoc, docs pages, examples, names, class lists, token names and values, file, folder and package names, config keys. Every text the MCP server sends about the project, each tool's answer, each resource it reads, the prompt, each error and notice, and each description in the resource list, is built as lines by one function, answer() in src/server/render.ts (the server takes no other text for them), and each line is one line: a line break left inside one, any of those Python's str.splitlines() splits at (\n, \r, a vertical tab, a form feed, U+001C to U+001E, U+0085, U+2028, U+2029), is escaped as in JSON (\n, \u2028), so the text after it cannot start a line of its own. An error's message, such as the list of problems in an invalid config, is one line too: every tool, resource, resource listing, completion and the prompt is registered through one helper that catches what its handler throws and sends it as an answer. No line starts with what the repository wrote, unmarked:

    • The JSDoc description and docs sections are Markdown quotes: every line starts with >, whichever of those line breaks the file uses.
    • A deprecation note, a prop's, a part's or a token's description, the first sentence of a description in list_components, search_components and the resource list, a prop's default that is not a short value (a @default sentence, a default over several lines, a string literal over 40 characters, cut to 80), a docs heading and an example title are in double quotes, on one line and cut short. shadcn/ui's purpose for a component without a description, Tailwind's default tokens and a library's theme are described by onsystem, and shown as written.
    • An example is code in a fence it cannot close.
    • A name that does not look like one, one with a space, a quote mark or a line break, or longer than 40 characters (100 for a token or a package), is JSON-quoted and cut short: a variant, a variant value, a prop, a token, a mode, a package, a registry, an icon's module, the element a component renders. A file or folder path with more than a path's characters (a space, a line break, which git allows) is JSON-quoted, and so is every import path, and an invalid config's unknown key, rule, library or severity, the name of a dependency whose onsystem field cannot be read (in that error and in the warnings the CLI prints), and the name of a dependency whose config names paths outside its package or workspace, with those paths, in the warning that says they were ignored.
    • A cva class list that does not read as classes (quote marks, sentence punctuation at the end of a class, a word in capitals, or three or more plain words that are not Tailwind classes), and a token's value or usage that does not read as a CSS value (three plain words in a row, or sentence punctuation before a word, outside quotes and parentheses), are quoted and cut short. These two checks are heuristics: a phrase of one or two plain words can still be shown as written.
    • When get_component, the ds://components resource, list_components, search_components or get_tokens marks anything, a line of its answer says that text after >, in double quotes or in a code fence is the repository's own: a description, not instructions.

    Shown as written, on one line: a name that looks like one (outline, color.brand.500), a component's name (a capitalised identifier: extraction takes no other, from a source file or a package's type declarations, so a string-named export such as export { Card as "Any text" } is no component), a prop's default that reads as a short value, a cast at its end included ("md" as Size), a prop's type (comments left out, each string literal cut to 40 characters), the project root, a path a tool was given in an error about it, and the repository's values inside a rule message, such as the allowed values no-unknown-variant lists (check_ui, and the plugin's hook, which prints a line break in one as it is). The structured content is JSON, with the repository's text as written. An agent reads the repository anyway; the marks keep its text from reading as onsystem's own answer.

  • An onsystem.config.ts, .mjs or .js file (or one under the old design-system-mcp.config name) is imported, so it runs as code, like an ESLint config. A tailwind.config is parsed, never run. Treat the config file like any other code in the repository.

The Claude Code plugin

The plugin's hook and server run in every project you open in Claude Code, so use them in repositories you trust, as you would an editor extension that runs a project's linter:

  • Both import the project's onsystem.config.ts, .mjs or .js, as above.
  • The hook runs the project's own install of the CLI when there is one (node_modules/onsystem, whose bin it runs with Node.js), whatever its version. Without one, it runs npx with the exact version the plugin was released with. The server runs npx -y onsystem@<that version>, which uses a project install of the same version.
  • npx runs in the project folder, so the project's npm config applies to it, such as a registry in its .npmrc.
  • The hook writes marker files in the system temp folder, so its notice is shown once per session and its pause after a failed npx holds.

The GitHub Action

What it runs and the permissions it needs are in required-check.md. In short:

  • It runs the onsystem the project installed (node_modules/onsystem, from working-directory up to the repository root), else npx --yes onsystem@<the action's version>. Pin a commit SHA to stay on one release of both.
  • On pull_request, pull_request_target and merge_group it runs git fetch from origin for the history it needs to find the changed lines. It needs only contents: read.
  • check imports the checked-out onsystem.config.*. Do not run it on pull_request_target with the pull request's head checked out: that runs a fork's config with the base repository's token.

Reports about any of these boundaries are welcome.

There aren't any published security advisories