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:
@@ -0,0 +1,116 @@
|
||||
// Package httpclient builds outbound HTTP clients hardened against SSRF
|
||||
// (T6): private, loopback, link-local and metadata addresses are refused at
|
||||
// dial time (so redirects are covered too), redirects are bounded, and
|
||||
// response bodies are capped.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Options configure a client.
|
||||
type Options struct {
|
||||
Timeout time.Duration
|
||||
AllowPrivate bool // permit RFC1918/loopback targets (local Ollama, self-hosted services)
|
||||
MaxBody int64 // response body cap for ReadAll; 0 = 8 MiB
|
||||
MaxRedirects int // 0 = 5
|
||||
}
|
||||
|
||||
// ErrPrivateAddress is returned when a dial would reach a blocked range.
|
||||
var ErrPrivateAddress = errors.New("httpclient: destination address is private or reserved")
|
||||
|
||||
var blocked = func() []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, s := range []string{"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4", "::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8", "::ffff:0:0/96"} {
|
||||
out = append(out, netip.MustParsePrefix(s))
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// IsPrivate reports whether addr falls in a blocked range.
|
||||
func IsPrivate(addr netip.Addr) bool {
|
||||
addr = addr.Unmap()
|
||||
for _, p := range blocked {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New builds a hardened client. The check happens after DNS resolution and
|
||||
// on every connection, so DNS rebinding and redirects to internal hosts are
|
||||
// both refused.
|
||||
func New(o Options) *http.Client {
|
||||
if o.Timeout <= 0 {
|
||||
o.Timeout = 60 * time.Second
|
||||
}
|
||||
if o.MaxRedirects <= 0 {
|
||||
o.MaxRedirects = 5
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("httpclient: no addresses for %s", host)
|
||||
}
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
if !o.AllowPrivate && IsPrivate(ip) {
|
||||
lastErr = fmt.Errorf("%w: %s resolves to %s", ErrPrivateAddress, host, ip)
|
||||
continue
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
}
|
||||
max := o.MaxRedirects
|
||||
return &http.Client{Timeout: o.Timeout, Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= max {
|
||||
return fmt.Errorf("httpclient: stopped after %d redirects", max)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// ReadAll reads a response body up to max bytes (0 = 8 MiB) and errors if
|
||||
// the body is larger, so a hostile server cannot exhaust memory.
|
||||
func ReadAll(r io.Reader, max int64) ([]byte, error) {
|
||||
if max <= 0 {
|
||||
max = 8 << 20
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) > max {
|
||||
return nil, fmt.Errorf("httpclient: response exceeds %d bytes", max)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPrivateRanges(t *testing.T) {
|
||||
for _, s := range []string{"127.0.0.1", "10.1.2.3", "192.168.1.1", "172.16.5.5", "169.254.169.254", "::1", "fd00::1", "::ffff:10.0.0.1", "0.0.0.0"} {
|
||||
if !IsPrivate(netip.MustParseAddr(s)) {
|
||||
t.Errorf("%s should be private", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"8.8.8.8", "1.1.1.1", "2606:4700::1111", "93.184.216.34"} {
|
||||
if IsPrivate(netip.MustParseAddr(s)) {
|
||||
t.Errorf("%s should be public", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRefusesLoopbackUnlessAllowed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/redir" {
|
||||
http.Redirect(w, r, "/redir", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(strings.Repeat("x", 100))) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
strict := New(Options{Timeout: 2 * time.Second})
|
||||
_, err := strict.Get(srv.URL)
|
||||
if err == nil || !errors.Is(errors.Unwrap(errors.Unwrap(err)), ErrPrivateAddress) && !strings.Contains(err.Error(), "private") {
|
||||
t.Fatalf("loopback should be refused by default: %v", err)
|
||||
}
|
||||
lax := New(Options{Timeout: 2 * time.Second, AllowPrivate: true})
|
||||
res, err := lax.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadAll(res.Body, 50); err == nil {
|
||||
t.Fatal("body cap not enforced")
|
||||
}
|
||||
res.Body.Close()
|
||||
if _, err := lax.Get(srv.URL + "/redir"); err == nil || !strings.Contains(err.Error(), "redirects") {
|
||||
t.Fatalf("redirect loop should be bounded: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user