Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,20 +136,20 @@ jobs:
/tmp/rules.fuzz -test.fuzz=FuzzMCPToolBypass -test.fuzztime=30s -test.short -test.fuzzcachedir=/tmp/fuzz-cache
/tmp/rules.fuzz -test.fuzz=FuzzConfusableBypass -test.fuzztime=30s -test.short -test.fuzzcachedir=/tmp/fuzz-cache

- name: Fuzz tests (full suite, 12s each — parallelized)
- name: Fuzz tests (full suite, 15s each — parallelized)
run: |
# Run full suite in parallel batches (2 at a time) to cut wall time
# while keeping enough CPU per fuzzer for meaningful coverage.
# Each fuzz target gets its own process so failures are isolated.
# 12s (not 10s) to avoid spurious "context deadline exceeded" from
# the Go fuzz framework when CPU-contended shutdown races the timer.
# 15s to avoid spurious "context deadline exceeded" from the Go fuzz
# framework when CPU-contended shutdown races the timer.
parallel_fuzz() {
local bin="$1"; shift
local pids=()
local targets=()
local failed=0
for target in "$@"; do
"$bin" -test.fuzz="$target" -test.fuzztime=12s -test.short -test.fuzzcachedir=/tmp/fuzz-cache &
"$bin" -test.fuzz="$target" -test.fuzztime=15s -test.short -test.fuzzcachedir=/tmp/fuzz-cache &
pids+=($!)
targets+=("$target")
# Cap concurrency at 2 for better per-target CPU utilization.
Expand Down Expand Up @@ -179,15 +179,17 @@ jobs:
FuzzCommandRegexBypass FuzzHostRegexBypass \
FuzzJSONUnicodeEscapeBypass FuzzEvasionDetectionBypass FuzzGlobCommandBypass \
FuzzPipelineExtraction FuzzContentConfusableBypass FuzzVariableExpansionEvasion \
FuzzShapeDetectionBypass FuzzWebSearchURLBypass \
FuzzShapeDetectionBypass \
|| rc=1
# FuzzNormalAgentFalsePositive creates a full engine per iteration
# (with DLP/gitleaks) — too slow for the 12s parallel batch.
# FuzzNormalAgentFalsePositive and FuzzWebSearchURLBypass create a
# full engine per iteration (with DLP/gitleaks) — too slow for the
# 12s parallel batch. Run sequentially with more time.
/tmp/rules.fuzz -test.fuzz=FuzzNormalAgentFalsePositive -test.fuzztime=15s -test.short -test.fuzzcachedir=/tmp/fuzz-cache || rc=1
/tmp/rules.fuzz -test.fuzz=FuzzWebSearchURLBypass -test.fuzztime=15s -test.short -test.fuzzcachedir=/tmp/fuzz-cache || rc=1
# FuzzPipeBypass and FuzzForkBombDetection need constrained memory and
# single worker to avoid OOM when running alongside other fuzz targets.
GOMEMLIMIT=2GiB /tmp/rules.fuzz -test.fuzz=FuzzPipeBypass -test.fuzztime=12s -test.short -test.fuzzcachedir=/tmp/fuzz-cache -test.parallel=1 || rc=1
GOMEMLIMIT=2GiB /tmp/rules.fuzz -test.fuzz=FuzzForkBombDetection -test.fuzztime=12s -test.short -test.fuzzcachedir=/tmp/fuzz-cache -test.parallel=1 || rc=1
GOMEMLIMIT=2GiB /tmp/rules.fuzz -test.fuzz=FuzzPipeBypass -test.fuzztime=15s -test.short -test.fuzzcachedir=/tmp/fuzz-cache -test.parallel=1 || rc=1
GOMEMLIMIT=2GiB /tmp/rules.fuzz -test.fuzz=FuzzForkBombDetection -test.fuzztime=15s -test.short -test.fuzzcachedir=/tmp/fuzz-cache -test.parallel=1 || rc=1
parallel_fuzz /tmp/httpproxy.fuzz \
FuzzParseSSEEventData FuzzCopyHeaders FuzzStripHopByHopHeaders \
FuzzParseEvent FuzzApplyResultToToolCalls \
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ docker run -p 9090:9090 crust
Then start the gateway:

```bash
crust start --auto
crust start
```

Auto mode detects your LLM provider from the model name — no endpoint URL or API key configuration needed. Your agent's existing auth is passed through.
Auto mode is the default — it detects your LLM provider from the model name with zero configuration. Your agent's existing auth is passed through.

## Agent Setup

Expand Down
22 changes: 12 additions & 10 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

```bash
# Gateway
crust start --auto # Auto mode (recommended)
crust start --endpoint URL --api-key KEY # Manual mode
crust start --auto --block-mode replace # Show block messages to agent
crust start --foreground --auto # Foreground mode (for Docker)
crust start # Auto mode (default, zero interaction)
crust start --manual # Prompt for endpoint URL + API key
crust start --endpoint URL --api-key KEY # Manual mode via flags
crust start --block-mode replace # Show block messages to agent
crust start --foreground # Foreground mode (for Docker)
crust stop # Stop the gateway
crust status [--json] [--live] # Check if running
crust status --live --api-addr HOST:PORT # Remote dashboard (Docker)
Expand Down Expand Up @@ -38,7 +39,8 @@ crust uninstall # Complete removal

| Flag | Description |
|------|-------------|
| `--auto` | Resolve providers from model names |
| `--auto` | Resolve providers from model names (default when no flags given) |
| `--manual` | Prompt interactively for endpoint URL and API key |
| `--endpoint URL` | LLM API endpoint URL |
| `--api-key KEY` | API key (prefer `LLM_API_KEY` env var) |
| `--foreground` | Run in foreground (for Docker/containers) |
Expand Down Expand Up @@ -108,17 +110,17 @@ Works with or without the daemon running. When the daemon is running, `crust sta
## Examples

```bash
# Interactive setup
# Auto mode (default, zero interaction)
crust start

# Auto mode with env-based auth
crust start --auto
# Manual mode — interactive prompt for endpoint + API key
crust start --manual

# Manual mode with explicit endpoint
# Manual mode with explicit flags
LLM_API_KEY=sk-xxx crust start --endpoint https://openrouter.ai/api/v1

# Docker/container mode
crust start --foreground --auto --listen-address 0.0.0.0
crust start --foreground --listen-address 0.0.0.0

# Follow logs
crust logs -f
Expand Down
6 changes: 3 additions & 3 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ internal/tui/
columns.go AlignColumns() for ANSI-aware two-column alignment
banner/ Gradient ASCII art banner with reveal animation + RevealLines
spinner/ Animated dot spinner with success glow effect
startup/ Interactive huh-based setup wizard with themed forms
startup/ Manual endpoint setup (huh form for --manual mode)
terminal/ Terminal emulator detection and capability bitfield
progress/ Determinate progress bar for multi-step operations
dashboard/ Live status dashboard with auto-refreshing metrics + stats tab
Expand Down Expand Up @@ -238,8 +238,8 @@ docker run -d -t -p 9090:9090 crust
# Production: plain text logs (no -t)
docker run -d -p 9090:9090 crust

# Interactive setup inside container
docker run -it --entrypoint crust crust start --foreground
# Interactive setup inside container (manual mode)
docker run -it --entrypoint crust crust start --foreground --manual

# View logs (styled with -t, plain without)
docker logs <container>
Expand Down
102 changes: 96 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ type SecurityConfig struct {
BlockMode types.BlockMode `yaml:"block_mode"` // "remove" (default) or "replace" (substitute with a text warning block)
}

// Validate validates the SecurityConfig and sets defaults.
// Validate validates the SecurityConfig and sets defaults for nil fields.
func (c *SecurityConfig) Validate() error {
// Validate and default BlockMode
if c.BlockMode == types.BlockModeUnset {
Expand Down Expand Up @@ -282,6 +282,93 @@ func (c *Config) Validate() error {
return errors.New(sb.String())
}

// rawConfig mirrors Config but uses pointers for fields where zero is
// invalid so YAML can distinguish "absent" (nil → use default) from
// "explicitly set to 0" (non-nil → copy, let Validate() reject).
type rawConfig struct {
Server struct {
Port *int `yaml:"port"`
LogLevel types.LogLevel `yaml:"log_level"`
NoColor bool `yaml:"no_color"`
} `yaml:"server"`
Upstream UpstreamConfig `yaml:"upstream"`
Storage StorageConfig `yaml:"storage"`
API APIConfig `yaml:"api"`
Telemetry struct {
Enabled bool `yaml:"enabled"`
RetentionDays int `yaml:"retention_days"`
ServiceName string `yaml:"service_name"`
SampleRate *float64 `yaml:"sample_rate"`
} `yaml:"telemetry"`
Security struct {
Enabled bool `yaml:"enabled"`
BufferStreaming bool `yaml:"buffer_streaming"`
MaxBufferEvents *int `yaml:"max_buffer_events"`
BufferTimeout *int `yaml:"buffer_timeout"`
BlockMode types.BlockMode `yaml:"block_mode"`
} `yaml:"security"`
Rules RulesConfig `yaml:"rules"`
}

// applyTo merges parsed YAML onto defaults.
// nil = absent in YAML → keep default. non-nil = explicitly set → copy.
func (r *rawConfig) applyTo(dst *Config) {
if r.Server.Port != nil {
dst.Server.Port = *r.Server.Port
}
if r.Server.LogLevel != "" {
dst.Server.LogLevel = r.Server.LogLevel
}
dst.Server.NoColor = r.Server.NoColor

if r.Upstream.URL != "" {
dst.Upstream.URL = r.Upstream.URL
}
dst.Upstream.Timeout = r.Upstream.Timeout
if len(r.Upstream.Providers) > 0 {
dst.Upstream.Providers = r.Upstream.Providers
}

if r.Storage.DBPath != "" {
dst.Storage.DBPath = r.Storage.DBPath
}
if r.Storage.EncryptionKey != "" {
dst.Storage.EncryptionKey = r.Storage.EncryptionKey
}

if r.API.SocketPath != "" {
dst.API.SocketPath = r.API.SocketPath
}

dst.Telemetry.Enabled = r.Telemetry.Enabled
dst.Telemetry.RetentionDays = r.Telemetry.RetentionDays
if r.Telemetry.ServiceName != "" {
dst.Telemetry.ServiceName = r.Telemetry.ServiceName
}
if r.Telemetry.SampleRate != nil {
dst.Telemetry.SampleRate = *r.Telemetry.SampleRate
}

dst.Security.Enabled = r.Security.Enabled
dst.Security.BufferStreaming = r.Security.BufferStreaming
if r.Security.MaxBufferEvents != nil {
dst.Security.MaxBufferEvents = *r.Security.MaxBufferEvents
}
if r.Security.BufferTimeout != nil {
dst.Security.BufferTimeout = *r.Security.BufferTimeout
}
if r.Security.BlockMode != types.BlockModeUnset {
dst.Security.BlockMode = r.Security.BlockMode
}

dst.Rules.Enabled = r.Rules.Enabled
if r.Rules.UserDir != "" {
dst.Rules.UserDir = r.Rules.UserDir
}
dst.Rules.DisableBuiltin = r.Rules.DisableBuiltin
dst.Rules.Watch = r.Rules.Watch
}

// isUnknownFieldError returns true if the error is from yaml.Decoder.KnownFields(true)
// detecting an unrecognized key (e.g. typo like "servr:").
func isUnknownFieldError(err error) bool {
Expand All @@ -302,22 +389,25 @@ func Load(path string) (*Config, error) {
return nil, err
}

// Try strict decode to warn about unknown fields (typos like "servr:")
// Parse into rawConfig (pointer fields detect absent vs explicit zero).
var raw rawConfig
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(cfg); err != nil {
if err := dec.Decode(&raw); err != nil {
if isUnknownFieldError(err) {
cfgLog.Warn("config has unknown fields (ignored): %v", err)
// Re-parse without strict mode for forward compatibility
cfg = DefaultConfig()
if err2 := yaml.Unmarshal(data, cfg); err2 != nil {
raw = rawConfig{}
if err2 := yaml.Unmarshal(data, &raw); err2 != nil {
return nil, fmt.Errorf("config parse error: %w", err2)
}
} else {
return nil, fmt.Errorf("config parse error: %w", err)
}
}

// Merge: nil → keep default, non-nil → copy (Validate catches bad values).
raw.applyTo(cfg)

// Expand environment variables in provider API keys.
// Collect referenced env var names so the daemon can propagate them.
for name, prov := range cfg.Upstream.Providers {
Expand Down
60 changes: 58 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ func TestValidate_PortRange(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "server.port") {
t.Errorf("port 99999 should fail: %v", err)
}

}

func TestValidate_LogLevel(t *testing.T) {
Expand Down Expand Up @@ -207,6 +206,63 @@ func TestValidate_RetentionDays(t *testing.T) {
}
}

func TestLoad_AbsentFieldsUseDefaults(t *testing.T) {
// Fields absent from YAML should keep defaults (rawConfig pointers are nil).
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
data := []byte("security:\n enabled: true\n buffer_streaming: true\n block_mode: replace\n")
if err := os.WriteFile(cfgPath, data, 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Absent → defaults preserved
if cfg.Server.Port != 9090 {
t.Errorf("Port = %d, want default 9090", cfg.Server.Port)
}
if cfg.Security.MaxBufferEvents != 50000 {
t.Errorf("MaxBufferEvents = %d, want default 50000", cfg.Security.MaxBufferEvents)
}
if cfg.Security.BufferTimeout != 120 {
t.Errorf("BufferTimeout = %d, want default 120", cfg.Security.BufferTimeout)
}
if cfg.Telemetry.SampleRate != 1.0 {
t.Errorf("SampleRate = %g, want default 1.0", cfg.Telemetry.SampleRate)
}
}

func TestLoad_ExplicitZeroPreserved(t *testing.T) {
// Explicit zero in YAML must be preserved (not replaced with default)
// so that Validate() can reject it.
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
data := []byte("server:\n port: 0\nsecurity:\n buffer_streaming: true\n max_buffer_events: 0\n buffer_timeout: 0\n")
if err := os.WriteFile(cfgPath, data, 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Server.Port != 0 {
t.Errorf("Port = %d, want explicit 0", cfg.Server.Port)
}
if cfg.Security.MaxBufferEvents != 0 {
t.Errorf("MaxBufferEvents = %d, want explicit 0", cfg.Security.MaxBufferEvents)
}
if cfg.Security.BufferTimeout != 0 {
t.Errorf("BufferTimeout = %d, want explicit 0", cfg.Security.BufferTimeout)
}
// Validate should reject these
if err := cfg.Validate(); err == nil {
t.Error("expected validation error for explicit zeros")
}
}

func TestValidate_ProviderURL(t *testing.T) {
cfg := DefaultConfig()
cfg.Upstream.Providers = map[string]ProviderConfig{
Expand Down Expand Up @@ -244,7 +300,7 @@ func TestValidate_BlockMode(t *testing.T) {

func TestValidate_MultipleErrors(t *testing.T) {
cfg := DefaultConfig()
cfg.Server.Port = 0
cfg.Server.Port = -1 // use -1 since mergeConfig would skip 0
cfg.Server.LogLevel = types.LogLevel("invalid")
cfg.Upstream.Timeout = -1
cfg.Telemetry.SampleRate = 5.0
Expand Down
Loading
Loading