Repository navigation
feat(mcp): scoped, expiring tokens for the HTTP MCP server (#3) - #259
Open
bottercode wants to merge 5 commits into
Open
bottercode wants to merge 5 commits into
bottercode wants to merge 5 commits into
Conversation
…okes the old token The HTTP server read --token-file once at startup. Writing a new token there left the old one working until a restart, and the new one was refused. The file is now checked with a stat per request, as the proxy does for tokens.json, and read again when it changes. Rotating takes effect at the next request. While the file is missing or empty, every request is refused, and the server won't start without a readable token. Part of modem-dev#3.
The store and scope rules for scoped HTTP MCP tokens (modem-dev#3), and the checks that enforce them. Nothing grants a scope yet: until the HTTP server does (the next commit), every operation runs unscoped, as before. - core/mcp-tokens.mjs: $BOXERY_HOME/mcp-tokens.json (0600), each token stored as a SHA-256 of its secret, with a label, an optional expiry, a scope (tools and actions, hosts, VM name patterns, admin) and the VMs created through it. Re-read when it changes, written under a lock. Tools are denied unless listed, and admin actions need admin. - core/mcp-auth.mjs: the active grant rides in async context, like the current host. It is checked where a VM name finally reaches ssh (sshRun, vmProblem, applyGrants, sync's git push, the desktop tunnel, both vm_copy endpoints) and before every host command (hostRun, by command: listings, one VM, new/fork, otherwise admin). So no way of naming a VM (vm, VM@SNAP, HOST/VM:path, fork targets, generated names) gets around it. runTool checks the tool and action first, for a clear message, and vm_info filters listings by scope. - A VM created through a token is recorded with its ssh host key and stays the token's only while the key matches, so a VM someone else later makes under the name isn't. Backends that manage host keys themselves (boat, OpenShell) fall back to the name alone. - Once a process calls enforce() (the HTTP server will), an operation outside any grant is refused, so a code path that loses its context fails closed. stdio MCP, pi and the CLI never call it. Tests: the store, the scope rules, the fail-closed context, and a guard that fails when a new ssh or host-command site isn't checked. Part of modem-dev#3.
…oken boxery-mcp --http accepts scoped tokens from the store besides the unscoped BOXERY_MCP_TOKEN / --token-file, and can start with scoped tokens alone. It warns when it has an unscoped token. - Each request is checked: an unknown or removed token gets 401 with WWW-Authenticate: Bearer error="invalid_token", and an expired one is told so. - A session belongs to the token, and Tailscale user, that opened it, following the MCP security best practices: another can't use or end it. - Each session's work runs inside its grant, and the server calls enforce(), so an operation that loses its grant is refused. Ending a session releases its leases inside its grant. - tools/list shows only the tools the token allows, and the initialize instructions say what the token allows. Unscoped sessions see exactly what they did before. Tests (offline, the stub host and fake VM): - A token without a VM is refused 17 ways of reaching one: vm, use, VM:path and HOST/VM:path copies, VM@SNAP, fork, rm/snap/revert/stop, vm_jobs, vm_grant, another host, admin actions. Only listings reach the host, and nothing runs in the VM. - A created VM is the token's until its host key changes, and rm forgets it. - --vm, --read-only and --tools behave as described. - Session ownership, revocation and expiry. Part of modem-dev#3.
boxery mcp-token new LABEL [--vm PATTERN]... [--host NAME]... [--tools TOOL[:ACTION],... | --read-only] [--admin] [--ttl 30d|12h|never] prints a token once. ls shows each token's scope, expiry and created VMs (never a secret). rm revokes one at its next request. It runs on this machine, before any host is selected, like setup and help. Agents get no tool for it: minting MCP access is the user's decision (test/parity.test.mjs). Docs: - docs/mcp.md: a Scoped tokens section. - docs/security.md: the HTTP MCP limit now points to scoped tokens. - ARCHITECTURE.md: the module table, a design decision (tokens on the controller, checked where boxery acts), and modem-dev#3 removed from the roadmap. - The generated CLI reference and help goldens. Closes modem-dev#3.
Author
|
@benvinegar can you review this? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3.
Problem
boxery-mcp --httphad a single bearer token, checked once atmcp/boxery-mcp.mjs. Whoever held it could call every tool on every VM of every registered host. To confirm, I ranvm_bashon a VM the client never created, offline with a stub host. There was no expiry, and the only way to revoke the token was to rotate it. Rotating didn't work either:--token-filewas read once at startup, so the old token kept working until the server restarted.What this does
1.
--token-fileis read again when it changes (fix(mcp): …). The file gets astatper request and is re-read when it changes, asboxery-proxydoes fortokens.json. Rotating it revokes the old token at the next request. While the file is missing or empty, every request is refused, and the server won't start without a readable one.2. Scoped tokens (
boxery mcp-token new|ls|rm, documented indocs/mcp.md#scoped-tokens):--tools TOOL[:ACTION],…, or--read-only(vm_read,vm_screenshot,vm_info). Anything not listed is refused.--admin:build-base,host_add,host_rm, pool leases, and any other host-wide command.--hostby default, or each--host NAME.--vmpatterns, plus those the token created itself.lssays so.--ttl), ornever.~/.boxery/mcp-tokens.json(0600), whichvm_copyandvm_grantalready refuse to read.Design choices (please push back)
boxery-hostas the issue suggested. The HTTP server is a controller-side front-end that can drive several hosts, and the host side shouldn't import the core.vm,VM@SNAP,HOST/VM:path, fork targets, generated names. Socore/mcp-auth.mjschecks the scope again where a name finally reaches ssh (sshRun,vmProblem,applyGrants, sync's git push, the desktop tunnel, bothvm_copyendpoints), and before every host command (hostRun, by command).runToolchecks the tool and action first, only for a clearer message. A test fails when a new ssh or host-command call site appears without a check.enforce(), so an operation that lost its grant is refused. stdio, pi and the CLI never call it, so they're unchanged.401withWWW-Authenticate: Bearer error="invalid_token". I didn't implement the OAuth 2.1 flow: the spec makes authorization optional, and it would be a lot of machinery for a server reached over a tailnet.tools/listshows only the tools the token allows, and theinitializeinstructions add one line describing the scope. Refusals read… is outside this token's scope (token 'x'); ask the user for a token that covers it, the same whether or not the VM exists.vm_info list,imagesandhostsare filtered too. Other output, such as "name taken" or a host error, can still reveal that a name exists, but not let the token use it (documented).BOXERY_MCP_TOKEN/--token-filetoken keeps full access, and the server logs a warning about it. The server can now start with scoped tokens alone.Commits
fix(mcp): re-read--token-filewhen it changes.style(mcp): whitespace.feat(core): token store and scope rules (core/mcp-tokens.mjs), and the checks where boxery acts (core/mcp-auth.mjs). Nothing is scoped yet, so behaviour doesn't change.feat(mcp): the HTTP server accepts scoped tokens, and sessions belong to their token.feat(cli):boxery mcp-token, plus docs:docs/mcp.md,docs/security.md,ARCHITECTURE.md(a design decision, Scoped, expiring tokens for the HTTP MCP server #3 removed from the roadmap), the CLI reference and help goldens.Each commit passes typecheck and the related tests on its own. I can split them into separate PRs if you prefer.
Tests
All offline, using the existing stub host and fake VM.
test/mcp-tokens.test.mjs:boxery-hostcommands, and the name-only backends match those declaringbackend_platform_host_key.test/mcp.test.mjs, server with scoped tokens only:vm,use,VM:pathandHOST/VM:pathcopies,VM@SNAP, fork, rm/snap/revert/stop,vm_jobs,vm_grant, another host, admin actions. Only listings reach the host, and nothing runs in the VM.rmforgets it.--vm,--read-onlyand--toolsbehave as documented.--token-filerotation.Run locally on macOS:
test/lint.shexceptshellcheck(not installed here; no shell files changed).npm test: every file passes when run alone. A full parallel run on this machine had a few timing failures (copy cancellation, background jobs) while it had about 56 MB of free memory, and they pass when rerun alone.npm run test:shell: every file passes exceptcommon/controller-local.sh, which hangs on this machine at theattach w1 boguspty case. Unmodifiedmasterhangs at the same place, and the 75 cases before it pass identically.Not in this PR (follow-ups)
vm_records, which would remove the name-only fallback on boat and OpenShell.