feat(admin): local-only stdio MCP server to mint scoped service-account tokens (dispatch#67)
dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall: Gitea's own token-creation and admin-user endpoints reject token auth outright -- POST /users/{u}/tokens returns 'auth required', POST /admin/users returns 'token does not have at least one of required scope(s), required=[write:admin]' -- for ANY existing token, by design. An existing token cannot mint another token or create an account; the only way through either endpoint is real basic auth as an actual site admin. That is exactly what a sandboxed agent session cannot safely hold. internal/adminbootstrap: the core bootstrap, HTTP-only, fully testable against a fake transport (5 tests, no network). Given real admin username/password, it creates the target account (or resets its password if it already exists -- deliberately unconditional, gated behind a required ConfirmServiceAccount flag since that reset is only safe for a dedicated bot account), mints it a scoped token by authenticating AS that account (Gitea requires this specifically -- an admin cannot mint on another account's behalf), grants it read-only collaborator access on named repos, and stores the result via a SecretStore -- never returning the token or either password to the caller. internal/localtools: the MCP tool wrapper (mint_service_account_token) and the real op-CLI-backed SecretStore (token passed via stdin, never argv, per the estate's own secret-handling rule). Deliberately its OWN package, not internal/tools -- this capability is far too dangerous to risk it ever being wired into RegisterAll and reachable from the shared HTTP gitea-mcp server, even behind OIDC. internal/mcp/stdio.go: a stdio JSON-RPC transport (one message per line, standard MCP stdio framing) for the existing registry.Tool interface -- the only transport gitea-mcp had before this was HTTP (server.go), which a sandboxed session can reach but a real admin password never should be piped through. cmd/gitea-admin-mcp: the binary. Meant to be spawned by a LOCAL Claude Code session (.mcp.json stdio server) or run by hand, via 'op run --env-file ~/.op-env' so admin credentials arrive just-in-time and never touch a sandboxed session. 11 new tests total, all passing under -race. Nothing in cmd/gitea-mcp (the shared HTTP server) changed.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// Package localtools holds MCP tools that must never be reachable from the
|
||||
// shared HTTP gitea-mcp server (RegisterAll, cmd/gitea-mcp). Each tool here
|
||||
// carries a capability too dangerous to expose network-side, even behind
|
||||
// OIDC — mint_service_account_token resets whatever password its target
|
||||
// account has. Only cmd/gitea-admin-mcp, a separate local-only stdio binary,
|
||||
// imports this package.
|
||||
package localtools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
// bootstrapper is the one method this tool needs — narrow so tests inject a
|
||||
// fake instead of a real adminbootstrap.Bootstrapper.
|
||||
type bootstrapper interface {
|
||||
Run(ctx context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error)
|
||||
}
|
||||
|
||||
type MintServiceAccountToken struct {
|
||||
b bootstrapper
|
||||
}
|
||||
|
||||
func NewMintServiceAccountToken(b bootstrapper) *MintServiceAccountToken {
|
||||
return &MintServiceAccountToken{b: b}
|
||||
}
|
||||
|
||||
func (t *MintServiceAccountToken) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "mint_service_account_token",
|
||||
Description: "Create (or reset the password of) a dedicated Gitea " +
|
||||
"service/bot account, mint it a scoped access token, optionally " +
|
||||
"grant it read-only collaborator access on named repos, and store " +
|
||||
"the token via 1Password. Requires confirm_service_account=true: " +
|
||||
"this resets whatever password the target account currently has, " +
|
||||
"which is only safe for a dedicated bot account, never a real " +
|
||||
"person's login.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"username":{"type":"string"},
|
||||
"email":{"type":"string"},
|
||||
"full_name":{"type":"string"},
|
||||
"scopes":{"type":"array","items":{"type":"string"}},
|
||||
"token_name":{"type":"string"},
|
||||
"secret_name":{"type":"string","description":"name to store the minted token under"},
|
||||
"grant_read_repos":{"type":"array","items":{"type":"string"},"description":"owner/repo strings"},
|
||||
"confirm_service_account":{"type":"boolean"}
|
||||
},
|
||||
"required":["username","email","scopes","token_name","secret_name","confirm_service_account"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type mintArgs struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
TokenName string `json:"token_name"`
|
||||
SecretName string `json:"secret_name"`
|
||||
GrantReadRepos []string `json:"grant_read_repos"`
|
||||
ConfirmServiceAccount bool `json:"confirm_service_account"`
|
||||
}
|
||||
|
||||
func (t *MintServiceAccountToken) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args mintArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
|
||||
repos := make([]adminbootstrap.RepoRef, 0, len(args.GrantReadRepos))
|
||||
for _, r := range args.GrantReadRepos {
|
||||
owner, name, ok := strings.Cut(r, "/")
|
||||
if !ok || owner == "" || name == "" {
|
||||
return nil, fmt.Errorf("grant_read_repos entry %q must be owner/repo", r)
|
||||
}
|
||||
repos = append(repos, adminbootstrap.RepoRef{Owner: owner, Name: name})
|
||||
}
|
||||
|
||||
result, err := t.b.Run(ctx, adminbootstrap.Request{
|
||||
Username: args.Username,
|
||||
Email: args.Email,
|
||||
FullName: args.FullName,
|
||||
Scopes: args.Scopes,
|
||||
TokenName: args.TokenName,
|
||||
SecretName: args.SecretName,
|
||||
GrantReadRepos: repos,
|
||||
ConfirmServiceAccount: args.ConfirmServiceAccount,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package localtools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// This tool lives in its own package, never internal/tools, on purpose: it
|
||||
// resets whatever password its target account has, which is far too
|
||||
// dangerous a capability to risk it ever being wired into RegisterAll and
|
||||
// exposed on the shared HTTP gitea-mcp server. Only cmd/gitea-admin-mcp (a
|
||||
// separate, local-only, stdio binary) imports this package.
|
||||
|
||||
type fakeBootstrapper struct {
|
||||
gotReq adminbootstrap.Request
|
||||
result adminbootstrap.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBootstrapper) Run(_ context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error) {
|
||||
f.gotReq = req
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestMintServiceAccountToken_ParsesArgsAndCallsBootstrapper(t *testing.T) {
|
||||
fb := &fakeBootstrapper{result: adminbootstrap.Result{
|
||||
Username: "dispatch-bot", Created: true,
|
||||
SecretRef: "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token",
|
||||
}}
|
||||
tool := localtools.NewMintServiceAccountToken(fb)
|
||||
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"username": "dispatch-bot",
|
||||
"email": "dispatch@d-ma.be",
|
||||
"scopes": []string{"read:repository", "read:issue"},
|
||||
"token_name": "dispatch-ci-readonly",
|
||||
"secret_name": "gitea-dispatch-bot-ci-readonly",
|
||||
"grant_read_repos": []string{"mathias/cobalt-dingo"},
|
||||
"confirm_service_account": true,
|
||||
})
|
||||
|
||||
out, err := tool.Call(context.Background(), args)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dispatch-bot", fb.gotReq.Username)
|
||||
assert.Equal(t, []string{"read:repository", "read:issue"}, fb.gotReq.Scopes)
|
||||
assert.Equal(t, true, fb.gotReq.ConfirmServiceAccount)
|
||||
require.Equal(t, []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}}, fb.gotReq.GrantReadRepos)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result["SecretRef"])
|
||||
// The tool's own output must never carry a raw token or password field --
|
||||
// adminbootstrap.Result structurally can't (it has none), which this pins.
|
||||
_, hasToken := result["Token"]
|
||||
_, hasPassword := result["Password"]
|
||||
assert.False(t, hasToken)
|
||||
assert.False(t, hasPassword)
|
||||
}
|
||||
|
||||
func TestMintServiceAccountToken_RejectsMalformedRepoRef(t *testing.T) {
|
||||
fb := &fakeBootstrapper{}
|
||||
tool := localtools.NewMintServiceAccountToken(fb)
|
||||
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"username": "dispatch-bot",
|
||||
"email": "dispatch@d-ma.be",
|
||||
"scopes": []string{"read:repository"},
|
||||
"token_name": "x",
|
||||
"secret_name": "x",
|
||||
"grant_read_repos": []string{"not-owner-slash-repo"},
|
||||
"confirm_service_account": true,
|
||||
})
|
||||
|
||||
_, err := tool.Call(context.Background(), args)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "owner/repo")
|
||||
}
|
||||
|
||||
func TestMintServiceAccountToken_PropagatesBootstrapperError(t *testing.T) {
|
||||
fb := &fakeBootstrapper{err: assert.AnError}
|
||||
tool := localtools.NewMintServiceAccountToken(fb)
|
||||
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"username": "dispatch-bot",
|
||||
"email": "dispatch@d-ma.be",
|
||||
"scopes": []string{"read:repository"},
|
||||
"token_name": "x",
|
||||
"secret_name": "x",
|
||||
"confirm_service_account": true,
|
||||
})
|
||||
|
||||
_, err := tool.Call(context.Background(), args)
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMintServiceAccountToken_Descriptor(t *testing.T) {
|
||||
tool := localtools.NewMintServiceAccountToken(&fakeBootstrapper{})
|
||||
d := tool.Descriptor()
|
||||
assert.Equal(t, "mint_service_account_token", d.Name)
|
||||
assert.NotEmpty(t, d.Description)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package localtools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpSecretStore implements adminbootstrap.SecretStore against the local
|
||||
// `op` (1Password) CLI. Run is injectable for tests; nil defaults to
|
||||
// actually invoking `op`.
|
||||
//
|
||||
// The token value is passed via the child process's stdin, never as a
|
||||
// literal argv element — matching the estate's own secret-handling rule
|
||||
// (a value in argv lands in process listings and any transcript of the
|
||||
// command). `credential[password]=-` is `op item create`'s documented way
|
||||
// to read one field's value from stdin instead of the command line.
|
||||
type OpSecretStore struct {
|
||||
Vault string
|
||||
Run func(ctx context.Context, stdin string, args ...string) error
|
||||
}
|
||||
|
||||
func (s OpSecretStore) Store(ctx context.Context, name, value string) (string, error) {
|
||||
run := s.Run
|
||||
if run == nil {
|
||||
run = runOp
|
||||
}
|
||||
args := []string{
|
||||
"item", "create",
|
||||
"--category=login",
|
||||
"--vault=" + s.Vault,
|
||||
"--title=" + name,
|
||||
"credential[password]=-",
|
||||
}
|
||||
if err := run(ctx, value, args...); err != nil {
|
||||
return "", fmt.Errorf("op item create: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("op://%s/%s/credential", s.Vault, name), nil
|
||||
}
|
||||
|
||||
func runOp(ctx context.Context, stdin string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, "op", args...)
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package localtools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The estate's own secret-handling rule: never place a literal secret in a
|
||||
// command's argv — it lands in process listings and any transcript of the
|
||||
// command. OpSecretStore feeds the token to `op` via stdin instead.
|
||||
|
||||
func TestOpSecretStore_PassesValueViaStdinNeverArgv(t *testing.T) {
|
||||
var gotStdin string
|
||||
var gotArgs []string
|
||||
s := localtools.OpSecretStore{
|
||||
Vault: "AgentSecrets",
|
||||
Run: func(_ context.Context, stdin string, args ...string) error {
|
||||
gotStdin = stdin
|
||||
gotArgs = args
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
ref, err := s.Store(context.Background(), "gitea-dispatch-bot-ci-readonly", "tok_the_actual_secret_value")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential", ref)
|
||||
assert.Equal(t, "tok_the_actual_secret_value", gotStdin)
|
||||
for _, a := range gotArgs {
|
||||
assert.NotContains(t, a, "tok_the_actual_secret_value",
|
||||
"the secret value must never appear as a literal argv element")
|
||||
}
|
||||
assert.Contains(t, gotArgs, "--vault=AgentSecrets")
|
||||
assert.Contains(t, gotArgs, "--title=gitea-dispatch-bot-ci-readonly")
|
||||
}
|
||||
|
||||
func TestOpSecretStore_PropagatesRunError(t *testing.T) {
|
||||
s := localtools.OpSecretStore{
|
||||
Vault: "AgentSecrets",
|
||||
Run: func(_ context.Context, _ string, _ ...string) error {
|
||||
return assert.AnError
|
||||
},
|
||||
}
|
||||
|
||||
_, err := s.Store(context.Background(), "x", "y")
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user