Files
iasysadmin/internal/tools/secrets.go
T
2026-09-27 16:04:02 -06:00

244 lines
7.6 KiB
Go

package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"sync"
)
// SecretStore guarda credenciales pedidas al operador durante la sesión
// (§80). Nunca se persisten ni se muestran al modelo: el modelo trabaja con
// marcadores {{secret:nombre}} que se sustituyen solo al ejecutar.
type SecretStore struct {
mu sync.Mutex
values map[string]string
}
// NewSecretStore crea un almacén vacío.
func NewSecretStore() *SecretStore { return &SecretStore{values: map[string]string{}} }
var (
secretNameRe = regexp.MustCompile(`^[A-Za-z0-9_]{1,40}$`)
placeholder = regexp.MustCompile(`\{\{secret:([A-Za-z0-9_]{1,40})\}\}`)
)
// Placeholder devuelve el marcador de un secreto.
func Placeholder(name string) string { return "{{secret:" + name + "}}" }
// Set guarda un secreto.
func (s *SecretStore) Set(name, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values[name] = value
}
// Has informa si existe un secreto.
func (s *SecretStore) Has(name string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.values[name]
return ok
}
// Names devuelve los nombres (nunca los valores).
func (s *SecretStore) Names() []string {
s.mu.Lock()
defer s.mu.Unlock()
var n []string
for k := range s.values {
n = append(n, k)
}
sort.Strings(n)
return n
}
// Expand sustituye los marcadores por sus valores. Un marcador desconocido
// es un error: ejecutar un comando con el texto literal del marcador
// (p.ej. como contraseña) sería un fallo silencioso.
func (s *SecretStore) Expand(text string) (string, error) {
if s == nil || !strings.Contains(text, "{{secret:") {
return text, nil
}
s.mu.Lock()
defer s.mu.Unlock()
var missing []string
out := placeholder.ReplaceAllStringFunc(text, func(m string) string {
name := placeholder.FindStringSubmatch(m)[1]
v, ok := s.values[name]
if !ok {
missing = append(missing, name)
return m
}
return v
})
if len(missing) > 0 {
return "", fmt.Errorf("unknown secret(s) %s; ask the operator with ask_secret first", strings.Join(missing, ", "))
}
return out, nil
}
// ShellEnv prepara un script de shell: cada marcador se sustituye por una
// referencia a variable de entorno y el valor viaja en el entorno del
// proceso. Así el valor nunca se interpreta como código de shell (sin
// riesgo de inyección si contiene comillas o ;) y no aparece en la lista de
// procesos como parte del script.
//
// La referencia depende del contexto de comillas en que aparece el marcador,
// para que se expanda siempre y como una sola palabra:
//
// sin comillas -> "${SYSAGENT_SECRET_x}"
// entre "..." -> ${SYSAGENT_SECRET_x}
// entre '...' -> '"${SYSAGENT_SECRET_x}"' (cierra, expande, reabre)
func (s *SecretStore) ShellEnv(script string) (string, []string, error) {
if s == nil || !strings.Contains(script, "{{secret:") {
return script, nil, nil
}
s.mu.Lock()
defer s.mu.Unlock()
// Pila de contextos de comillas: $(...) y `...` abren un contexto nuevo
// aunque estén dentro de "...", como hace bash.
type quoteCtx struct {
single, dbl bool
parens int // paréntesis abiertos dentro de este $(...)
backtick bool // contexto abierto por `
}
var (
b strings.Builder
env, missing []string
seen = map[string]bool{}
stack = []quoteCtx{{}}
)
for i := 0; i < len(script); {
cur := &stack[len(stack)-1]
if m := placeholder.FindStringSubmatchIndex(script[i:]); m != nil && m[0] == 0 {
name := script[i+m[2] : i+m[3]]
v, ok := s.values[name]
if !ok {
missing = append(missing, name)
} else if !seen[name] {
seen[name] = true
env = append(env, "SYSAGENT_SECRET_"+name+"="+v)
}
ref := "${SYSAGENT_SECRET_" + name + "}"
switch {
case cur.single:
b.WriteString(`'"` + ref + `"'`)
case cur.dbl:
b.WriteString(ref)
default:
b.WriteString(`"` + ref + `"`)
}
i += m[1]
continue
}
c := script[i]
switch {
case cur.single:
if c == '\'' {
cur.single = false
}
case c == '\\' && i+1 < len(script):
b.WriteString(script[i : i+2])
i += 2
continue
case c == '$' && i+1 < len(script) && script[i+1] == '(':
b.WriteString("$(")
i += 2
stack = append(stack, quoteCtx{})
continue
case c == '`':
if cur.backtick && len(stack) > 1 {
stack = stack[:len(stack)-1]
} else {
stack = append(stack, quoteCtx{backtick: true})
}
case c == '"':
cur.dbl = !cur.dbl
case c == '\'' && !cur.dbl:
cur.single = true
case c == '(' && !cur.dbl:
cur.parens++
case c == ')' && !cur.dbl:
if cur.parens > 0 {
cur.parens--
} else if len(stack) > 1 && !cur.backtick {
stack = stack[:len(stack)-1]
}
}
b.WriteByte(c)
i++
}
if len(missing) > 0 {
return "", nil, fmt.Errorf("unknown secret(s) %s; ask the operator with ask_secret first", strings.Join(missing, ", "))
}
return b.String(), env, nil
}
// SecretPrompter pide un secreto al operador sin eco. nil = no hay operador
// (modo tareas).
type SecretPrompter func(ctx context.Context, prompt string) (string, error)
// ---- ask_secret ----
type askSecretTool struct{}
type askSecretArgs struct {
Name string `json:"name"`
Prompt string `json:"prompt"`
Refresh bool `json:"refresh"`
}
func (askSecretTool) Name() string { return "ask_secret" }
func (askSecretTool) Description() string {
return "Ask the operator for a password or other secret, typed without echo (interactive --cli sessions only). You never see the value: you get the placeholder {{secret:name}} to use in exec args, shell scripts (passed as an environment variable) or file contents. Secrets live only for this session."
}
func (askSecretTool) Schema() map[string]any {
return obj([]string{"name", "prompt"}, map[string]any{
"name": str("identifier, letters/digits/underscore, e.g. postgres_admin"),
"prompt": str("question shown to the operator, e.g. \"Password of PostgreSQL user admin on db01\""),
"refresh": boolean("ask again even if the secret is already known"),
})
}
func (askSecretTool) Run(ctx context.Context, env *Env, raw json.RawMessage) Result {
var a askSecretArgs
if err := decode(raw, &a); err != nil {
return fail("ask_secret", err)
}
label := "ask_secret " + a.Name
if !secretNameRe.MatchString(a.Name) {
return fail(label, errors.New("name must match [A-Za-z0-9_]{1,40}"))
}
if env.Secrets == nil {
return fail(label, errors.New("secret store not available"))
}
ph := Placeholder(a.Name)
if env.Secrets.Has(a.Name) && !a.Refresh {
return Result{OK: true, ExitCode: 0, Summary: label + " (already known)", Output: "already available as " + ph}
}
if env.Prompt == nil {
return fail(label, errors.New("no interactive operator (task mode): secrets cannot be requested; report the blocker and, if useful, record in memoria.md where the credential is stored"))
}
value, err := env.Prompt(ctx, fmt.Sprintf("\n[sysagent] %s: ", strings.TrimSpace(a.Prompt)))
if err != nil {
return fail(label, err)
}
if value == "" {
return fail(label, errors.New("the operator entered an empty value (cancelled)"))
}
env.Secrets.Set(a.Name, value)
env.Log.AddSecret(value, ph)
note := ""
if len(value) < 4 {
note = "\nwarning: very short secret; it may not be masked reliably in outputs"
}
return Result{OK: true, ExitCode: 0, Summary: label + " (stored)",
Output: fmt.Sprintf("stored for this session as %s. Use it literally in exec args, shell scripts or file contents; it is substituted only at execution time and masked in all outputs. Prefer passing secrets through environment variables or files with restrictive permissions: command-line arguments are visible to other local users in the process list.%s", ph, note)}
}