feat: add provider-neutral LLM layer with secrets, budgets, recording and prompts

Phase 7 (ADR-0004). Cognitive code asks the router for a role; the
router maps roles to configured models with ordered fallbacks, applies
global and per-mind daily token budgets and a concurrency limit, records
every call (request, response, usage, latency, cost, seed, request hash)
and can serve recordings back for exact replay. Providers are raw HTTP
clients for OpenAI-compatible, Anthropic and Ollama endpoints, with
embedding support for the embed role wired into memory retrieval.

Secrets are references only: env:, file:, or enc: values encrypted with
AES-256-GCM under NOEMA_MASTER_KEY; keys are resolved at call time and
never rendered, logged or exported. Outbound HTTP refuses private and
loopback addresses at dial time unless a provider explicitly allows it,
bounds redirects and caps bodies. Provider errors are scrubbed.

Prompts are named, versioned rows with system and user templates and an
optional JSON schema; untrusted text enters only through delimited data
blocks. Six defaults are seeded and never overwritten. The UI adds the
LLM page (providers, models, role matrix, usage, errors, budgets), a
provider page with connectivity test, and a prompt manager with editor,
test runner, history, restore, clone and diff. CLI: provider, prompt.
This commit is contained in:
Andy Dixon
2026-06-23 23:43:33 +01:00
parent 0b790f34c7
commit d9c6ffa30f
43 changed files with 4138 additions and 20 deletions
+134
View File
@@ -0,0 +1,134 @@
// Package secrets resolves secret references so that plaintext credentials
// never live in the database, configuration exports, logs or HTML (T13, T21).
//
// Reference forms:
//
// env:NAME read from the environment at use time
// file:/path read from a file (trailing newline trimmed)
// enc:<base64> AES-256-GCM ciphertext under the master key
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
)
// Resolver turns references into values.
type Resolver struct {
key []byte // 32 bytes or nil when no master key is configured
}
// ErrNoMasterKey is returned when enc: references are used without a key.
var ErrNoMasterKey = errors.New("secrets: NOEMA_MASTER_KEY is not configured")
// New creates a resolver. masterKey is base64 (standard or URL, padded or
// not) of 32 bytes, or empty to disable encrypted references.
func New(masterKey string) (*Resolver, error) {
r := &Resolver{}
masterKey = strings.TrimSpace(masterKey)
if masterKey == "" {
return r, nil
}
var key []byte
var err error
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if key, err = enc.DecodeString(masterKey); err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("secrets: master key is not valid base64")
}
if len(key) != 32 {
return nil, fmt.Errorf("secrets: master key must be 32 bytes, got %d", len(key))
}
r.key = key
return r, nil
}
// CanEncrypt reports whether enc: references can be produced and resolved.
func (r *Resolver) CanEncrypt() bool { return r != nil && r.key != nil }
// IsReference reports whether s has a recognised prefix.
func IsReference(s string) bool {
return strings.HasPrefix(s, "env:") || strings.HasPrefix(s, "file:") || strings.HasPrefix(s, "enc:")
}
// Resolve returns the secret value for a reference. Empty references resolve
// to empty strings so optional secrets need no special casing.
func (r *Resolver) Resolve(ref string) (string, error) {
switch {
case ref == "":
return "", nil
case strings.HasPrefix(ref, "env:"):
name := strings.TrimPrefix(ref, "env:")
v, ok := os.LookupEnv(name)
if !ok {
return "", fmt.Errorf("secrets: environment variable %s is not set", name)
}
return v, nil
case strings.HasPrefix(ref, "file:"):
b, err := os.ReadFile(strings.TrimPrefix(ref, "file:"))
if err != nil {
return "", fmt.Errorf("secrets: %w", err)
}
return strings.TrimRight(string(b), "\r\n"), nil
case strings.HasPrefix(ref, "enc:"):
if !r.CanEncrypt() {
return "", ErrNoMasterKey
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(ref, "enc:"))
if err != nil {
return "", fmt.Errorf("secrets: bad ciphertext encoding")
}
block, _ := aes.NewCipher(r.key)
gcm, _ := cipher.NewGCM(block)
if len(raw) < gcm.NonceSize() {
return "", fmt.Errorf("secrets: ciphertext too short")
}
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
return "", fmt.Errorf("secrets: decryption failed (wrong master key?)")
}
return string(plain), nil
default:
return "", fmt.Errorf("secrets: unrecognised reference form (use env:, file: or enc:)")
}
}
// Encrypt produces an enc: reference for a plaintext typed into the UI.
func (r *Resolver) Encrypt(plain string) (string, error) {
if !r.CanEncrypt() {
return "", ErrNoMasterKey
}
block, _ := aes.NewCipher(r.key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := gcm.Seal(nonce, nonce, []byte(plain), nil)
return "enc:" + base64.RawURLEncoding.EncodeToString(ct), nil
}
// Describe returns a display string for a reference that reveals nothing.
func Describe(ref string) string {
switch {
case ref == "":
return "not set"
case strings.HasPrefix(ref, "env:"):
return "environment variable " + strings.TrimPrefix(ref, "env:")
case strings.HasPrefix(ref, "file:"):
return "file " + strings.TrimPrefix(ref, "file:")
case strings.HasPrefix(ref, "enc:"):
return "encrypted in database"
default:
return "invalid reference"
}
}
+56
View File
@@ -0,0 +1,56 @@
package secrets
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
func TestReferences(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
r, err := New(key)
if err != nil {
t.Fatal(err)
}
ref, err := r.Encrypt("sk-secret")
if err != nil || !strings.HasPrefix(ref, "enc:") || strings.Contains(ref, "sk-secret") {
t.Fatalf("encrypt: %v %s", err, ref)
}
if v, _ := r.Resolve(ref); v != "sk-secret" {
t.Fatalf("round trip failed: %q", v)
}
other, _ := New(base64.StdEncoding.EncodeToString([]byte("fedcba9876543210fedcba9876543210")))
if _, err := other.Resolve(ref); err == nil {
t.Fatal("decryption with wrong key succeeded")
}
none, _ := New("")
if _, err := none.Resolve(ref); err != ErrNoMasterKey {
t.Fatalf("expected ErrNoMasterKey, got %v", err)
}
if _, err := none.Encrypt("x"); err != ErrNoMasterKey {
t.Fatal("encrypt without key should fail")
}
t.Setenv("NOEMA_TEST_SECRET", "from-env")
if v, _ := r.Resolve("env:NOEMA_TEST_SECRET"); v != "from-env" {
t.Fatal("env reference failed")
}
if _, err := r.Resolve("env:NOEMA_DEFINITELY_UNSET"); err == nil {
t.Fatal("unset env should error")
}
p := filepath.Join(t.TempDir(), "s")
os.WriteFile(p, []byte("from-file\n"), 0o600)
if v, _ := r.Resolve("file:" + p); v != "from-file" {
t.Fatal("file reference failed")
}
if _, err := r.Resolve("plaintext-key"); err == nil {
t.Fatal("plaintext must be rejected")
}
if Describe(ref) != "encrypted in database" || Describe("env:X") != "environment variable X" {
t.Fatal("describe wrong")
}
if _, err := New("short"); err == nil {
t.Fatal("short key accepted")
}
}