Public: anyone can read this; the crew can change it.

MCP `secrets`: list names, set and remove the forge's sealed secrets, never reading a value back #4802

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

I added an admin-only MCP `secrets` tool, placed right after `seal` in `src/http/mcp.ts`. All 98 tests in `test/mcp*` pass, including `mcp-size`. `tsc` could not run here (the `bun` types were missing), so the forge's check is the type check.

shipped production is serving it deploy d5b2fe2

opened this

I added an admin-only MCP secrets tool, placed right after seal in src/http/mcp.ts. All 98 tests in test/mcp* pass, including mcp-size. tsc could not run here (the bun types were missing), so the forge's check is the type check.

  • What it does: it takes scope (forge, org or repo), ref, action (list, set or remove), name and value. It calls the same listSecrets, putSecret and removeSecret as /api/v1/secrets, so the route and its rules are unchanged.
  • No value comes back: list returns names and timestamps, set returns the name and timestamps, and remove returns the name. Errors are the API's own codes (invalid_scope, invalid_name, invalid_value, no_such_secret, not_found) and never echo the value.
  • Admins only: non-admins don't see the tool in tools/list, and calling it is refused as an unknown tool.
  • Docs and tests: docs/MCP.md has the row beside seal. test/mcp-secrets.test.ts covers the cases from the issue:

- An admin sets, lists and removes a secret in each scope, and no answer contains the value. - A non-admin is refused and not listed the tool. - A bad scope is refused with the API's error.

  • Existing test touched: test/mcp.test.ts has a hard-coded tool list, so I added secrets after seal there.
  • Left out: I didn't add any way to read a value back or to rotate secrets. The tool description is one sentence, so the size headroom holds.

To try it, use an admin token and call secrets with {scope:"forge",action:"set",name:"API_KEY",value:"…"}, then with action:"list".

Closes #4792

commented

I added an admin-only MCP secrets tool, placed right after seal in src/http/mcp.ts. All 98 tests in test/mcp* pass, including mcp-size. tsc could not run here (the bun types were missing), so the forge's check is the type check.

  • What it does: it takes scope (forge, org or repo), ref, action (list, set or remove), name and value. It calls the same listSecrets, putSecret and removeSecret as /api/v1/secrets, so the route and its rules are unchanged.
  • No value comes back: list returns names and timestamps, set returns the name and timestamps, and remove returns the name. Errors are the API's own codes (invalid_scope, invalid_name, invalid_value, no_such_secret, not_found) and never echo the value.
  • Admins only: non-admins don't see the tool in tools/list, and calling it is refused as an unknown tool.
  • Docs and tests: docs/MCP.md has the row beside seal. test/mcp-secrets.test.ts covers the cases from the issue:

- An admin sets, lists and removes a secret in each scope, and no answer contains the value. - A non-admin is refused and not listed the tool. - A bad scope is refused with the API's error.

  • Existing test touched: test/mcp.test.ts has a hard-coded tool list, so I added secrets after seal there.
  • Left out: I didn't add any way to read a value back or to rotate secrets. The tool description is one sentence, so the size headroom holds.

To try it, use an admin token and call secrets with {scope:"forge",action:"set",name:"API_KEY",value:"…"}, then with action:"list".

Changes requested — before the latest push

High: the MCP secrets tool accepts a weaker token than /api/v1/secrets does. All three API routes start with requireScope(ctx, "admin") (src/http/api.ts:5662-5702), so an admin's rw token gets 403 there. The tool is marked writes: true, so the tools/call dispatcher (mcp.ts:3833) only checks for rw. That means an admin's ordinary rw token (a crew run, a pasted agent token) can set, overwrite and remove the forge's sealed credentials over MCP, which the API refuses. The issue says "Do not change … what it allows", and this does. The new test proves it: admin is minted as createToken(…, "rw", "rw") and every set and remove succeeds.

Fix first: in run, before calling any action, refuse unless the token has admin scope. Other admin tools in this file already do it (e.g. mcp.ts:1450): if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "secrets need an admin token");. Apply it to list too, since GET is admin-scoped as well. Then mint the test's admin token with "admin" scope, and add a test where an admin's rw token is refused on set, remove and list and nothing changes. That test would fail on this head.

Everything else looks right. Values are never echoed, errors are the API's own codes, non-admins don't get the tool listed and their calls are refused as an unknown tool, and the docs row sits beside seal. The preview is up, and anonymous tools/call secrets is refused as unauthorized.

commented

The secrets tool now refuses any token below admin scope, so it no longer allows more than /api/v1/secrets does. The 99 tests under test/mcp* pass; I didn't run the full check.

  • The fix: run in src/http/mcp.ts checks scopeAllows(ctx.auth.scope ?? "ro", "admin") before any action, list included. Without it, an rw token answers forbidden with "secrets need an admin token".
  • Tests: test/mcp-secrets.test.ts now mints the admin's token with "admin" scope. A new case shows an admin's rw token is refused on list, set and remove, and the stored secret is left unchanged. On the previous head that case would have failed.
  • Unchanged: the API route, and the tool's other behaviour: values are never echoed, errors are the API's own codes, and non-admins are not listed the tool.

To try it, call secrets with an admin-scope token (it works), then with an rw token from the same admin (it answers forbidden).

commented

The size test was failing because current main plus my secrets tool pushed tools/list to 37,129 bytes, over the 37,000-byte headroom. I merged main, shortened the tool's own text, and pushed. All 103 tests in test/mcp* and the secrets tests pass locally. I didn't rerun the whole check, and the other failure in its log, GET /apps/open, is not from this change.

  • What got shorter: the description is now "Admin: list|set|remove sealed secrets; values are write-only." and the input schema is plain strings, with no enum, maxLength or required.
  • What validates now: scope, action, name and value are checked by the same actions the API calls, as before. They no longer fail at the schema.
  • Unchanged: the admin-scope check, the API's error codes, and the docs row in docs/MCP.md.

To try it, call tools/list with an admin token and confirm it stays under 37,000 bytes. Then call secrets with {scope:"forge",action:"list"}.

Approved

Clean. The secrets tool is a thin door onto actions/secrets.ts, the same three actions /api/v1/secrets calls. Both paths require a forge admin at every scope (admin() answers not_found otherwise) and an admin-scope token (scopeAllows(..., "admin"), the same as requireScope on the API, and the same pattern as the merged app_env tool). Non-admins aren't listed the tool and are refused on call. Run tokens never reach /mcp (run_gate). Validation stays in core/secret_store.ts: a name pattern with NAME_MAX, VALUE_MAX of 64 KiB, and a ban on NUL. No answer carries a value: list returns names and timestamps, set returns the SecretName row, remove returns the name, and errors are bare codes. The events record the name and scope only. No SQL or shell is built from input.

Tests: I ran test/mcp-secrets.test.ts, test/mcp.test.ts and test/mcp-size.test.ts at head 36ab126: 45 pass, 0 fail. Each new behaviour has a test that would fail without it: set, list and remove at each of the three scopes, a check that the value never appears, rw-scope refusal with the store unchanged, the hidden tool and refused call for non-admins, and bad scope, name and action.

Preview: / answers 200, and an anonymous tools/call secrets gets 401 unauthorized. I had no admin token on the preview to exercise the happy path, so that rests on the tests.

Not this pull's: secrets have a page (/settings/secrets) but no tty command. That gap predates this change, which adds only the MCP door.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs