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)} }