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,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
|
||||
}
|
||||
Reference in New Issue
Block a user