Files
43837afca3 fix(security): consolidate & enhance batched security fixes (#1155, #967, #972, #974, #989, #973) (#1185)
* fix(sandbox): avoid shell in FsBridge writes

Replace sh -c with interpolated path by shell-free 'tee -- <path>' argv form,
piping content via stdin. Prevents command injection through filenames
containing shell metacharacters inside the sandbox container.

Co-authored-by: evgyur <evgyur@gmail.com>

* fix(security): fail-closed on pairing DB errors across channels

On IsPaired lookup error, deny instead of granting access. Covers the shared
CheckDMPolicy/CheckGroupPolicy helpers (Slack/Discord/Feishu/WhatsApp/Zalo) and
the four inline Telegram pairing checks.

Co-authored-by: Srini <srinis.k@gmail.com>

* fix(security): harden provider URL validation against SSRF

Enforce scheme check for all provider types; restrict local types (ollama,
claude_cli, acp) to an explicit localhost allowlist instead of skipping checks;
resolve remote hostnames and reject any IP in a private/reserved range via the
shared security.IsBlocked CIDR list (covers loopback, link-local, metadata,
multicast, and unspecified 0.0.0.0/::). Closes the wildcard-DNS bypass and the
local-type escape hatch. Operator opt-in via GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS.

Exports security.IsBlocked as the single source of truth for blocked ranges.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* feat(pipeline): add fail-closed tool call authorization gate

Gate tool execution against the server-side AllowedTools allowlist built from the
RBAC/tenant-aware filtered tool set. Resolve the tool-call prefix before the
allowlist lookup so prefixed agents are not wrongly blocked, re-check deny on lazy
MCP activation, and expand IsDenied to cover aliased tool names.

Co-authored-by: Huy Doan <tui@pm.me>

* fix(security): expand file-serve deny-list defense-in-depth

Add absolute-path deny prefixes (/home, /Users, /srv, /var/lib, /var/www, /opt)
and an explicit fail-closed log when no file-serving boundary is configured.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* fix(providers): allow claude cli executable paths

Refs: #1185

---------

Co-authored-by: evgyur <evgyur@gmail.com>
Co-authored-by: Srini <srinis.k@gmail.com>
Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>
Co-authored-by: Huy Doan <tui@pm.me>
2026-06-05 00:48:38 +07:00

158 lines
4.3 KiB
Go

package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const embeddingBatchSize = 2048
// ExpectedEmbeddingDim is the pgvector column dimension used across the system.
// All embedding providers must return vectors of this dimension.
const ExpectedEmbeddingDim = 1536
// OpenAIEmbeddingProvider implements store.EmbeddingProvider for OpenAI-compatible APIs.
type OpenAIEmbeddingProvider struct {
providerName string
apiKey string
apiBase string
model string
client *http.Client
retry RetryConfig
}
// NewOpenAIEmbeddingProvider creates an embedding provider for OpenAI-compatible APIs.
func NewOpenAIEmbeddingProvider(apiKey, apiBase, model string) *OpenAIEmbeddingProvider {
if apiBase == "" {
apiBase = "https://api.openai.com/v1"
}
if model == "" {
model = "text-embedding-3-small" // 1536 dimensions, matches pgvector column
}
return &OpenAIEmbeddingProvider{
providerName: "openai",
apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"),
model: model,
// Use a fixed 60s timeout client. Embedding requests are short but can be
// batched. The apiBase is validated at provider-creation time by
// validateProviderURL in internal/http/providers.go.
client: &http.Client{Timeout: 60 * time.Second},
retry: DefaultRetryConfig(),
}
}
func (p *OpenAIEmbeddingProvider) Name() string { return p.providerName }
func (p *OpenAIEmbeddingProvider) Model() string { return p.model }
// Embed generates vector embeddings for the given texts.
// Returns [][]float32 where each inner slice has ExpectedEmbeddingDim elements.
func (p *OpenAIEmbeddingProvider) Embed(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
results := make([][]float32, len(texts))
// Process in batches of embeddingBatchSize
for start := 0; start < len(texts); start += embeddingBatchSize {
end := min(start+embeddingBatchSize, len(texts))
embeddings, err := p.embedBatch(ctx, texts[start:end])
if err != nil {
return nil, fmt.Errorf("embedding batch [%d:%d]: %w", start, end, err)
}
for i, emb := range embeddings {
results[start+i] = emb
}
}
return results, nil
}
func (p *OpenAIEmbeddingProvider) embedBatch(ctx context.Context, texts []string) ([][]float32, error) {
reqBody := map[string]any{
"model": p.model,
"input": texts,
}
return RetryDo(ctx, p.retry, func() ([][]float32, error) {
data, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/embeddings", bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("embedding request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, &HTTPError{
Status: resp.StatusCode,
Body: string(body),
RetryAfter: ParseRetryAfter(resp.Header.Get("Retry-After")),
}
}
var result embeddingResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode embedding response: %w", err)
}
// Convert float64 → float32, validate dimension, sort by index
embeddings := make([][]float32, len(texts))
for _, d := range result.Data {
if d.Index >= len(embeddings) {
continue
}
if len(d.Embedding) != ExpectedEmbeddingDim {
return nil, fmt.Errorf(
"embedding dimension mismatch: model %s returned %d, expected %d (pgvector column)",
p.model, len(d.Embedding), ExpectedEmbeddingDim,
)
}
vec := make([]float32, len(d.Embedding))
for j, v := range d.Embedding {
vec[j] = float32(v)
}
embeddings[d.Index] = vec
}
return embeddings, nil
})
}
// --- Embedding API response types ---
type embeddingResponse struct {
Data []embeddingData `json:"data"`
Usage *embeddingUsage `json:"usage,omitempty"`
}
type embeddingData struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
}
type embeddingUsage struct {
PromptTokens int `json:"prompt_tokens"`
TotalTokens int `json:"total_tokens"`
}