Skip to main content
Glama

stdio-box

stdio-box is one container image that runs a third-party MCP stdio server behind the Keystone door. The wrapper is the same for every server; each server is a provider directory under packages/.

Callers reach the box only through Keystone. The door checks the person or service account, and the box checks the door's bearer token and signed identity before it forwards a tool call.

How Keystone calls the box

Only the door calls the box. A person reaches it through an agent signed in at the door. A service reaches it as a client of the door, with that service's own service account: the assertion then has sub of the form svc:<name> and sub_type of service. The box does not read X-Author-Email.

stdio-box serve listens on 0.0.0.0 and BOX_PORT. A missing or unacceptable setting exits 2, one line per problem: config error: <NAME> is required.

Variable

Default

Accepted value

BOX_BEARER_TOKEN

at least 32 bytes

BOX_IDENTITY_SECRET

at least 32 bytes

BOX_IDENTITY_AUDIENCE

non-empty; the registry entry name

BOX_PUBLIC_BASE_URL

an https:// URL, including https://public-domain-pending.invalid

BOX_STORE_KEY

at least 32 bytes

BOX_PROVIDER

^[a-z][a-z0-9_-]{0,30}$

BOX_PROVIDER_DIR

/opt/box/packages/<BOX_PROVIDER>

directory containing provider.yaml and tools.snapshot.json

BOX_COMMAND

non-empty

BOX_PORT

8081

integer from 1 to 65535

BOX_DATA_DIR

/data

BOX_LOG_LEVEL

INFO

DEBUG, INFO, WARNING, ERROR, or CRITICAL

BOX_ALLOWED_HOSTS

empty

comma-separated host names

BOX_DEBUG

unset

1 enables debug-spawn

BOX_CHILD_ARGV_OVERRIDE

unset

child program path, used only when BOX_DEBUG=1

BOX_MAX_INSTANCES

8

integer from 1 to 32

BOX_IDLE_SECONDS

600

integer from 1 to 31536000

BOX_START_TIMEOUT_SECONDS

60

integer from 1 to 31536000

BOX_CALL_TIMEOUT_SECONDS

120

integer from 1 to 31536000

BOX_AUDIT_RETENTION_DAYS

30

integer from 1 to 3650

BOX_ALLOW_HTTP_URLS

false

true allows an http URL in a check

BOX_ALLOW_PRIVATE_URLS

false

true allows a private, loopback, or link-local address

BOX_ALLOWED_URL_HOSTS

empty

comma-separated host patterns (fnmatch)

BOX_FORM_TTL_SECONDS

900

integer from 60 to 3600

BOX_DEBUG and BOX_CHILD_ARGV_OVERRIDE are spawner settings. A missing value is not an error. BOX_DEBUG=1 enables debug-spawn. Together with BOX_CHILD_ARGV_OVERRIDE, it names the child program and launch does not install.

Checks on /mcp, in order:

  1. Host, with or without a port, must be BOX_IDENTITY_AUDIENCE, that name plus -mcp, localhost, 127.0.0.1, or a name from BOX_ALLOWED_HOSTS. Otherwise HTTP 421 and {"error":"misdirected_request"}.

  2. Authorization: Bearer must match BOX_BEARER_TOKEN. The comparison does not stop early on length. Otherwise HTTP 401 and WWW-Authenticate: Bearer. The MCP parser does not run.

  3. tools/list carries no identity. The box returns the published tools from tools.snapshot.json, then the ten creds_* tools. It does not start the provider server.

  4. tools/call requires X-Keystone-Identity: HS256, header typ of keystone-identity+jwt, audience BOX_IDENTITY_AUDIENCE. mcp_target must be the tool name on this call. Each jti is accepted once, and remembered until its exp plus 30 seconds. The memory holds at most 100000 entries; when it is full the call is rejected and nothing already stored is dropped.

A failed tool call is isError: true with one text block of JSON: code, message, and hint. code is one of identity_rejected, no_credentials_profile, tool_not_in_profile, profile_needs_reentry, instance_unavailable, instance_failed, instances_exhausted, instance_busy, upstream_timeout, not_available, invalid_argument, unknown_provider, profile_exists, profile_not_found, profile_in_use, binding_exists, binding_not_found, secret_field_in_chat, secret_reentry_required, missing_fields, form_unavailable, check_failed. There is no credentials_ambiguous code: when several bindings match, the first in order is the one that applies.

tools.snapshot.json is the provider server's tools/list, stored as-is:

{
  "schema_version": 1,
  "server": {"command": "uvx echo-server==0.0.0"},
  "taken_at": "2026-10-15T00:00:00Z",
  "tools": []
}

The snapshot's server.command must equal the provider's server.command. Published names come from the child names by tools.strip_prefix, then tools.normalize: snake (re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_").lower()), then tools.rename, then tools.hide. A published name matches ^[a-z][a-z0-9_]{0,63}$ and does not start with creds_. Two child tools that publish as one name are an error at startup.

GET /healthz does not check the bearer token. A request that carries X-Forwarded-Host (the door's proxy, from the public domain) gets the status code and {}. Any other request gets status, store, isolation, and install. isolation is the wrapper's current value: unknown until a child starts, then per_slot_uid or shared_uid. install is unknown until launch has installed a server; then it is key, at, duration_ms, and from_cache. A store schema newer than this process is HTTP 503. A key that does not match is HTTP 200 with status degraded and store key_mismatch.

A call of a provider tool is routed to one profile, described under How a call is routed. creds_providers describes the loaded provider. creds_whoami reports which profile this caller would use and does not refuse. The other creds_* tools are the account tools under Box tools. isolation and server.installed_key are "unknown" until those checks report.

Related MCP server: mcp-server

How a call is routed

Each call reads the caller's identity again. Nothing about the choice is cached.

  1. A binding user:<sub> wins. sub is lowercased, so user:Ann@Example.com matches ann@example.com. A service account is the same rule with sub svc:<name>: the binding is user:svc:<name>.

  2. When rbac is not off, role bindings are checked in ascending position. The first role that is in the caller's roles wins. Other roles of that caller that sit lower are not used. Two roles never refuse the call; the administrator's order decides. When rbac is off, this step is skipped because the assertion carries no roles.

  3. The default binding.

  4. Otherwise the call is refused with no_credentials_profile: "There is no account for you in this box. Ask an administrator to bind one to you or to your role."

The profile's state does not change which binding wins. If the winning profile has no secrets yet, or its secrets cannot be read, the call is profile_needs_reentry and does not fall through to a later binding. A profile that is ready or check_failed is allowed to run.

The tool must be in the profile variant's tools patterns (or the variant must list no patterns, which allows every tool). Otherwise the call is tool_not_in_profile and the child is not started: "The account () does not include . Ask an administrator for an account that covers it."

creds_whoami returns the same choice without refusing: sub, sub_type, roles, rbac, profile, tier (user, role, or default), the winning binding, not_used (lower role bindings that also matched), the variant's tool patterns, would_refuse, and a hint. When not_used is not empty, the hint says the higher binding was used and how to move another up with creds_bind … --replace --position.

Instances

One profile has at most one child. The child starts on the first call and stops when it is idle. The instance key is the profile id and its secret version.

Limit

Setting

Default

What happens

Live instances

BOX_MAX_INSTANCES

8 (1–32)

A new profile evicts the idle instance with the oldest last_call_at. If every instance has a call in flight, the call is instances_exhausted (retryable).

Idle stop

BOX_IDLE_SECONDS

600

A background pass every 30 seconds stops an instance with no calls in flight for longer than this.

Start

BOX_START_TIMEOUT_SECONDS

60

initialize must answer within this. Otherwise the start counts as a failure.

Call

BOX_CALL_TIMEOUT_SECONDS

120

The call is upstream_timeout and the child is sent notifications/cancelled.

Calls at once on one instance

constant

8, wait 30 seconds

instance_busy (retryable). One slow account does not take the slots of the others.

Ping

constants

every 15 seconds, timeout 5 seconds, restart after 3 misses in a row

One missed ping does not restart. The log says instance <profile> missed ping n/3.

Restart after a crash

constants

1 second, doubling up to 60 seconds; back to 1 second after 60 seconds of stable running

During the wait the call is instance_unavailable with retry_after_seconds.

Failed starts

constant

5 in a row, then failed

The call is instance_failed until a credential check or a rotation clears it.

Stop

constants

close the session, then the spawner closes stdin, waits 2 seconds, SIGTERM, waits 5 seconds, SIGKILL

In-flight calls fail as soon as the session closes.

stderr

constants

50 lines per second, burst 200

Suppressed lines are counted. Field values of length 6 or more are masked.

Child environment

—

PATH, HOME=/tmp/box/slot-N, TMPDIR, LANG=C.UTF-8, server.static_env, and the variant's fields

The wrapper's environment is not inherited. A field that is not part of the variant is not passed.

Slot cleanup

—

before the slot is given out, and after the child stops

A slot whose cleanup failed is quarantined and is not given out again.

The wrapper also reads meta.revision every 2 seconds. If a live profile was deleted, its version changed, or it now needs its secrets again, that instance is stopped (a version change lets in-flight calls finish for up to 30 seconds). credsctl therefore applies without restarting the container. Audit rows older than BOX_AUDIT_RETENTION_DAYS are deleted once an hour. A status snapshot is written about every 10 seconds for credsctl status.

Changing limits

Set the BOX_* limits in the package override, under environment:. The file is packages/<package>.override.yml.

environment:
  BOX_MAX_INSTANCES: "4"
  BOX_IDLE_SECONDS: "300"
  BOX_START_TIMEOUT_SECONDS: "60"
  BOX_CALL_TIMEOUT_SECONDS: "120"
  BOX_AUDIT_RETENTION_DAYS: "30"

A value outside the accepted range is a config error. The process exits 2 and names the variable. The ping, backoff, and per-instance concurrency numbers are not variables.

One image, many providers

The image is built from this repository and published as ghcr.io/keystone-codes/stdio-box. It contains the wrapper and the data for every provider: provider.yaml, the lock file, and tools.snapshot.json land at /opt/box/packages/<name>/. Manifests, example policies, and provider READMEs stay out of the image, because the manifest pins the image digest.

The server program itself is not in the image. The first start of a container installs it from the lock file into the cache volume. After that the container can run without the public network. A host that cannot reach PyPI or npm can set BOX_PIP_INDEX_URL or BOX_NPM_REGISTRY. This release does not ship a per-provider image with the server already installed.

Adding a provider is a new directory under packages/ and a new image release. See packages/README.md.

Processes in the container

PID 1  tini                                 (root; reaps orphaned children)
 └─ stdio-box launch                        (root, short-lived)
     │  1. prepares /data, /cache, /tmp/box
     │  2. installs the provider into /cache under uid 10002
     │  3. splits into two processes:
     ├─ spawner                             (root; spawn, signal, and reap over a socketpair)
     └─ wrapper  uid 10001, non-dumpable    (HTTP :8081 — /mcp, /creds/, /healthz)
          ↕ stdin/stdout/stderr passed across by the spawner
         child  uid 20000+slot              (one profile's server; HOME=/tmp/box/slot-N)

stdio-box launch is the process the image starts. It fixes the directory owners, installs the provider server into /cache under uid 10002 when setuid works, then splits into the root spawner and the wrapper. A missing BOX_COMMAND exits 2. A failed install logs provider_install_failed and exits 3. BOX_DEBUG=1 together with BOX_CHILD_ARGV_OVERRIDE skips installation and uses that program as the child. stdio-box serve without spawner arguments is the HTTP wrapper. stdio-box serve --spawner inline or --spawner-fd is the wrapper process launch execs. stdio-box install and stdio-box snapshot-tools install without that split.

Path

Owner

Mode

Use

/data

uid 10001 (box)

0700

Profile store

/cache

uid 10002 (box-install)

0755

Installed server, written only by the installer

/tmp/box

root

0711

Per-slot homes for children

The image does not set USER. launch starts as root so it can drop to these uids.

Installing the server

The image does not contain the provider's server. BOX_PROVIDER selects /opt/box/packages/<BOX_PROVIDER> (or BOX_PROVIDER_DIR when that is set). BOX_COMMAND names the server and its exact version. The box parses that string with shlex. It does not run it in a shell, and it does not invoke uvx or npx while the container is up.

Grammar:

  • uvx <distribution>==<version> [arguments…]

  • npx [-y|--yes] <package>@<version> [arguments…] (<package> may be @scope/name)

The version is exact. A Python version must be a single packaging version. An npm version must be X.Y.Z or X.Y.Z-<prerelease>. Ranges, latest, and ^ ~ > < * x are rejected (box_command_invalid). A flag before the package (--from, --with, -p, and any other) is rejected: those flags would skip the lock file. Arguments after the package are kept for the child process.

Before installation, the command is checked against the provider lock (box_command_not_locked when it does not match):

  • Python: requirements.txt has a line for the normalized distribution name at that exact version, with at least one --hash=sha256:, and every requirement line has a sha256 hash.

  • Node: package.json dependencies is exactly that package and version, package-lock.json is lockfileVersion 3, packages["node_modules/<package>"].version is that version, and every downloaded package has integrity.

The installer runs as uid 10002 and writes /cache/<provider>/<key>/. The key is the first 16 hex characters of a SHA-256 over the lock bytes, package.json for Node, the installer kind, and the installer version baked into the image. If <key>/.complete already contains that key, the tree is reused and the network is not used. The first start needs a route to the index, or to a mirror. Later starts do not.

Mirrors are visible only to the installer. BOX_PIP_INDEX_URL is passed as UV_INDEX_URL. BOX_NPM_REGISTRY is passed as npm_config_registry. When either is set it must start with https://. Hashes are checked for a mirror the same way as for the public index.

BOX_INSTALL_TIMEOUT_SECONDS defaults to 240 and must be from 30 to 1800. A failed install logs provider_install_failed and launch exits 3. Docker's restart: unless-stopped starts the container again, and the install is tried again.

A machine with no network and no mirror can be prepared by installing once where the index is reachable, then copying the Docker volume ks-pkg-<name>-cache. This release does not publish an image with the server already installed.

The installed tree is owned by uid 10002. Directories are 0755. Files are not writable by the group or by other users, so a child (uid 20000+slot) can run the server and cannot change it. Nothing except the installer runs as uid 10002.

If the container cannot change uid (user: in an override, or the capabilities that setuid needs have been dropped), the installer runs without changing uid and the log records install_isolation_unavailable. In that shared_uid mode the spawner does not start, children share the wrapper's uid, and a child can modify the cache.

stdio-box install [--provider-dir DIR] [--cache DIR] runs the same install without launch. When the caller is not root, it does not try to change uid.

stdio-box snapshot-tools installs into a temporary cache, starts the server once, and writes tools/list. keystonectl package wrap-stdio runs it as docker run --rm --entrypoint stdio-box -v <directory>:/opt/box/provider:ro -e BOX_PROVIDER_DIR=/opt/box/provider <image> snapshot-tools --out -.

provider.yaml

schemas/provider-v1.json (Draft 2020-12, $id https://keystone.codes/schemas/stdio-provider-v1.json). Every object rejects unknown keys. stdio-box validate-provider <directory> checks the schema and the rules below. Success is exit 0. Failures are one path: message line each and exit 1.

Field

Required

Constraints

schema_version

yes

1

name

yes

^[a-z][a-z0-9_-]{0,30}$, equal to the directory name

title

yes

1–80 characters

server.command

yes

1–300 characters, starts with uvx or npx

server.bin

yes

^[A-Za-z0-9._-]{1,64}$

server.lock

yes

requirements.txt (command starts with uvx) or package-lock.json (command starts with npx, and package.json is in the directory). The file must exist.

server.static_env

no

Up to 32 pairs. Name ^[A-Z][A-Z0-9_]{0,63}$, value up to 1024 characters.

fields

yes

1–16 objects: name, type url or string, secret, hint (1–200), optional to (env or {arg: "--flag"}), optional max_length 1–4096.

variants

yes

1–8 objects. name matches ^[a-z][a-z0-9_]{0,20}$. Optional title (1–80). fields names fields of this variant. Optional tools (1–16 fnmatch patterns; omitted means every tool). Optional allowed_hosts (1–16 host patterns, only when the variant has a url field). checks is 1–4 items.

variants[].checks[]

yes

Exactly one of http or tool. http: method GET or POST, url, optional auth (basic of two fields, bearer of one field, or header with name, optional scheme, and field), optional headers, expect_status, optional identity, optional timeout_seconds 1–30. tool: name, optional arguments, optional timeout_seconds 1–30.

tools

no

strip_prefix, normalize none or snake, rename, hide.

discovery

yes

mode placeholder or manual. Optional env. In placeholder mode, env must supply every field of at least one variant, and each URL value's host must end with .invalid.

identity_to_child

no

none (default) or meta.

Further checks: field names are unique, variant names are unique, and two variants cannot list the same set of fields. Every name used in a variant's fields, in auth, or as {FIELD} in a check URL or header must exist. {FIELD} in a URL or header must be a non-secret field of that variant. The credential in auth (the second basic field, bearer, or header.field) must be a secret field of that variant; the first basic field is the username and may be non-secret. A secret field with to: {arg: ...} is rejected: secret field <name> cannot be passed as an argument.

Worked example, Atlassian (Jira and Confluence). The same file is tests/data/packages/atlassian/provider.yaml. Its requirements.txt in the test tree is a stub so the presence check passes; the real lock arrives with the provider package.

schema_version: 1
name: atlassian
title: Atlassian (Jira and Confluence)
server:
  command: "uvx mcp-atlassian==0.23.1"
  bin: mcp-atlassian
  lock: requirements.txt
  static_env:
    READ_ONLY_MODE: "false"
fields:
  - {name: JIRA_URL, type: url, secret: false, hint: "Jira address, for example https://example.atlassian.net"}
  - {name: JIRA_USERNAME, type: string, secret: false, hint: "Account email (Atlassian Cloud)"}
  - {name: JIRA_API_TOKEN, type: string, secret: true, hint: "API token from id.atlassian.com, Security, API tokens"}
  - {name: JIRA_PERSONAL_TOKEN, type: string, secret: true, hint: "Personal access token (Jira Server or Data Center)"}
  - {name: CONFLUENCE_URL, type: url, secret: false, hint: "Confluence address, for example https://example.atlassian.net/wiki"}
  - {name: CONFLUENCE_USERNAME, type: string, secret: false, hint: "Account email (Atlassian Cloud)"}
  - {name: CONFLUENCE_API_TOKEN, type: string, secret: true, hint: "API token from id.atlassian.com (the same token works for Jira)"}
  - {name: CONFLUENCE_PERSONAL_TOKEN, type: string, secret: true, hint: "Personal access token (Confluence Server or Data Center)"}
variants:
  - name: cloud
    title: Jira and Confluence, Atlassian Cloud
    fields: [JIRA_URL, JIRA_USERNAME, JIRA_API_TOKEN, CONFLUENCE_URL, CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN]
    allowed_hosts: ["*.atlassian.net"]
    checks:
      - http:
          method: GET
          url: "{JIRA_URL}/rest/api/2/myself"
          auth:
            basic: [JIRA_USERNAME, JIRA_API_TOKEN]
          expect_status: [200]
          identity: displayName
      - http:
          method: GET
          url: "{CONFLUENCE_URL}/rest/api/user/current"
          auth:
            basic: [CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN]
          expect_status: [200]
          identity: displayName
  - name: jira_cloud
    fields: [JIRA_URL, JIRA_USERNAME, JIRA_API_TOKEN]
    tools: ["jira_*"]
    allowed_hosts: ["*.atlassian.net"]
    checks:
      - http:
          method: GET
          url: "{JIRA_URL}/rest/api/2/myself"
          auth:
            basic: [JIRA_USERNAME, JIRA_API_TOKEN]
          expect_status: [200]
          identity: displayName
  - name: confluence_cloud
    fields: [CONFLUENCE_URL, CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN]
    tools: ["confluence_*"]
    allowed_hosts: ["*.atlassian.net"]
    checks:
      - http:
          method: GET
          url: "{CONFLUENCE_URL}/rest/api/user/current"
          auth:
            basic: [CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN]
          expect_status: [200]
          identity: displayName
  - name: server
    title: Jira and Confluence, Server or Data Center
    fields: [JIRA_URL, JIRA_PERSONAL_TOKEN, CONFLUENCE_URL, CONFLUENCE_PERSONAL_TOKEN]
    checks:
      - http:
          method: GET
          url: "{JIRA_URL}/rest/api/2/myself"
          auth:
            bearer: JIRA_PERSONAL_TOKEN
          expect_status: [200]
          identity: displayName
      - http:
          method: GET
          url: "{CONFLUENCE_URL}/rest/api/user/current"
          auth:
            bearer: CONFLUENCE_PERSONAL_TOKEN
          expect_status: [200]
          identity: displayName
  - name: jira_server
    fields: [JIRA_URL, JIRA_PERSONAL_TOKEN]
    tools: ["jira_*"]
    checks:
      - http:
          method: GET
          url: "{JIRA_URL}/rest/api/2/myself"
          auth:
            bearer: JIRA_PERSONAL_TOKEN
          expect_status: [200]
          identity: displayName
  - name: confluence_server
    fields: [CONFLUENCE_URL, CONFLUENCE_PERSONAL_TOKEN]
    tools: ["confluence_*"]
    checks:
      - http:
          method: GET
          url: "{CONFLUENCE_URL}/rest/api/user/current"
          auth:
            bearer: CONFLUENCE_PERSONAL_TOKEN
          expect_status: [200]
          identity: displayName
tools:
  normalize: none
discovery:
  mode: placeholder
  env:
    JIRA_URL: https://discovery.invalid
    JIRA_USERNAME: discovery@example.invalid
    JIRA_API_TOKEN: discovery-placeholder
    CONFLUENCE_URL: https://discovery.invalid/wiki
    CONFLUENCE_USERNAME: discovery@example.invalid
    CONFLUENCE_API_TOKEN: discovery-placeholder
identity_to_child: none

Adding a provider

A provider is a directory packages/<name>/. The image build copies the directory's data (provider.yaml, the lock, package.json when the server is Node, and tools.snapshot.json) to /opt/box/packages/<name>/. Manifests and the provider README stay out of the image.

Build the lock in that directory:

uv pip compile --universal --generate-hashes --python-version 3.12 requirements.in -o requirements.txt
npm install --package-lock-only

uv pip compile --universal records wheels for more than one platform, and uv pip install honors those markers. Do not hand-write tools.snapshot.json. keystonectl package wrap-stdio runs snapshot-tools in this image and writes the file. Commit the lock and the snapshot with provider.yaml.

Security model

Who may call a tool is decided by the door's policy. The box keeps no role list of its own.

The signed identity assertion is forwarded to the child only when the provider sets identity_to_child: meta. The default is none.

A secret field cannot be passed as a command-line argument. Credential values travel in the child's environment, and only in the explicit list the wrapper builds for that profile. The child never receives the wrapper's environment.

PID 1  tini                                 (root; reaps orphaned children)
 └─ stdio-box launch                        (root, short-lived)
     ├─ spawner                             (root; spawn, signal, and reap over a socketpair)
     └─ wrapper  uid 10001, non-dumpable    (HTTP :8081 — /mcp, /creds/, /healthz)
          ↕ stdin/stdout/stderr passed across by the spawner
         child  uid 20000+slot              (one profile's server; HOME=/tmp/box/slot-N)

The wrapper calls prctl(PR_SET_DUMPABLE, 0) as soon as it starts, so /proc/<wrapper>/environ is not readable by another process of uid 10001. After exec, a child is dumpable again; what protects the next profile is that the child runs as 20000 + slot, not as the wrapper.

A child can see its own profile values, its slot directory /tmp/box/slot-N (mode 0700), and the server code under /cache (readable and executable, not writable). It cannot see the store key, the database under /data (mode 0700, owner 10001), or another profile's environment. It cannot change the code a later profile's child will run: /cache is owned by uid 10002, and the spawner never runs a child as that user.

The spawner is the only process that stays root. It does not listen on the network and does not read /data. The wrapper asks it to spawn, signal, and reap over the socketpair inherited across exec. There is no control socket on the filesystem.

keystonectl sets security_opt: no-new-privileges:true and does not set user: or drop capabilities. no-new-privileges does not block this layout: root drops uid with setuid, not with a setuid binary.

If an override sets user: (the process is not root) or drops SETUID/SETGID, the box keeps running in shared_uid mode and does not start the root spawner. Children then share the wrapper's uid, the log contains child_isolation_unavailable once, and isolation is shared_uid. Dropping those capabilities leaves the process root, because it can no longer call setuid to become uid 10001 either. In that mode a uid-wide reap would kill the wrapper, so cleanup is only killpg of the child's process group and recreating /tmp/box/slot-N. A descendant that called setsid can outlive that.

Slot numbers are reused, and with them the uid 20000 + slot. Before every spawn into a slot, and after every stop, the spawner reaps that uid: a helper process setuid to the slot sends SIGKILL to every process it can see (this container's PID namespace), then deletes that uid's entries at the top of /tmp and /dev/shm without following symlinks, and recreates the slot directory. If processes remain after 5 seconds the slot is quarantined until the container restarts and is not handed out again.

Each child sets RLIMIT_NPROC to 512 and RLIMIT_CORE to 0 before exec. Core dumps would contain tokens. There is no address-space limit: RLIMIT_AS breaks Node; an operator can set the container memory limit with an override. Without user namespaces the process count is per uid on the whole host, so two boxes on one machine share the 512 processes of a slot with the same number.

Child stderr is logged at info as stdio_box.child.stderr with profile, generation, and slot. Lines are masked before they are written: every profile field value of 6 characters or more becomes ***, then well-known token shapes (GitHub, GitLab, Slack, cloud keys, Atlassian, Notion, Yandex OAuth, Tavily). Lines longer than 2000 characters are cut. A token bucket keeps 50 lines per second with a burst of 200; the rest increment stderr_suppressed_total and, once a second, produce a warning suppressed N stderr line(s).

Children share the container network. Third-party code can open connections to other packages' facades on the keystone-mcp-upstreams network and to addresses outside the host. Those facades still require their own bearer; the network path itself is open.

Storage

Profiles and bindings live in <BOX_DATA_DIR>/box.sqlite3 (in the image, /data/box.sqlite3). The file is created mode 0600. The database uses WAL. Tables:

Table

What it holds

meta

schema version, salt, key check, revision

profiles

id, variant, non-secret fields, state, check result

profile_secrets

secret fields, AES-256-GCM, one row per version

bindings

who receives which profile, and the role order

form_links

SHA-256 of a form token, never the token

audit

calls and admin actions, kept for BOX_AUDIT_RETENTION_DAYS (30)

status

one JSON snapshot written by the running wrapper

BOX_STORE_KEY is the key generated for the package (secrets.token_urlsafe(32)). The store derives separate 32-byte keys with HKDF-SHA256 for secrets, the key check, form CSRF, and audit subject hashes. Secret fields of one profile are a single JSON object sealed with AES-256-GCM. The nonce is random. The additional data is the profile id and the secret version, so a row cannot be moved under another profile or version.

What this protects: a copy of the data volume without BOX_STORE_KEY. The secret fields cannot be opened.

What this does not protect:

  • Non-secret fields (the address, the account name) are stored in the clear, as are profile ids, bindings, and audit rows.

  • The key is in the container environment. docker inspect shows it. Anyone who can read the running container can read the store.

  • Encryption does not hide which profile was used, only the secret values.

A database written by a newer build is not opened and is not modified. An internal /healthz then answers 503. A request that arrived through the public domain (X-Forwarded-Host) gets the same status code and an empty body.

Box tools

An administrator configures the box from chat. These ten tools are part of the server's catalog. Secret values never travel in either direction: a tool result, a refusal, a log line, and an audit row never contain one. The person who owns the account types secrets on the one-time form.

Tool

What it does

creds_providers

The provider in this box: variants, which fields are secret, server.installed_key, and isolation.

creds_profiles

Profiles, their non-secret fields, last check, bindings, and the instance if one is running.

creds_bindings

Every binding in the order a call is resolved: user, then role by position, then default. Each row includes the profile's state.

creds_profile_create

Create a profile. When the variant has secret fields, the result is a one-time link.

creds_profile_rotate

Change non-secret fields, or issue a new link so the secrets can be entered again.

creds_profile_delete

Delete a profile that nothing is bound to, and stop its instance.

creds_bind

Bind user:<email>, user:svc:<name>, role:<role>, or default to a profile.

creds_unbind

Remove one binding. Role positions below it move up.

creds_whoami

Which profile this caller would use, which binding won, and which other role bindings were skipped.

creds_check

Run the variant's checks and start the instance. Both have to succeed.

creds_profile_create and creds_profile_rotate accept only non-secret fields. Naming a secret field, in any letter case, is refused and the value is discarded. A field of type url is checked before anything is written. Changing a URL, or any field that a check places in a URL or a header, requires the secrets to be entered again on the form. Stored secrets are never sent to a new address.

Bindings and their order

The box picks one profile for a call, in this order:

  1. user:<sub> — the subject from the signed identity, lowercased. user:svc:backend is a service account.

  2. role:<name> bindings whose role is in the call, walked in the order an administrator set. The first hit wins.

  3. default.

There is no error when several roles match. The administrator sets the order with credsctl bind <subject> <profile> --position N. A new role binding without --position goes to the end. --replace --position N moves an existing role binding and can point it at another profile. Removing a role binding closes the gap so positions stay 1, 2, 3 with no holes. user: and default have no position.

Profile state does not affect the choice. If the first match needs its secrets entered again, the call uses that profile and fails, instead of quietly using the next account.

creds_bindings is that same order, with the profile state on every row. user is sorted by subject. role is position 1, 2, 3 with no holes. default is last, or null when it is not set. creds_whoami says which row won (tier and binding, including position for a role) and lists the caller's other role rows under not_used, because they were lower in the order. hint names the binding to move up.

To put a role binding somewhere else, call creds_bind again with replace: true and the new position. The reply for a role includes order, the full role list after the change. A new role binding without position goes to the end. position on user: or default is refused. user: and default have no position.

The box does not check roles. Whoever the policy lets call <ns>_creds_* manages everyone's accounts in this box. That is the administrator's decision and their responsibility. The box does not keep an allow-list of administrators.

Do not give <ns>_* to an ordinary role. A pattern is matched against the full published name, so billing_* covers every tool of namespace billing, including billing_admin_report. The same pattern on this box also covers <ns>_creds_profile_delete and the other account tools. List the tool names the role should have. stdio-box policy-example --namespace <ns> prints a reader role and a writer role that do that, and a <ns>_box_admin role whose only grant is <ns>_creds_*. keystonectl prints that file and does not merge it into the policy.

The five tools that change accounts (creds_profile_create, creds_profile_rotate, creds_profile_delete, creds_bind, creds_unbind) are listed in requires_human_identity. Only a signed-in person may change accounts. An API key or a service account must not: those calls act for a machine, and a machine must not be able to rebind the accounts everyone else uses. The door enforces that list. The box does not.

Moving or reinstalling a box

Copy the store volume and use the same BOX_STORE_KEY. The process still starts if the key is different: bindings and non-secret fields stay readable, secret fields do not, and those profiles are reported as needs_reentry. An internal /healthz answers 200 with "status": "degraded" and "store": "key_mismatch". A public-domain request gets 200 and {}. Enter the secrets again, or re-encrypt with the old key (below). sub_hash values in logs from before the key change do not match values from after it.

Backups and the key

Before a schema migration the store copies the database to <BOX_DATA_DIR>/backup/ and keeps the three newest copies. Keep a backup of the volume separate from BOX_STORE_KEY. The ciphertext is only a secret from someone who does not have the key. The key is also visible in the container environment, so a backup that includes both the volume and the key (or docker inspect output) is not protected.

The cache volume does not hold profiles. It does not need the same treatment.

Changing the key

credsctl store rekey --old-key-file <path> (or - for stdin) opens every secret row with the old key and seals it with the current BOX_STORE_KEY. Rows already sealed with the current key are left as they are. If you no longer have the old key, enter the secrets again; the first successful write adopts the new key, and profiles you have not rewritten stay needs_reentry.

Entering secrets

There are three ways to put an account into the box:

  1. A one-time form in the browser, at a link the administrator asked for.

  2. credsctl profile import, from a mode 0600 env file or from stdin.

  3. OAuth done by the person, in a later version. This version has no such flow.

The form is not a login. A link that expires in 15 minutes and works once is enough: the person who opens it types the token, and the box checks that token with the provider before it stores anything. There is no other sign-in on the box.

Request

Response

GET /creds/<token> while the token is live

200, an HTML form: provider and variant titles, profile id, non-secret fields read-only, type=password autocomplete=off inputs for the secret fields, a hidden csrf. Sets cookie __Host-box_form=<nonce>; Secure; HttpOnly; SameSite=Strict; Path=/.

GET with an unknown, expired, or used token

404, the same page: "This link is not valid. Ask the administrator for a new one."

POST /creds/<token>, application/x-www-form-urlencoded, at most 16 KiB

Checks below. Success is 200: "Saved. <title> says this account is <identity>. You can close this page."

POST when the provider rejects the values

422, the same form, "<title> did not accept these values (HTTP <code>).", and one more attempt. The fifth failure is 410.

POST when the provider cannot be reached

Values are stored, state ready, last_check_ok null. 200: "Saved, but <title> could not be reached to check them."

Any other method

405

POST is accepted only in this order: the token is live, Origin is the origin of BOX_PUBLIC_BASE_URL (Origin: null is rejected), the cookie is present, csrf matches, the body contains only csrf and the variant's secret fields (each non-empty, within max_length, no line break), and the box has seen fewer than 60 such posts in the last minute. A failure before the provider check is 400, "The form could not be accepted. Open the link again." An unknown token is 404. The rate limit is 429.

GET does not use the link up. A link is used up when the values are saved, or after five rejected attempts. One profile has one live link; a new link replaces the previous one. The lifetime is BOX_FORM_TTL_SECONDS (default 900, allowed 60–3600).

Every /creds/ response sends Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none', X-Frame-Options: DENY, Referrer-Policy: same-origin, Cache-Control: no-store, and X-Content-Type-Options: nosniff. The page has no script and no external resources. Referrer-Policy is same-origin, not no-referrer: with no-referrer a browser sends Origin: null on the form post, and the origin check would reject every real submission.

nginx in front of the box sets Host to 127.0.0.1 and passes the public name in X-Forwarded-Host. /creds/ accepts Host 127.0.0.1, localhost, or the host of BOX_PUBLIC_BASE_URL. If X-Forwarded-Host is present it must be that public host, otherwise the response is 421.

When BOX_PUBLIC_BASE_URL is https://public-domain-pending.invalid (--defer-public), the form cannot be opened. Enter the account with credsctl profile import only.

A check URL has to be https unless BOX_ALLOW_HTTP_URLS=true. Its host has to match the variant's allowed_hosts when that list is set, and BOX_ALLOWED_URL_HOSTS when that list is set. Every address the name resolves to has to be public: not loopback, not a private network, not link-local, not a unique-local IPv6 address, and not carrier-grade NAT. BOX_ALLOW_PRIVATE_URLS=true turns that address check off for a provider that lives on an internal network; set it only on purpose. The check connects to the address it just resolved and keeps the original name for Host and TLS.

With TLS=manual, keystonectl's nginx does not write an access log for public paths, but your own proxy will record /creds/<token> unless you turn that log off for /creds/.

location ^~ /creds/ {
    access_log off;
    proxy_pass http://127.0.0.1:8081;
}
handle /creds/* {
    log {
        output discard
    }
    reverse_proxy 127.0.0.1:8081
}

Rotation writes the new values only after the check is not a refusal. Until then the previous version stays as it was.

credsctl

credsctl is the operator CLI. In the container it runs as uid 10001. Started as root, it drops to that uid before it opens the database. Every command accepts --json. Field values are never printed. Each command that changes the store writes an audit row event=admin, sub=cli:credsctl.

credsctl profile list
credsctl profile delete <id>
credsctl profile import <id> [--variant <name>] --from-file <path|-> \
    [--if-absent] [--ignore-unknown] [--no-check] [--description <text>]
credsctl bind <subject> <profile> [--replace] [--position N]
credsctl unbind <subject>
credsctl bindings
credsctl store status
credsctl store rekey --old-key-file <path|->
credsctl audit [--since 24h] [--profile <id>] [--event <name>]
credsctl status

profile import reads an env file (NAME=value, blank lines and # comments skipped, quotes left as typed). The file must be mode 0600 unless the path is - (stdin). Without --variant, the variant is the one whose fields are exactly the non-empty provider fields in the file. A file that matches none (or more than one) is variant_ambiguous. Keys that are not provider fields are unknown_fields unless --ignore-unknown is set, in which case those names are skipped and listed as ignored. --if-absent on a profile that already exists exits 4 and writes nothing. Import does not bind the profile; bind is a separate command. Without --no-check, the variant's HTTP checks run before anything is written. A refusal exits 1 and leaves the store unchanged. If a check cannot be reached, the import still exits 0 (check: unreachable) and last_check_ok stays null. --no-check stores the values without a check (check: skipped, last_check_ok null).

bindings prints user, then role with positions, then default — the same order a call is resolved in.

store status reports key_state, schema_version, how many profiles there are, and how many are needs_reentry. status prints the wrapper's status row. Until the wrapper is running it is {"status": "not running"}.

Exit codes:

Code

Meaning

0

Done. An import whose check could not reach the provider is still 0 (check: unreachable).

1

The provider rejected the values. Nothing was written.

2

Usage.

3

Invalid input: unknown_fields, missing_fields, variant_ambiguous, file mode, a bad id, subject, or position, profile_in_use, binding_exists.

4

--if-absent and the profile already exists.

5

The store cannot be opened, including a database newer than this program (store_schema_newer).

--json prints one object: {"ok", "code", "error": {"id", "message"}, "result"}. code is the exit code. error is null on success.

Moving an account out of a deploy env file, then binding it as the default. <service> is the service field of the container whose role is mcp, from keystonectl service status <name> --json (result.services[].containers[]). Do not build that name from the package name.

docker compose exec -T <service> \
  credsctl profile import atlassian/shared --from-file - --if-absent --ignore-unknown \
  < jira.env
docker compose exec -T <service> credsctl bind default atlassian/shared

Releasing

IMAGE in the repository root is the pinned reference ghcr.io/keystone-codes/stdio-box:<version>@sha256:<digest>. Until the first release it has no digest line. Package manifests pin that same reference on containers.mcp.image. A package that is not part of the release keeps its own image.

  1. Run the Release image workflow (workflow_dispatch) with a version that matches pyproject.toml. The workflow tests, builds linux/amd64 and linux/arm64, and pushes ghcr.io/keystone-codes/stdio-box:<version> with provenance and an SBOM.

  2. It runs scripts/pin_digest.py with that image reference and opens a pull request. The pull request writes the reference into IMAGE and into containers.mcp.image of each package whose image is still the zero digest or a previous stdio-box release. The description lists those packages and their versions.

  3. Merging that pull request to main runs Tag release. It checks the registry digest against IMAGE, tags v<version>, and for each manifest whose image equals IMAGE tags <name>/v<version> when that tag does not exist yet. An existing package tag is left where it is. The same package version with different contents fails the workflow, because a tag is immutable. The GitHub release records the image digest and the package tags.

The next change to a provider is a new image version and a new patch version of that package.

Development

Requires Python 3.12, uv, and Docker.

uv sync --frozen
uv run ruff check .
uv run ruff format --check .
uv run pytest -q
docker build .

pytest -q includes the image-layout test, which builds an image from the echo provider under tests/data/packages. pytest -q -m "not integration" skips it.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    A universal MCP server for registering internal, external, and OpenAPI-based APIs as MCP tools. It exposes them to MCP clients via Streamable HTTP and provides admin portal, RBAC/session auth, credential injection, and audit logging.
    Academic Free v1.1
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables MCP tool calls with strict schema validation and stdio isolation, while providing a security gateway for tool-level authorization, streaming PII redaction, and model failover routing.
    -