diff --git a/server/internal/mcp/tools_create.go b/server/internal/mcp/tools_create.go index 3be8e75..80221e1 100644 --- a/server/internal/mcp/tools_create.go +++ b/server/internal/mcp/tools_create.go @@ -3,6 +3,8 @@ package mcp import ( "context" "fmt" + "regexp" + "strings" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" @@ -319,3 +321,73 @@ func init() { }, }) } + +var secretNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) + +// buildSecrets validates the arguments and returns the group and values to +// store. Names follow the same rule as the REST handlers (api.validName). +func buildSecrets(args map[string]any) (string, map[string]string, error) { + group := stringArg(args, "group") + if !secretNamePattern.MatchString(group) { + return "", nil, fmt.Errorf("group is required and may only contain letters, digits, '.', '_' and '-'") + } + raw, _ := args["values"].(map[string]any) + if len(raw) == 0 { + return "", nil, fmt.Errorf("values must contain at least one key") + } + values := make(map[string]string, len(raw)) + for k, v := range raw { + if !secretNamePattern.MatchString(k) { + return "", nil, fmt.Errorf("invalid key name: %s", k) + } + s, ok := v.(string) + if !ok { + return "", nil, fmt.Errorf("value for key %s must be a string", k) + } + values[k] = s + } + return group, values, nil +} + +func init() { + All().Register(Tool{ + Name: "create_secret", + Args: []ToolArg{ + {Name: "group", Type: ArgString, Description: "Secret group name; created if it does not exist.", Required: true}, + {Name: "values", Type: ArgTagMap, Description: "Key/value pairs to store, encrypted at rest.", Required: true}, + }, + Write: true, + Scope: "secrets:write", + Description: "Add new secret keys to a group, creating the group if needed. " + + "Existing keys are never overwritten - the call is refused if any key already " + + "exists, so rotate or replace a secret in the Vantage UI. Values are not returned.", + Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) { + group, values, err := buildSecrets(args) + if err != nil { + return nil, err + } + // UpsertSecrets overwrites silently. A model replacing a live + // credential would break whatever uses it, so only additions + // are allowed from here. + existing, err := services.GetSecretGroup(c.InstanceID, group) + if err != nil { + return nil, fmt.Errorf("could not read secret group: %w", err) + } + for _, s := range existing { + if _, clash := values[s.Key]; clash { + return nil, fmt.Errorf("key %q already exists in group %q; change it in the Vantage UI", s.Key, group) + } + } + if err := services.UpsertSecrets(c.InstanceID, group, values); err != nil { + return nil, fmt.Errorf("could not save the secrets: %w", err) + } + keys := services.SortedKeys(values) + LogCreated(c, "secret", group, strings.Join(keys, ", ")) + return map[string]any{ + "group": group, + "keys": keys, + "note": "Saved. Values are not readable through MCP.", + }, nil + }, + }) +} diff --git a/server/internal/mcp/tools_create_test.go b/server/internal/mcp/tools_create_test.go index 43e76d1..422bf05 100644 --- a/server/internal/mcp/tools_create_test.go +++ b/server/internal/mcp/tools_create_test.go @@ -226,3 +226,19 @@ func TestBuildMonitorWithoutRunnerIsDisabled(t *testing.T) { t.Error("monitor created enabled") } } + +func TestBuildSecretsValidates(t *testing.T) { + if _, _, err := buildSecrets(map[string]any{"group": "bad group", "values": map[string]any{"k": "v"}}); err == nil { + t.Error("accepted invalid group name") + } + if _, _, err := buildSecrets(map[string]any{"group": "prod"}); err == nil { + t.Error("accepted empty values") + } + if _, _, err := buildSecrets(map[string]any{"group": "prod", "values": map[string]any{"k": 1.0}}); err == nil { + t.Error("accepted non-string value") + } + g, v, err := buildSecrets(map[string]any{"group": "prod", "values": map[string]any{"DB_PASS": "x"}}) + if err != nil || g != "prod" || v["DB_PASS"] != "x" { + t.Errorf("valid input rejected: %v", err) + } +}