fix: append mutation, pipe-mode hang, Mistral regex false positives

- Fix append footgun: allHooks/allMCPServers allocated fresh to avoid
  mutating cfg's backing array (lines 391/413 in main.go)
- Fix pipe-mode permission prompt: detect no-TTY stdin and auto-deny
  instead of blocking forever on fmt.Scanln EOF
- Tighten Mistral API key regex from bare [a-zA-Z0-9]{32} (matched
  commit hashes, UUIDs) to context-gated pattern requiring "mistral"
  keyword nearby. Added scanner test for positives and negatives.
- Remove README demo GIF TODO placeholder
- Unify version string: pass buildVersion from ldflags into tui.Config
  instead of hardcoding "v0.1.0-dev"
- Populate benchmarks doc with actual Go benchmark results
This commit is contained in:
2026-04-12 03:49:47 +02:00
parent d7b524664d
commit e5a1d21f53
6 changed files with 78 additions and 11 deletions

View File

@@ -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).
<!-- TODO: replace with actual demo recording -->
<!-- ![demo](docs/assets/demo.gif) -->
## Quickstart
```sh

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 ---

View File

@@ -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{

View File

@@ -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