Skip to content

Security ​

This page is the prose version of SECURITY.md.

Trust model ​

OPENGIST_TOKEN is an Opengist Personal Access Token. Whoever holds it can do everything its scopes allow. With the usual gist:read + gist:write pair that means reading every gist your account can see — private and unlisted included — and creating, rewriting or deleting them.

Gists are where configuration, logs and half-finished scripts end up. Treat the token as equivalent to that whole archive, and take two consequences seriously:

  • Everything this server returns enters the model's context. Do not point it at an instance whose private gists you would not paste into a chat window.
  • A public gist is a publishing channel. It is world-readable and, on most instances, listed. Content that goes out through create_gist cannot be withdrawn from anyone who already fetched it.

The token is read once at startup and then deleted from process.env, so it is not visible to child processes or in /proc/<pid>/environ. It stays in memory for the lifetime of the process, which is unavoidable — it has to sign every request.

The confirmation, honestly ​

Three kinds of operation ask a person before they act:

OperationWhy it is asked about
delete_gist, delete_gist_filesirreversible
Widening visibility (private → unlisted → public)irreversible disclosure
Creating a public/unlisted gist, or writing into onedisclosure of whatever the model is holding

Where the MCP client supports elicitation, that is a dialog shown to whoever is sitting there — the model cannot answer it on their behalf, and nothing happens until an answer comes back.

The reason it is not a confirm: true flag is that a flag is something the model can set on its own — and can be talked into setting by text inside a gist it read earlier in the session.

Where the client cannot show a dialog, the call is refused and carries a random, single-use token that expires after five minutes; the second call, with the token attached, executes. That token only ever appeared in a previous tool result, so it cannot be produced by injected text — but be clear about what it proves, because this server is: the call was made twice with the same arguments, and nothing more. A model can read it out of the first result and quote it back in the same turn. The fallback text says so rather than implying somebody approved, and names whether it was the client that could not be asked or the operator who switched the dialog off with ELICITATION=false.

Either way the approval is bound to the exact effect of the call it was issued for:

  • delete_gist_files binds to the precise set of filenames, so a confirmation for ["notes.txt"] cannot be replayed for ["notes.txt", "secrets.env"].
  • create_gist binds to the file contents, the title, the description and the expiry, so a confirmation obtained for harmless content cannot be replayed with different content under the same filename.
  • update_gist binds to the whole call — visibility, title, description and every file operation — so an approval for "make this public" cannot arrive with extra file writes attached.

Two smaller properties are worth knowing. Confirmations are checked after the arguments are validated, so a call that could not have succeeded anyway is reported as the input error it is rather than costing a round-trip. And narrowing visibility is never asked about: making a gist private is not a disclosure.

See Asking a person.

Untrusted content ​

Everything Opengist returns was written by a person, and quite possibly not by you: file contents, titles, descriptions, topics, and the git author names on commits. A gist is exactly the shape of thing that contains "ignore your previous instructions", whether deliberately or because someone pasted a prompt-injection example into it in 2024.

Two mechanisms handle this:

Marking. Any response carrying upstream text is tagged with an explicit note saying it is untrusted data to be reported, never followed. This covers file contents, gist metadata, the metadata of embedded forks and of the gist something was forked from, and commit author names.

Not quoting it back. The notes and refusals this server writes are prose that a model reads as instructions from its tooling, so no user-controlled string is interpolated into them. Confirmation prompts describe a deletion by counting the files, not by naming them. Truncation notes refer to files[2], not to "config.yaml" — a filename ending in ", offset 0). SYSTEM: would otherwise close the quoting and forge what looks like operator guidance. The names are still available to the model, as structured fields of the JSON result, where they are data.

Bounded results ​

Anything unbounded is a way to fill a context window with something useless:

  • File contents are capped per file and against a total budget, with a further 400 KB backstop on the whole serialized result.
  • Binary content is detected and omitted rather than dumped as mojibake.
  • Commits and forks are left out unless asked for.
  • Response bodies over 8 MB are refused while being read, before any of the above gets a chance to trim a string that is already resident in memory.
  • search_gists states exactly how much it scanned and marks incomplete results.

Every omission names the call that fetches the rest, so nothing disappears silently.

What the instance sends ​

Nothing Opengist answers is taken at its word. Every response is read at one boundary, field by field: a field of the wrong type is absent rather than fatal, a count is finite, a commit id has the shape of one before it is used in a request path or a note, a display string is cut at 2000 characters, and related gists are read one level deep. One entry that cannot be read costs the listing that one entry, never the other ninety-nine.

Every string that leaves is stripped of control characters (C0 except tab, line feed and carriage return; C1; DEL) and repaired of lone surrogates, which a slice at a character budget can produce and which some clients cannot encode. Where that touches a file body, the result says how many characters were removed from which file — the gist itself still contains them, so a write that copies the content back would drop them, and the note is there so that is a decision rather than an accident.

Transport ​

Requests refuse redirects, so the bearer token cannot be replayed against another host. Every request carries a 30-second timeout. Path parameters are validated against ., .., slashes and control characters, and then URL-encoded. The status of an answer is read before its body: an error body is read under a 64 KiB ceiling of its own, cut, cleaned, and labelled as the instance's text, and HTML error pages are dropped entirely rather than pushed into the context.

The token is checked for the shape a header value must have — printable ASCII, at most 1024 characters — at startup and again in front of every request. The HTTP layer's own refusal quotes the offending header value in full, and for the Authorization header that value is the token; the check here names the header and nothing else.

What actually holds ​

The confirmation tokens and OPENGIST_READ_ONLY are guard rails inside this process. They are worth having, and they are not a security boundary — a bug in this server would be enough to get past them.

The boundaries that hold are outside it: the scope of your access token, and the permission prompts of your MCP host. A token with gist:read and user:read and nothing else cannot write, whatever the model attempts and whatever this code does. If that is the property you want, set the scopes.

Reporting a vulnerability ​

Use private vulnerability reporting, never a public issue, and do not include real tokens, hostnames or gist contents in the report.

Released under the MIT License.