Files
gitea-mcp/internal/tools/repo_mirror_push.go
T
mathiasandClaude Opus 4.8 64176fe6d7
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
fix(repo_mirror_push): resolve mirror credential from server env, not the payload (#49)
The mirror credential no longer has to ride the tool-call payload (which is
persisted to transcript → claudewatcher → brain → gitea history). Adds
remote_password_env: the name of a server-side env var the tool resolves at call
time, so the secret stays in the server process. An env name that resolves to
empty errors loudly rather than silently sending an empty password. Raw
remote_password still works but the schema/description now mark it DISCOURAGED.

Tests: password resolved from the env var (never in output); unset env var →
ErrValidation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:00:07 +02:00

141 lines
4.9 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoMirrorPush struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush {
return &RepoMirrorPush{c: c, a: a}
}
func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_mirror_push",
Description: "Manage push mirrors for a repository: add, list, or delete. For the mirror credential, PREFER remote_password_env (the name of an env var the server reads) so the secret never rides the tool-call payload/transcript; remote_password (raw) is discouraged and will be persisted in logs.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"action":{"type":"string","enum":["add","list","delete"]},
"remote_address":{"type":"string","description":"Mirror target URL (required for add)."},
"remote_username":{"type":"string"},
"remote_password_env":{"type":"string","description":"PREFERRED: name of a server-side env var holding the mirror credential; the server resolves it, so the secret is never in this call. Errors if the var is unset."},
"remote_password":{"type":"string","description":"DISCOURAGED: raw credential — lands in the tool-call transcript/logs. Use remote_password_env instead."},
"interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."},
"sync_on_commit":{"type":"boolean"},
"mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."}
},
"required":["owner","repo","action"]
}`),
}
}
type repoMirrorPushArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Action string `json:"action"`
RemoteAddress string `json:"remote_address"`
RemoteUsername string `json:"remote_username"`
RemotePassword string `json:"remote_password"`
RemotePasswordEnv string `json:"remote_password_env"`
Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"`
MirrorName string `json:"mirror_name"`
}
// safeMirror omits remote_password so it is never returned to the caller.
type safeMirror struct {
ID int `json:"id"`
RemoteName string `json:"remote_name"`
RemoteAddress string `json:"remote_address"`
Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"`
}
func toSafeMirror(m *gitea.PushMirror) safeMirror {
return safeMirror{
ID: m.ID,
RemoteName: m.RemoteName,
RemoteAddress: m.RemoteAddress,
Interval: m.Interval,
SyncOnCommit: m.SyncOnCommit,
}
}
// resolveMirrorPassword prefers remote_password_env — the name of a server-side
// env var — so the credential never appears in the tool-call payload (#49). It
// falls back to the raw (discouraged) remote_password. An env name that resolves
// to empty is a loud error, not a silent empty password.
func resolveMirrorPassword(args repoMirrorPushArgs) (string, error) {
if args.RemotePasswordEnv != "" {
pw := os.Getenv(args.RemotePasswordEnv)
if pw == "" {
return "", fmt.Errorf("remote_password_env %q is unset or empty in the server environment: %w",
args.RemotePasswordEnv, gitea.ErrValidation)
}
return pw, nil
}
return args.RemotePassword, nil
}
func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoMirrorPushArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
switch args.Action {
case "add":
password, err := resolveMirrorPassword(args)
if err != nil {
return nil, err
}
m, err := t.c.AddPushMirror(ctx, args.Owner, args.Repo, gitea.AddPushMirrorArgs{
RemoteAddress: args.RemoteAddress,
RemoteUsername: args.RemoteUsername,
RemotePassword: password,
Interval: args.Interval,
SyncOnCommit: args.SyncOnCommit,
})
if err != nil {
return nil, err
}
return textOK(toSafeMirror(m))
case "list":
mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
safe := make([]safeMirror, len(mirrors))
for i := range mirrors {
safe[i] = toSafeMirror(&mirrors[i])
}
return textOK(safe)
case "delete":
if args.MirrorName == "" {
return nil, fmt.Errorf("mirror_name is required for action=delete")
}
if err := t.c.DeletePushMirror(ctx, args.Owner, args.Repo, args.MirrorName); err != nil {
return nil, err
}
return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName})
default:
return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action)
}
}