diff --git a/README.md b/README.md index 1ce643a..8afdfe3 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,6 @@ **A provider-agnostic agentic coding assistant built in Go.** gnoma routes tasks to the best available LLM — cloud or local — through a multi-armed bandit router, while tools, hooks, skills, MCP servers, and plugins keep it extensible. Named after the northern pygmy-owl (*Glaucidium gnoma*); agents are called **elfs** (elf owl). - - - ## Quickstart ```sh diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 42e0f10..07b2327 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -325,8 +325,14 @@ func main() { logger.Debug("incognito mode enabled") } - // Permission checker with console prompt for pipe mode + // Permission checker with console prompt for pipe mode. + // In pure pipe mode (no TTY on stdin), auto-deny — Scanln would block on EOF. pipePromptFn := func(ctx context.Context, toolName string, args json.RawMessage) (bool, error) { + stat, _ := os.Stdin.Stat() + if stat.Mode()&os.ModeCharDevice == 0 { + fmt.Fprintf(os.Stderr, "⚠ Tool %s denied (no TTY for prompt, use --permission bypass to allow)\n", toolName) + return false, nil + } fmt.Fprintf(os.Stderr, "⚠ Tool %s wants to execute. Allow? [y/N] ", toolName) var response string fmt.Scanln(&response) @@ -388,7 +394,9 @@ func main() { // Build hook dispatcher from config + plugin hooks. // Streamer adapter wraps the router for prompt hooks. // ElfSpawnFn closure wraps elfMgr for agent hooks. - allHooks := append(cfg.Hooks, pluginResult.Hooks...) + allHooks := make([]gnomacfg.HookConfig, 0, len(cfg.Hooks)+len(pluginResult.Hooks)) + allHooks = append(allHooks, cfg.Hooks...) + allHooks = append(allHooks, pluginResult.Hooks...) hookDefs, err := hook.ParseHookDefs(allHooks) if err != nil { fmt.Fprintf(os.Stderr, "hook config error: %v\n", err) @@ -410,7 +418,9 @@ func main() { } // Start MCP servers (config + plugin) and register tools in the tool registry. - allMCPServers := append(cfg.MCPServers, pluginResult.MCPServers...) + allMCPServers := make([]gnomacfg.MCPServerConfig, 0, len(cfg.MCPServers)+len(pluginResult.MCPServers)) + allMCPServers = append(allMCPServers, cfg.MCPServers...) + allMCPServers = append(allMCPServers, pluginResult.MCPServers...) var mcpMgr *mcp.Manager if len(allMCPServers) > 0 { serverCfgs, err := mcp.ParseServerConfigs(allMCPServers) @@ -665,6 +675,7 @@ func main() { StartWithResumePicker: openResumePicker, Skills: skillReg, PluginInfos: buildPluginInfos(discoveredPlugins, enabledSet), + Version: buildVersion, }) p := tea.NewProgram(m) if _, err := p.Run(); err != nil { diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index 74d3c01..63c01ab 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -31,11 +31,26 @@ go test -bench=. -benchmem ./internal/router/ go run ./cmd/gnoma-bench/ --arms=5 --tasks=1000 --seed=42 ``` -## Results +## Results (M4 heuristic, 2026-04-12) -No benchmark results yet. This scaffold will be populated as M9 (Router Advanced) lands. +5 arms (Sonnet, Opus, GPT-4o, Qwen3:8b local, Mistral Large), 10 task types. AMD Ryzen 7 3700X. -### Planned comparisons +``` +BenchmarkScoreArm-16 3046383 392.5 ns/op 0 B/op 0 allocs/op +BenchmarkSelectBest-16 276529 4347 ns/op 0 B/op 0 allocs/op +BenchmarkFilterFeasible-16 1200006 1003 ns/op 504 B/op 10 allocs/op +BenchmarkRouterSelect-16 177916 6794 ns/op 1224 B/op 40 allocs/op +BenchmarkRouterSelectWithQuality-16 152180 7885 ns/op 1224 B/op 40 allocs/op +BenchmarkClassifyTask-16 122278 9780 ns/op 1536 B/op 14 allocs/op +``` + +Key observations: +- `ScoreArm` is zero-alloc at ~400ns — good headroom for M9 bandit sampling overhead +- Full `Select` (filter + score + pool reserve + commit) is ~7us per routing decision +- Quality tracker adds ~1us overhead (7.9us vs 6.8us) — acceptable for EMA lookups +- `ClassifyTask` at ~10us is dominated by `strings.Contains` keyword matching; a trie or compiled regex could reduce this if it becomes a bottleneck, but for per-request overhead it's negligible + +### Planned comparisons (M9) - Heuristic-only (M4) vs. bandit (M9) after 50, 200, 1000 observations - 2-arm (local + cloud) vs. 5-arm (mixed providers) scenarios diff --git a/internal/security/scanner.go b/internal/security/scanner.go index be2436f..f403832 100644 --- a/internal/security/scanner.go +++ b/internal/security/scanner.go @@ -175,7 +175,7 @@ func defaultPatterns() []SecretPattern { {"openai_api_key", `sk-(?:proj-)?[a-zA-Z0-9_-]{20,}`}, {"openai_svcacct_key", `sk-svcacct-[a-zA-Z0-9_-]{20,}`}, {"openai_admin_key", `sk-admin-[a-zA-Z0-9_-]{20,}`}, - {"mistral_api_key", `[a-zA-Z0-9]{32}(?:[a-zA-Z0-9]{0})`}, // 32-char; entropy-gated + {"mistral_api_key", `(?i)(?:mistral|MISTRAL)[_\s]*(?:api[_\s]*)?key[=:\s"']+([a-zA-Z0-9]{32})\b`}, // context-gated: requires "mistral" nearby {"huggingface_token", `hf_[a-zA-Z0-9]{34,}`}, // --- Cloud Providers --- diff --git a/internal/security/security_test.go b/internal/security/security_test.go index f970bf8..e7ccbea 100644 --- a/internal/security/security_test.go +++ b/internal/security/security_test.go @@ -99,6 +99,45 @@ func TestScanner_DetectsDatabaseURL(t *testing.T) { } } +func TestScanner_DetectsMistralKey(t *testing.T) { + s := NewScanner(6.0) + + // Should detect Mistral key in assignment contexts. + positives := []string{ + `MISTRAL_API_KEY=abcdefghijklmnop1234567890abcdef`, + `mistral_key = "abcdefghijklmnop1234567890abcdef"`, + `MISTRAL_API_KEY: abcdefghijklmnop1234567890abcdef`, + `export MISTRAL_API_KEY='abcdefghijklmnop1234567890abcdef'`, + } + for _, text := range positives { + matches := s.Scan(text) + found := false + for _, m := range matches { + if m.Pattern == "mistral_api_key" { + found = true + } + } + if !found { + t.Errorf("should detect Mistral key in: %s", text) + } + } + + // Should NOT false-positive on bare 32-char strings. + negatives := []string{ + `commit abcdefabcdefabcdefabcdefabcdefab`, // git hash + `uuid: 550e8400e29b41d4a716446655440000`, // UUID without dashes + `checksum = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"`, // generic checksum + } + for _, text := range negatives { + matches := s.Scan(text) + for _, m := range matches { + if m.Pattern == "mistral_api_key" { + t.Errorf("false positive on: %s", text) + } + } + } +} + func TestScanner_NoFalsePositives(t *testing.T) { s := NewScanner(6.0) // high entropy threshold to avoid false positives safe := []string{ diff --git a/internal/tui/app.go b/internal/tui/app.go index 8fceae6..571bc98 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -32,7 +32,8 @@ import ( "somegit.dev/Owlibou/gnoma/internal/stream" ) -const version = "v0.1.0-dev" +// version is set from Config.Version at init; falls back to "dev". +var version = "dev" type streamEventMsg struct{ event stream.Event } type turnDoneMsg struct{ err error } @@ -66,6 +67,7 @@ type Config struct { StartWithResumePicker bool // open session picker on launch Skills *skill.Registry // nil = no skills loaded PluginInfos []PluginInfo // discovered plugins for /plugins command + Version string // build version string (from ldflags) } // PluginInfo is a summary of an installed plugin for TUI display. @@ -118,6 +120,9 @@ type Model struct { } func New(sess session.Session, cfg Config) Model { + if cfg.Version != "" { + version = cfg.Version + } ti := textarea.New() ti.Placeholder = "Type a message... (Enter to send, Shift+Enter for newline)" ti.ShowLineNumbers = false