244 lines
7.6 KiB
Go
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)}
|
|
}
|