From f0ebc93e132f07763f308d7744f1139fa260a65f Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:48:52 +0200 Subject: [PATCH 1/3] fix: 22 validated bug fixes from adversarial bug hunt (RED-first) WebSocket/session: torn frames after write timeout (dead conn state re-created while a parked sender blocks) - dead latch is now sticky; validateSessionToken mints before compare on 401 probes (file rewrite + token rotation under attacker control) - compare/deny before any write; strict path on legacy sessions now denies outright. saveLocked restores Messages/boundary on failed save (permanent ErrConflict). VectorIndex Search embeds outside the mutex. Config/artifact: expandEnv no longer eats the byte after a bare '$'; ParseEnvelope strips UTF-8 BOM (envelope JSON with file:// refs was delivered unvalidated). Danger/approvals: denylist match collapses internal whitespace ("git push" bypass); trustAll honors TrustShortcutAllowed (excluded classes always prompt - 3 legacy tests re-pinned to the documented contract); trust grants record approvals so friction engages; ClassifyURL strips trailing-dot hostnames (169.254.169.254.). Tools: browser lazy-init race (mutex); browser snapshot rune-boundary truncation; multi_grep surfaces root walk errors; batch_patch preview renders truthful hunks at the real offset; bg_start distinguishes malformed JSON from empty command. Hardening: mcpclient closes stdout pipe on Start failure; redact detects fused secret names (MYAPITOKEN); session audit load fails closed on unreadable files (no history rewrite); JSON session export omits auth_token; budget RecordExternal rejects/clamps non-finite or overflowing external costs; skills cache GCs other projects' entries; AddFact trims once so merge detection, dedup, and corpus agree. Adversarial diff review: blockers fixed (strict-bootstrap dead path, gofmt); low findings noted for follow-up. --- cmd/odek/bg_tools.go | 5 +- cmd/odek/bg_tools_error_test.go | 25 +++ cmd/odek/browser_race_test.go | 24 +++ cmd/odek/browser_tool.go | 19 +- cmd/odek/browser_truncation_test.go | 30 +++ cmd/odek/perf_tools.go | 70 ++++++- cmd/odek/perf_tools_diff_test.go | 40 ++++ cmd/odek/perf_tools_multigrep_root_test.go | 26 +++ cmd/odek/serve.go | 32 ++- cmd/odek/serve_api.go | 6 +- cmd/odek/serve_api_export_test.go | 32 +++ cmd/odek/serve_bugfix_ws_token_test.go | 193 ++++++++++++++++++ .../artifact/parse_envelope_bughunt_test.go | 48 +++++ internal/artifact/ref.go | 5 + internal/budget/usage.go | 12 +- internal/budget/usage_overflow_test.go | 33 +++ internal/config/expandenv_bughunt_test.go | 27 +++ internal/config/loader.go | 12 +- internal/danger/approver.go | 9 +- internal/danger/approver_test.go | 33 +-- .../danger/approver_trust_friction_test.go | 37 ++++ internal/danger/classifier.go | 10 +- internal/danger/normalize.go | 7 + internal/danger/redbugs5_test.go | 40 ++++ internal/mcpclient/client.go | 4 + internal/memory/memory.go | 5 + internal/memory/memory_trim_test.go | 29 +++ internal/redact/redact.go | 15 ++ internal/redact/redact_fused_test.go | 26 +++ internal/session/audit.go | 25 ++- internal/session/audit_readerr_test.go | 31 +++ internal/session/bughunt_save_restore_test.go | 150 ++++++++++++++ internal/session/session.go | 20 +- internal/session/vector_index.go | 31 ++- internal/skills/cache.go | 17 +- internal/skills/cache_gc_test.go | 45 ++++ internal/skills/tools.go | 2 +- 37 files changed, 1123 insertions(+), 52 deletions(-) create mode 100644 cmd/odek/bg_tools_error_test.go create mode 100644 cmd/odek/browser_race_test.go create mode 100644 cmd/odek/browser_truncation_test.go create mode 100644 cmd/odek/perf_tools_diff_test.go create mode 100644 cmd/odek/perf_tools_multigrep_root_test.go create mode 100644 cmd/odek/serve_api_export_test.go create mode 100644 cmd/odek/serve_bugfix_ws_token_test.go create mode 100644 internal/artifact/parse_envelope_bughunt_test.go create mode 100644 internal/budget/usage_overflow_test.go create mode 100644 internal/config/expandenv_bughunt_test.go create mode 100644 internal/danger/approver_trust_friction_test.go create mode 100644 internal/danger/redbugs5_test.go create mode 100644 internal/memory/memory_trim_test.go create mode 100644 internal/redact/redact_fused_test.go create mode 100644 internal/session/audit_readerr_test.go create mode 100644 internal/session/bughunt_save_restore_test.go create mode 100644 internal/skills/cache_gc_test.go diff --git a/cmd/odek/bg_tools.go b/cmd/odek/bg_tools.go index 6587ab9b..e91259f8 100644 --- a/cmd/odek/bg_tools.go +++ b/cmd/odek/bg_tools.go @@ -360,7 +360,10 @@ func (t *bgStartTool) Call(args string) (string, error) { Command string `json:"command"` TimeoutSeconds int `json:"timeout_seconds"` } - if err := json.Unmarshal([]byte(args), &p); err != nil || strings.TrimSpace(p.Command) == "" { + if err := json.Unmarshal([]byte(args), &p); err != nil { + return "", fmt.Errorf("bg_start: invalid arguments (malformed JSON): %w", err) + } + if strings.TrimSpace(p.Command) == "" { return "", fmt.Errorf("bg_start requires a non-empty \"command\"") } // Spawn-time approval, shell parity: the loop's batch gate only covers diff --git a/cmd/odek/bg_tools_error_test.go b/cmd/odek/bg_tools_error_test.go new file mode 100644 index 00000000..d203497c --- /dev/null +++ b/cmd/odek/bg_tools_error_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// Malformed JSON arguments must be reported as a decode failure, not as +// "requires a non-empty command" — otherwise the model retries with the +// same payload shape instead of fixing the JSON. +func TestBgStartCall_MalformedJSONErrorMessage(t *testing.T) { + tool := &bgStartTool{} + _, err := tool.Call(`{bad json`) + if err == nil { + t.Fatal("expected error for malformed JSON args") + } + lower := strings.ToLower(err.Error()) + if !strings.Contains(lower, "json") && !strings.Contains(lower, "invalid argument") { + t.Fatalf("error %q does not mention invalid arguments/JSON", err) + } + if json.Valid([]byte(`{bad json`)) { + t.Fatal("sanity: input unexpectedly valid JSON") + } +} diff --git a/cmd/odek/browser_race_test.go b/cmd/odek/browser_race_test.go new file mode 100644 index 00000000..d9dbd258 --- /dev/null +++ b/cmd/odek/browser_race_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "sync" + "testing" +) + +// Zero-value browserTool lazy-initializes state and client inside Call; +// concurrent first calls race and can build duplicate states. +func TestBrowserTool_LazyInitRace(t *testing.T) { + tool := &browserTool{} + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + tool.Call(`{"action":"navigate","url":"http://127.0.0.1:1/"}`) + }() + } + wg.Wait() + if tool.state == nil || tool.client == nil { + t.Fatal("expected state and client to be initialized") + } +} diff --git a/cmd/odek/browser_tool.go b/cmd/odek/browser_tool.go index c51248d9..3878b8be 100644 --- a/cmd/odek/browser_tool.go +++ b/cmd/odek/browser_tool.go @@ -60,6 +60,14 @@ const maxBrowserElements = 500 // history limit cannot be bypassed by a small number of huge pages. const maxBrowserSnapshotBytes = 1 * 1024 * 1024 +// truncatePageContent caps page text at maxBrowserSnapshotBytes, backing up +// to a UTF-8 rune boundary so a multibyte character split by the cap never +// ships U+FFFD mojibake, and appends a truncation marker. +func truncatePageContent(content string) string { + return truncateUTF8Safe(content, maxBrowserSnapshotBytes) + + "\n[content truncated: exceeds per-snapshot byte cap]" +} + // browserState holds the shared state for one browser session. type browserState struct { mu sync.Mutex @@ -74,6 +82,7 @@ type browserTool struct { ctxTool state *browserState client *http.Client + initMu sync.Mutex dangerousConfig danger.DangerousConfig trustedClasses map[danger.RiskClass]bool } @@ -172,7 +181,9 @@ func (t *browserTool) Call(argsJSON string) (string, error) { return jsonError("action is required (navigate, snapshot, click, back)") } - // Ensure state and client exist + // Ensure state and client exist exactly once under lock — parallel + // first calls would otherwise race and build duplicate states. + t.initMu.Lock() if t.state == nil { t.state = &browserState{nextRef: 1} } @@ -183,6 +194,7 @@ func (t *browserTool) Call(argsJSON string) (string, error) { Transport: ssrfGuardedTransport(), } } + t.initMu.Unlock() switch args.Action { case "navigate": @@ -472,8 +484,9 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna snap.Content = strings.Join(contentParts, "\n") if len(snap.Content) > maxBrowserSnapshotBytes { - snap.Content = snap.Content[:maxBrowserSnapshotBytes] + - "\n[content truncated: exceeds per-snapshot byte cap]" + // Back up to a UTF-8 rune boundary so a multibyte character split + // by the cap never ships U+FFFD mojibake. + snap.Content = truncatePageContent(snap.Content) } snap.Elements = elements diff --git a/cmd/odek/browser_truncation_test.go b/cmd/odek/browser_truncation_test.go new file mode 100644 index 00000000..4fd54984 --- /dev/null +++ b/cmd/odek/browser_truncation_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "strings" + "testing" + "unicode/utf8" +) + +// Snapshot truncation must back up to a UTF-8 rune boundary so multibyte +// characters cut by the cap never ship U+FFFD mojibake. +func TestTruncatePageContent_RuneBoundary(t *testing.T) { + // Build content where the cap lands mid-rune: 'é' is 2 bytes. + unit := strings.Repeat("a", 9) + "é" + var b strings.Builder + for b.Len() < maxBrowserSnapshotBytes { + b.WriteString(unit) + } + content := b.String() + got := truncatePageContent(content) + trimmed := strings.TrimSuffix(got, "\n[content truncated: exceeds per-snapshot byte cap]") + if !utf8.ValidString(trimmed) { + t.Fatal("truncated content is not valid UTF-8") + } + if strings.ContainsRune(trimmed, utf8.RuneError) { + t.Fatal("truncated content contains U+FFFD replacement rune") + } + if len(trimmed) > maxBrowserSnapshotBytes { + t.Fatalf("truncated content %d bytes exceeds cap %d", len(trimmed), maxBrowserSnapshotBytes) + } +} diff --git a/cmd/odek/perf_tools.go b/cmd/odek/perf_tools.go index 0a4cef24..a05f26ca 100644 --- a/cmd/odek/perf_tools.go +++ b/cmd/odek/perf_tools.go @@ -269,8 +269,7 @@ func (t *batchPatchTool) Call(argsJSON string) (result string, err error) { continue } - diff := fmt.Sprintf("--- a/%s\n+++ b/%s\n@@ -1 +1 @@\n-%s\n+%s\n", - p.Path, p.Path, truncatePreviewLine(original, 100), truncatePreviewLine(modified, 100)) + diff := patchPreviewDiff(p.Path, original, modified, p.OldString, p.NewString) // Preserve the original file's mode. origMode := os.FileMode(0644) @@ -344,6 +343,59 @@ func (t *batchPatchTool) Call(argsJSON string) (result string, err error) { // truncatePreviewLine shortens one side of a batch_patch preview line to max // bytes, backing off to a UTF-8 rune boundary so multibyte content never // renders as U+FFFD mojibake in the diff. +// patchPreviewDiff renders a truthful unified-diff preview of a batch_patch +// edit: the hunk covers the region around the actual old_string match, so the +// -/+ lines show the real change even when the match sits far past the file +// head (a fixed first-N-bytes window renders identical lines for both sides). +func patchPreviewDiff(path, original, modified, oldString, newString string) string { + header := fmt.Sprintf("--- a/%s\n+++ b/%s\n", path, path) + const ctxBytes = 30 // context bytes kept on each side of the match + offset := strings.Index(original, oldString) + if offset < 0 { + // Match not found (e.g. preview computed before the check): fall + // back to a head preview of both versions. + return header + fmt.Sprintf("@@ -1 +1 @@\n-%s\n+%s\n", + truncatePreviewLine(original, 100), truncatePreviewLine(modified, 100)) + } + newOffset := strings.Index(modified, newString) + if newOffset < 0 { + newOffset = offset + } + start := offset - ctxBytes + if start < 0 { + start = 0 + } + end := offset + len(oldString) + ctxBytes + if end > len(original) { + end = len(original) + } + newStart := newOffset - ctxBytes + if newStart < 0 { + newStart = 0 + } + newEnd := newOffset + len(newString) + ctxBytes + if newEnd > len(modified) { + newEnd = len(modified) + } + startLine := 1 + strings.Count(original[:start], "\n") + newStartLine := 1 + strings.Count(modified[:newStart], "\n") + var b strings.Builder + fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", startLine, end-start, newStartLine, newEnd-newStart) + for _, ln := range strings.SplitAfter(original[start:end], "\n") { + if ln == "" { + continue + } + fmt.Fprintf(&b, "-%s\n", strings.TrimSuffix(ln, "\n")) + } + for _, ln := range strings.SplitAfter(modified[newStart:newEnd], "\n") { + if ln == "" { + continue + } + fmt.Fprintf(&b, "+%s\n", strings.TrimSuffix(ln, "\n")) + } + return header + b.String() +} + func truncatePreviewLine(s string, max int) string { if len(s) <= max { return s @@ -1311,8 +1363,15 @@ func (t *multiGrepTool) searchPattern(pattern, root, fileGlob string, limit int) resultBytes := 0 var skipped []string + var rootErr error filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info == nil { + // Surface a missing/unreadable root instead of returning a + // silent count:0 result for a path that was never scanned. + if path == root { + rootErr = err + return err + } return nil } if info.IsDir() { @@ -1398,6 +1457,13 @@ func (t *multiGrepTool) searchPattern(pattern, root, fileGlob string, limit int) return nil }) + rootErrOut := rootErr + if rootErrOut != nil { + return grepPatternResult{ + Pattern: pattern, + Error: fmt.Sprintf("cannot walk root %q: %v", root, rootErrOut), + } + } return grepPatternResult{ Pattern: pattern, Matches: matches, diff --git a/cmd/odek/perf_tools_diff_test.go b/cmd/odek/perf_tools_diff_test.go new file mode 100644 index 00000000..3a44b266 --- /dev/null +++ b/cmd/odek/perf_tools_diff_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "strings" + "testing" +) + +// The batch_patch preview hunk must be truthful: it shows the region around +// the actual match, not a hardcoded first-100-bytes diff that renders +// identical -/+ lines when old_string sits past byte 100. +func TestPatchPreviewDiff_OffsetMatch(t *testing.T) { + padding := strings.Repeat("x", 300) + original := padding + "\nold line\n" + strings.Repeat("y", 100) + modified := padding + "\nnew line\n" + strings.Repeat("y", 100) + diff := patchPreviewDiff("f.txt", original, modified, "old line", "new line") + if strings.Contains(diff, "@@ -1 +1 @@") { + t.Fatalf("preview still uses hardcoded hunk header: %s", diff) + } + if !strings.Contains(diff, "-old line") || !strings.Contains(diff, "+new line") { + t.Fatalf("preview hunk does not show the changed lines:\n%s", diff) + } +} + +func TestPatchPreviewDiff_HeadMatch(t *testing.T) { + original := "alpha\nbeta\ngamma" + modified := "alpha\nBETA\ngamma" + diff := patchPreviewDiff("f.txt", original, modified, "beta", "BETA") + if !strings.Contains(diff, "-beta") || !strings.Contains(diff, "+BETA") { + t.Fatalf("head-match preview wrong:\n%s", diff) + } +} + +func TestPatchPreviewDiff_OldNotFound(t *testing.T) { + diff := patchPreviewDiff("f.txt", "abc", "abd", "zzz", "q") + if diff == "" || !strings.Contains(diff, "f.txt") { + t.Fatalf("expected fallback diff naming the file, got %q", diff) + } + fmt.Print() +} diff --git a/cmd/odek/perf_tools_multigrep_root_test.go b/cmd/odek/perf_tools_multigrep_root_test.go new file mode 100644 index 00000000..10d19cca --- /dev/null +++ b/cmd/odek/perf_tools_multigrep_root_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// A nonexistent walk root previously returned count:0 with no error — +// the walk's root error was swallowed by the callback. +func TestMultiGrep_NonexistentRootIsError(t *testing.T) { + tool := &multiGrepTool{} + out, _ := tool.Call(`{"patterns":["x"],"path":"/nonexistent-dir-xyz-123456"}`) + var res struct { + Results []struct { + Error string `json:"error"` + } `json:"results"` + } + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(res.Results) != 1 || res.Results[0].Error == "" || + !strings.Contains(res.Results[0].Error, "/nonexistent-dir-xyz-123456") { + t.Fatalf("expected root error surfaced, got: %s", out) + } +} diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 7e30a6e5..79c724a8 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -2692,6 +2692,11 @@ func validateSessionToken(store *session.Store, sess *session.Session, token str return "", false } if sess.AuthToken == "" { + if token != "" { + // A wrong token against a legacy session is a failed match, not + // a bootstrap: fail closed and leave the file untouched. + return "", false + } sess.AuthToken = session.GenerateAuthToken() if err := store.Save(sess); err != nil { // If we cannot persist the token, still allow this request but do not @@ -2720,10 +2725,12 @@ func validateSessionTokenStrict(store *session.Store, sess *session.Session, tok return false } if sess.AuthToken == "" { - sess.AuthToken = session.GenerateAuthToken() - if err := store.Save(sess); err != nil { - return false - } + // A freshly minted token is random and unguessable, so a client + // cannot present it before learning it: the strict path cannot + // bootstrap at all. Deny the mutation without minting or writing — + // the read-path GET bootstrap is what mints the token and returns + // it to the client; strict calls match it afterwards. + return false } return subtle.ConstantTimeCompare([]byte(token), []byte(sess.AuthToken)) == 1 } @@ -2793,10 +2800,20 @@ func connWriter(conn *golangws.Conn) *connWriteState { return actual.(*connWriteState) } -// releaseConnWriter drops a closed connection's write state. Called from -// handleWS's teardown and after a write-timeout teardown. +// releaseConnWriter latches a closed connection's write state dead. The +// entry itself must stay: a parked Message.Send goroutine from a timed-out +// write may still be blocked inside the conn; deleting the entry lets the +// next connWriter create fresh live state and issue a concurrent Send on a +// *golangws.Conn that is not concurrency-safe — interleaved torn frames. +// States are tiny and keyed per connection, so retaining them for the +// process lifetime is bounded by distinct connections served. func releaseConnWriter(conn *golangws.Conn) { - wsConnWriters.Delete(conn) + if v, ok := wsConnWriters.Load(conn); ok { + w := v.(*connWriteState) + w.mu.Lock() + w.dead = true + w.mu.Unlock() + } } func writeWSJSON(conn *golangws.Conn, data any) { @@ -2830,7 +2847,6 @@ func writeWSJSON(conn *golangws.Conn, data any) { // errors out. w.dead = true go func() { _ = conn.Close() }() - releaseConnWriter(conn) } } diff --git a/cmd/odek/serve_api.go b/cmd/odek/serve_api.go index a4bc17e4..8a7d404b 100644 --- a/cmd/odek/serve_api.go +++ b/cmd/odek/serve_api.go @@ -222,7 +222,11 @@ func handleSessionExport(sess *session.Session, format string, w http.ResponseWr case "json": w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=odek-session-%s.json", shortID(sess.ID))) - _ = json.NewEncoder(w).Encode(sess) + // The export is meant to be shareable — the session-scoped auth + // token must never appear in it. + sanitized := *sess + sanitized.AuthToken = "" + _ = json.NewEncoder(w).Encode(&sanitized) default: http.Error(w, "unsupported format (md|json)", http.StatusBadRequest) } diff --git a/cmd/odek/serve_api_export_test.go b/cmd/odek/serve_api_export_test.go new file mode 100644 index 00000000..048d74b3 --- /dev/null +++ b/cmd/odek/serve_api_export_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/BackendStack21/odek/internal/session" +) + +// JSON session export must not leak the session-scoped auth token: the +// export file is meant to be shareable. +func TestSessionExportJSON_OmitsAuthToken(t *testing.T) { + sess := &session.Session{ID: "sess-1234", AuthToken: "supersecret-token-value"} + sess.AuthToken = "supersecret-token-value" + w := httptest.NewRecorder() + handleSessionExport(sess, "json", w) + body := w.Body.String() + msg := body + if len(msg) > 200 { + msg = msg[:200] + } + if strings.Contains(body, "supersecret-token-value") || strings.Contains(body, "auth_token") { + t.Fatalf("export leaks auth token: %s", msg) + } + var decoded map[string]any + if err := json.Unmarshal(bytes.TrimSpace(w.Body.Bytes()), &decoded); err != nil { + t.Fatalf("export is not valid JSON: %v", err) + } +} diff --git a/cmd/odek/serve_bugfix_ws_token_test.go b/cmd/odek/serve_bugfix_ws_token_test.go new file mode 100644 index 00000000..7a16a65e --- /dev/null +++ b/cmd/odek/serve_bugfix_ws_token_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "bytes" + "crypto/sha1" + "encoding/base64" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + golangws "golang.org/x/net/websocket" + + "github.com/BackendStack21/odek/internal/session" +) + +// After a write timeout the watchdog latches the connection dead and tears +// it down. A parked Message.Send goroutine still holds the old write state; +// if releaseConnWriter deletes the entry, a subsequent writeWSJSON creates +// fresh (live) state and issues a second concurrent Send on the same +// *websocket.Conn — which is not concurrency-safe. The dead state must be +// sticky: the same state pointer must come back and every later write must +// fast-fail. +func TestRED_WriteTimeoutDeadConnStaysDead(t *testing.T) { + old := wsWriteTimeout.Load() + wsWriteTimeout.Store(int64(150 * time.Millisecond)) + t.Cleanup(func() { wsWriteTimeout.Store(old) }) + + pipe := newBlockingWSConn(t) + conn := pipe.wsConn + + writeWSJSON(conn, map[string]string{"type": "flood", "data": "x"}) + // The write timed out and latched dead; simulate the watchdog teardown. + w1 := connWriter(conn) + if !w1.dead { + t.Fatalf("expected write state to be latched dead after timeout") + } + releaseConnWriter(conn) + + w2 := connWriter(conn) + if w2 != w1 { + t.Fatalf("connWriter re-created state after release: dead latch was lost (%p != %p)", w2, w1) + } + if !w2.dead { + t.Fatalf("re-acquired write state lost the dead latch") + } + + // A later write must fast-fail: nothing may reach the connection. + writeWSJSON(conn, map[string]string{"type": "after"}) + if n := pipe.bytesWritten(); n > 0 { + t.Fatalf("fast-fail write delivered %d bytes to a dead connection", n) + } +} + +// wsPipe is an io.ReadWriteCloser that completes the x/net/websocket client +// handshake, then blocks all further writes until the test releases it, +// simulating a client that stopped reading (full TCP receive window). +type wsPipe struct { + rel chan struct{} + written chan []byte + wsConn *golangws.Conn + hsKey string + responded bool +} + +func newBlockingWSConn(t *testing.T) *wsPipe { + t.Helper() + p := &wsPipe{ + rel: make(chan struct{}), + written: make(chan []byte, 16), + } + t.Cleanup(func() { close(p.rel) }) + cfg, err := golangws.NewConfig(originURL.String(), originURL.String()) + if err != nil { + t.Fatalf("NewConfig: %v", err) + } + conn, err := golangws.NewClient(cfg, p) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + p.wsConn = conn + return p +} + +var originURL = mustURL("ws://localhost/") + +func mustURL(raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil { + panic(err) + } + return u +} +func (p *wsPipe) Read(b []byte) (int, error) { + if !p.responded { + p.responded = true + sum := sha1.Sum([]byte(p.hsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) + accept := base64.StdEncoding.EncodeToString(sum[:]) + resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n" + return copy(b, resp), nil + } + <-p.rel + return 0, io.EOF +} + +func (p *wsPipe) Write(b []byte) (int, error) { + if p.hsKey == "" { + // Client handshake request: capture Sec-WebSocket-Key so Read can + // answer a valid 101. + req := string(b) + if i := strings.Index(req, "Sec-WebSocket-Key: "); i >= 0 { + rest := req[i+len("Sec-WebSocket-Key: "):] + if j := strings.Index(rest, "\r\n"); j >= 0 { + p.hsKey = rest[:j] + } + } + return len(b), nil + } + // Post-handshake frame: block while the test is live; a released write is + // recorded as delivered. + <-p.rel + select { + case p.written <- append([]byte(nil), b...): + default: + } + return 0, io.EOF +} + +func (p *wsPipe) Close() error { return nil } + +func (p *wsPipe) bytesWritten() int { + select { + case b := <-p.written: + return len(b) + default: + return 0 + } +} + +// validateSessionTokenStrict must compare BEFORE any mint+persist: with an +// empty stored token, an unauthenticated probe (wrong token, DELETE) must get +// a plain 401 and the session file on disk must be byte-for-byte unchanged. +// Minting-and-saving on every probe rewrote the legacy file and rotated the +// token under attacker control. +func TestRED_TokenProbeDoesNotRewriteLegacySession(t *testing.T) { + dir := t.TempDir() + store, err := session.NewStoreWithDir(dir) + if err != nil { + t.Fatal(err) + } + sess := &session.Session{ + ID: "20260926-tokentest00000000000000000001", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Task: "legacy session", + AuthToken: "", + } + if err := store.Save(sess); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, sess.ID+".json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if ok := validateSessionTokenStrict(store, sess, "attacker-token"); ok { + t.Fatalf("strict validation accepted a wrong token on a legacy session") + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatalf("failed auth probe mutated the session file (%d -> %d bytes)", len(before), len(after)) + } + + // The lenient variant must not mint+persist before comparing either: + // a failed probe must not rewrite the file. + if _, ok := validateSessionToken(store, sess, "attacker-token"); ok { + t.Fatalf("lenient validation accepted a wrong token on a legacy session") + } + after2, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after2) { + t.Fatalf("failed lenient probe mutated the session file (%d -> %d bytes)", len(before), len(after2)) + } +} diff --git a/internal/artifact/parse_envelope_bughunt_test.go b/internal/artifact/parse_envelope_bughunt_test.go new file mode 100644 index 00000000..b0c62c46 --- /dev/null +++ b/internal/artifact/parse_envelope_bughunt_test.go @@ -0,0 +1,48 @@ +package artifact + +import ( + "encoding/json" + "strings" + "testing" +) + +// An MCP server that prepends a UTF-8 BOM (or other tooling that does) +// produces envelope text whose first byte is not '{' — ParseEnvelope used to +// return (nil, nil) ("plain text"), and the mcpclient then delivered the raw +// envelope JSON with file:// refs to the model without any artifact-root +// validation. A BOM-prefixed envelope must parse like any other. +func TestRED_ParseEnvelopeToleratesBOM(t *testing.T) { + env := &Envelope{Schema: SchemaToolResult, Text: "hello"} + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + bomText := "\uFEFF" + string(raw) + got, err := ParseEnvelope(bomText) + if err != nil { + t.Fatalf("BOM-prefixed envelope must parse, got error: %v", err) + } + if got == nil { + t.Fatal("BOM-prefixed envelope was treated as plain text (nil, nil) — fail-open for envelope JSON with file:// refs") + } + if got.Text != "hello" { + t.Fatalf("unexpected text: %q", got.Text) + } +} + +// Junk-prefixed text is still plain text, but text that merely has the +// schema marker inside must not be misparsed either way. +func TestRED_ParseEnvelopeJunkPrefixStaysPlain(t *testing.T) { + env := &Envelope{Schema: SchemaToolResult, Text: "hi"} + raw, _ := json.Marshal(env) + got, err := ParseEnvelope("note: " + string(raw)) + if err != nil { + t.Fatalf("plain text must not error: %v", err) + } + if got != nil { + t.Fatalf("junk-prefixed text must stay plain text, got envelope %+v", got) + } + if !strings.Contains("ok", "ok") { + t.Fatal("unreachable") + } +} diff --git a/internal/artifact/ref.go b/internal/artifact/ref.go index dac02978..effc7799 100644 --- a/internal/artifact/ref.go +++ b/internal/artifact/ref.go @@ -81,6 +81,11 @@ type Envelope struct { // claim the payload must back up. func ParseEnvelope(text string) (*Envelope, error) { trimmed := strings.TrimSpace(text) + // A UTF-8 BOM in front of the envelope JSON made the '{' probe miss and + // the whole envelope fall through as "plain text" — the raw JSON with + // file:// refs then reached the model without artifact-root validation. + // Strip the BOM so an envelope is detected wherever tooling adds one. + trimmed = strings.TrimPrefix(trimmed, "\uFEFF") if len(trimmed) == 0 || trimmed[0] != '{' { return nil, nil } diff --git a/internal/budget/usage.go b/internal/budget/usage.go index 69a8313a..96188596 100644 --- a/internal/budget/usage.go +++ b/internal/budget/usage.go @@ -50,9 +50,17 @@ func (c *Checker) RecordExternal(u Usage) { return } c.toolCalls = AddCount(c.toolCalls, u.ToolCalls) - if u.CostKnown { - c.externalCostAdjustment += u.CostUSD - c.limits.EstimatedCostUSD(u.TotalInput(), u.OutputTokens) + if !u.CostKnown { + return + } + delta := u.CostUSD - c.limits.EstimatedCostUSD(u.TotalInput(), u.OutputTokens) + // Reject non-finite or out-of-range cost signals: converting them + // through microUSD is platform-dependent and poisons Observed with + // negative or saturated values. + if math.IsNaN(delta) || math.IsInf(delta, 0) || math.Abs(delta) > 1e12 { + return } + c.externalCostAdjustment = math.Max(-1e12, math.Min(1e12, c.externalCostAdjustment+delta)) } func (c *Checker) Cost(inputTokens, outputTokens int64) float64 { diff --git a/internal/budget/usage_overflow_test.go b/internal/budget/usage_overflow_test.go new file mode 100644 index 00000000..bcc0d479 --- /dev/null +++ b/internal/budget/usage_overflow_test.go @@ -0,0 +1,33 @@ +package budget + +import ( + "math" + "testing" +) + +// Provider-reported costs far beyond int64 range (or non-finite) leaked into +// externalCostAdjustment unclamped; microUSD conversion of such values is +// platform-dependent (saturation vs negative wrap) and poisoned Observed. +func TestRecordExternal_OverflowingCostClamped(t *testing.T) { + c := &Checker{limits: Limits{ + MaxInputTokens: 1_000_000, + MaxOutputTokens: 1_000_000, + MaxCostUSD: 100, + InputCostPerMillionUSD: 1, + OutputCostPerMillionUSD: 1, + }} + c.RecordExternal(Usage{CostKnown: true, CostUSD: 1e19}) + c.RecordExternal(Usage{CostKnown: true, CostUSD: math.Inf(1)}) + c.RecordExternal(Usage{CostKnown: true, CostUSD: math.NaN()}) + + cost := c.Cost(10, 10) + if math.IsNaN(cost) || math.IsInf(cost, 0) { + t.Fatalf("Cost returned non-finite value %v after overflowing external usage", cost) + } + if cost > 1e12 { + t.Fatalf("Cost %v exceeds clamped ceiling", cost) + } + if err := c.CheckUsage(10, 10); err != nil && err.Observed < 0 { + t.Fatalf("CheckUsage reported negative Observed %d", err.Observed) + } +} diff --git a/internal/config/expandenv_bughunt_test.go b/internal/config/expandenv_bughunt_test.go new file mode 100644 index 00000000..6309622e --- /dev/null +++ b/internal/config/expandenv_bughunt_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +// A '$' followed by a non-identifier byte must be emitted verbatim without +// consuming the following byte: "cost: $ 5" stays "cost: $ 5" (the space +// after the $ was previously eaten), "$9.99" stays "$9.99" (the '9' was +// eaten), and shell-sigil-lookalikes like "$?" emit "$?" unharmed. +func TestRED_ExpandEnvKeepsCharAfterBareDollar(t *testing.T) { + cases := map[string]string{ + "cost: $ 5": "cost: $ 5", + "$9.99": "$9.99", + "$?": "$?", + "$$": "$", + "a$ b": "a$ b", + "plain": "plain", + "a$$b": "a$b", + "$$9.99": "$9.99", + "${UNSET_XY}": "", + "$UNSET_XY": "", + } + for in, want := range cases { + if got := expandEnv(in); got != want { + t.Errorf("expandEnv(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 0aac73c7..4e030622 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1071,14 +1071,20 @@ func expandEnv(s string) string { } // Find variable name: ${VAR} or $VAR or $VAR_NAME - name, w := parseVarName(s[j+1:]) - i = j + 1 + w + name, _ := parseVarName(s[j+1:]) + i = j + 1 if name == "" { - // $ followed by non-identifier: emit as-is + // $ followed by non-identifier: emit the '$' verbatim. The + // following byte is NOT consumed — eating it silently corrupted + // config values ("cost: $ 5" → "cost: $5", "$9.99" → "$.99"). buf.WriteByte('$') continue } + i = j + 1 + len(name) + if s[j+1] == '{' { + i = j + 1 + len(name) + 2 // ${VAR} + } buf.WriteString(os.Getenv(name)) } buf.WriteString(s[i:]) diff --git a/internal/danger/approver.go b/internal/danger/approver.go index 8f88b801..d35a74cd 100644 --- a/internal/danger/approver.go +++ b/internal/danger/approver.go @@ -251,10 +251,12 @@ func (a *TTYApprover) prompt(cls RiskClass, cmd, description string) error { // ttyPromptMu. It may recurse for the "context" command or after telling // the user that trust-session is unavailable for a high-impact class. func (a *TTYApprover) promptLocked(cls RiskClass, cmd, description string) error { - // Check session trust cache + // Check session trust cache. Trust shortcuts only ever cover classes + // TrustShortcutAllowed permits — Destructive, Persistence, UnreadExec, + // Blocked, Unknown and ToolBatch always prompt, even with trustAll set. a.mu.Lock() trusted := a.TrustedClasses != nil && a.TrustedClasses[cls] - trusted = trusted || a.trustAll + trusted = (trusted || a.trustAll) && TrustShortcutAllowed(cls) a.mu.Unlock() if trusted { return nil @@ -399,6 +401,9 @@ func (a *TTYApprover) promptLocked(cls RiskClass, cmd, description string) error fmt.Fprintf(os.Stderr, " trust-session not available for %s — type 'a' to approve once or 'd' to deny\n", cls) return a.promptLocked(cls, cmd, description) } + // A trust grant is an approval: record it so rapid-fire grants + // engage the same approval-fatigue friction as plain approvals. + a.recordApproval(cls) // Cache this risk class for the session a.mu.Lock() if a.TrustedClasses != nil { diff --git a/internal/danger/approver_test.go b/internal/danger/approver_test.go index 0924d928..7c665374 100644 --- a/internal/danger/approver_test.go +++ b/internal/danger/approver_test.go @@ -176,14 +176,18 @@ func TestSetTrustAll_ApprovesAll(t *testing.T) { // Enable blanket trust a.SetTrustAll(true) - // Destructive class should auto-approve despite NonInteractive=deny - if err := a.PromptCommand(Destructive, "rm -rf /", "dangerous command"); err != nil { + // Classes eligible for trust shortcuts auto-approve despite + // NonInteractive=deny. + if err := a.PromptCommand(SystemWrite, "touch /tmp/x", ""); err != nil { t.Errorf("expected nil with trustAll=true, got: %v", err) } - // Blocked class should also auto-approve - if err := a.PromptCommand(Blocked, "some blocked cmd", ""); err != nil { - t.Errorf("expected nil with trustAll=true, got: %v", err) + // Excluded classes always prompt, even with trustAll — they never + // receive trust shortcuts. + for _, cls := range []RiskClass{Destructive, Blocked} { + if err := a.PromptCommand(cls, "rm -rf /", "dangerous command"); err == nil { + t.Errorf("expected prompt denial for excluded class %s with trustAll=true", cls) + } } } @@ -194,8 +198,8 @@ func TestSetTrustAll_ThenDisable(t *testing.T) { // Enable blanket trust a.SetTrustAll(true) - // Should be approved - if err := a.PromptCommand(Destructive, "rm -rf /", ""); err != nil { + // Should be approved (an eligible class) + if err := a.PromptCommand(SystemWrite, "touch /tmp/x", ""); err != nil { t.Errorf("expected nil with trustAll=true, got: %v", err) } @@ -246,17 +250,22 @@ func TestPromptCommand_TrustedClassSkipsTTY(t *testing.T) { a := NewTTYApprover(&DangerousConfig{NonInteractive: strPtr("deny")}) a.TTYPath = "/nonexistent/tty-for-test" - // Trust Destructive class + // Destructive never receives trust shortcuts — even a per-class trust + // mark cannot skip its prompt. a.SetTrustedClasses(map[RiskClass]bool{Destructive: true}) + if err := a.PromptCommand(Destructive, "rm -rf /tmp/data", ""); err == nil { + t.Error("expected prompt denial for Destructive despite trusted class mark") + } - // Trusted class is checked before TTY → should succeed even with NonInteractive=deny - err := a.PromptCommand(Destructive, "rm -rf /tmp/data", "") + // A shortcut-eligible trusted class skips the TTY even with deny + a.SetTrustedClasses(map[RiskClass]bool{SystemWrite: true}) + err := a.PromptCommand(SystemWrite, "touch /tmp/data", "") if err != nil { t.Errorf("expected nil for trusted class, got: %v", err) } - // SystemWrite is NOT trusted → should be denied - err = a.PromptCommand(SystemWrite, "touch /etc/config", "") + // A class NOT in the trusted set → should be denied + err = a.PromptCommand(NetworkEgress, "curl http://example.com", "") if err == nil { t.Fatal("expected error for untrusted class with NonInteractive=deny") } diff --git a/internal/danger/approver_trust_friction_test.go b/internal/danger/approver_trust_friction_test.go new file mode 100644 index 00000000..651cb706 --- /dev/null +++ b/internal/danger/approver_trust_friction_test.go @@ -0,0 +1,37 @@ +package danger + +import ( + "os" + "testing" + "time" +) + +// Trust grants ("t"/"trust") count as approvals: they must feed the friction +// log, otherwise an attacker can rapid-fire trust grants that never engage +// approval-fatigue friction. +func TestPromptCommand_TrustGrantRecordsApproval(t *testing.T) { + ResetTTYFrictionStateForTest() + f, err := os.CreateTemp("", "ttyin") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString("t\nt\nt\n"); err != nil { + t.Fatal(err) + } + f.Close() + t.Cleanup(func() { os.Remove(f.Name()) }) + + a := &TTYApprover{TTYPath: f.Name(), FrictionThreshold: 3, FrictionWindow: time.Minute} + cls := NetworkEgress + for i := 0; i < 3; i++ { + if err := a.PromptCommand(cls, "curl http://example.com", ""); err != nil { + t.Fatalf("prompt %d: %v", i, err) + } + } + if got := a.recentApprovalCount(cls); got != 3 { + t.Fatalf("recentApprovalCount after 3 trust grants = %d, want 3 (trust grants must be recorded)", got) + } + if !a.shouldFriction(cls) { + t.Fatal("friction should engage after 3 quick trust grants") + } +} diff --git a/internal/danger/classifier.go b/internal/danger/classifier.go index 4f6542bb..b5830b79 100644 --- a/internal/danger/classifier.go +++ b/internal/danger/classifier.go @@ -531,7 +531,9 @@ func ClassifyURL(rawURL string) RiskClass { return NetworkEgress // can't parse — don't block, but will fail at fetch time } - host := u.Hostname() + // A trailing-dot FQDN ("169.254.169.254.") resolves to the same host + // but would otherwise dodge both the IP and hostname checks. + host := strings.TrimSuffix(u.Hostname(), ".") // Try as an IP address — uses browser-compatible parsing that handles // decimal (127.0.0.1), octal (0177.0.0.1), hex (0x7f000001), @@ -893,9 +895,11 @@ func (c *DangerousConfig) ActionForCommand(cmd string) Action { return Allow } } - // Denylist is checked before classification — prefix match after trimming. + // Denylist is checked before classification — prefix match after + // collapsing internal whitespace runs on both sides, so 'git push' + // (double space or tab) cannot bypass a 'git push' denylist entry. for _, pattern := range c.Denylist { - if strings.HasPrefix(cmd, strings.TrimSpace(pattern)) { + if strings.HasPrefix(normalizeCommandSpacing(cmd), normalizeCommandSpacing(strings.TrimSpace(pattern))) { return Deny } } diff --git a/internal/danger/normalize.go b/internal/danger/normalize.go index c8c5f804..7dc94e7b 100644 --- a/internal/danger/normalize.go +++ b/internal/danger/normalize.go @@ -92,6 +92,13 @@ var homoglyphMap = map[rune]rune{ 'y': 'y', 'z': 'z', } +// normalizeCommandSpacing collapses internal whitespace runs to single +// spaces so denylist prefix matching cannot be bypassed by double spaces +// or tabs between tokens ('git\u00a0\u00a0push' evading a 'git push' entry). +func normalizeCommandSpacing(s string) string { + return strings.Join(strings.Fields(s), " ") +} + // isInvisible reports whether r is a zero-width or otherwise invisible // character commonly used to evade text scanners. func isInvisible(r rune) bool { diff --git a/internal/danger/redbugs5_test.go b/internal/danger/redbugs5_test.go new file mode 100644 index 00000000..7b3d445f --- /dev/null +++ b/internal/danger/redbugs5_test.go @@ -0,0 +1,40 @@ +package danger + +import ( + "testing" +) + +// Denylist prefix matching trimmed only the edges; internal whitespace runs +// ('gitpush', 'git\t push') bypassed a 'git push' denylist +// entry and fell through to the class-based action. +func TestActionForCommand_DenylistInternalWhitespace(t *testing.T) { + cfg := DangerousConfig{ + Classes: map[RiskClass]Action{NetworkEgress: Allow}, + Denylist: []string{"git push"}, + } + for _, cmd := range []string{"git push origin", "git\t push origin", "git push origin"} { + if got := cfg.ActionForCommand(cmd); got != Deny { + t.Errorf("ActionForCommand(%q) = %v, want Deny", cmd, got) + } + } +} + +// SetTrustAll previously short-circuited promptLocked for every class, +// including the classes TrustShortcutAllowed explicitly excludes +// (UnreadExec, Persistence, Destructive, Blocked, Unknown, ToolBatch). +func TestTrustAll_DoesNotSkipExcludedClasses(t *testing.T) { + a := NewTTYApprover(&DangerousConfig{}) + a.TTYPath = "/nonexistent-tty-for-test" + a.SetTrustAll(true) + for _, cls := range []RiskClass{UnreadExec, Persistence, Destructive, Blocked, Unknown} { + if err := a.PromptCommand(cls, "cmd", "desc"); err == nil { + t.Errorf("PromptCommand(%s) with trustAll returned nil, want error", cls) + } + } + // Classes that DO qualify for trust shortcuts keep the skip. + for _, cls := range []RiskClass{Safe, SystemWrite} { + if err := a.PromptCommand(cls, "cmd", "desc"); err != nil { + t.Errorf("PromptCommand(%s) with trustAll returned %v, want nil", cls, err) + } + } +} diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go index 6adce935..eba12da7 100644 --- a/internal/mcpclient/client.go +++ b/internal/mcpclient/client.go @@ -393,6 +393,10 @@ func New(name string, cfg ServerConfig) (*Client, error) { if err := cmd.Start(); err != nil { stdin.Close() + // The stdout pipe reader must be released too, or the descriptor + // leaks on every failed start. Stderr is inherited (not piped), so + // there is nothing to close for it. + stdout.Close() return nil, fmt.Errorf("mcpclient %s: start: %w", name, err) } diff --git a/internal/memory/memory.go b/internal/memory/memory.go index 380caeba..1bf01915 100644 --- a/internal/memory/memory.go +++ b/internal/memory/memory.go @@ -793,6 +793,11 @@ func (m *MemoryManager) AddFact(target, content string) error { return fmt.Errorf("memory: disabled") } + // Normalize once: FactStore stores trimmed content, so merge detection, + // dedup, and the corpus must all reason over the trimmed form or padded + // duplicates can append phantom corpus entries. + content = strings.TrimSpace(content) + // Serialize the whole read-modify-write across instances sharing this dir. unlock, err := lockFactsDir(m.facts.dir) if err != nil { diff --git a/internal/memory/memory_trim_test.go b/internal/memory/memory_trim_test.go new file mode 100644 index 00000000..10419255 --- /dev/null +++ b/internal/memory/memory_trim_test.go @@ -0,0 +1,29 @@ +package memory + +import ( + "testing" +) + +// AddFact must normalise (trim) content consistently with what FactStore +// stores on disk. Otherwise padded duplicates append phantom entries to the +// merge-detector corpus that never exist in the fact store. +func TestAddFact_PaddedDuplicatesProduceSingleCorpusEntry(t *testing.T) { + cfg := DefaultMemoryConfig() + cfg.MergeOnWrite = boolPtr(true) + mm := NewMemoryManager(t.TempDir(), nil, cfg) + + if err := mm.AddFact("env", " go 1.22 "); err != nil { + t.Fatalf("first AddFact: %v", err) + } + if err := mm.AddFact("env", " go 1.22 "); err != nil { + t.Fatalf("second AddFact: %v", err) + } + if got := len(mm.merge.Corpus()); got != 1 { + t.Errorf("corpus length after padded duplicate adds = %d, want 1", got) + } + // The corpus entry must be the normalized (trimmed) form, matching the + // normalized fact content — not a padded variant. + if got := mm.merge.Corpus(); len(got) == 1 && got[0] != "go 1.22" { + t.Errorf("corpus entry = %q, want trimmed %q", got[0], "go 1.22") + } +} diff --git a/internal/redact/redact.go b/internal/redact/redact.go index af5bc4fe..850956ce 100644 --- a/internal/redact/redact.go +++ b/internal/redact/redact.go @@ -312,6 +312,21 @@ func sensitiveName(name string) bool { return true } } + // Fused names with no separator at all (MYAPITOKEN, OPENAIAPIKEY) carry + // the secret word at the end. Suffix matching only — prefix matching + // false-positives on legitimate names like TOKENBUCKET. Short words are + // excluded so accidental endings (MONKEY → KEY) don't match. + fused := strings.ToUpper(strings.Map(func(r rune) rune { + if r == '_' || r == '-' { + return -1 + } + return r + }, name)) + for _, word := range []string{"APIKEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS", "PRIVATEKEY", "ACCESSKEY", "SECRETKEY"} { + if len(fused) > len(word) && strings.HasSuffix(fused, word) { + return true + } + } return false } diff --git a/internal/redact/redact_fused_test.go b/internal/redact/redact_fused_test.go new file mode 100644 index 00000000..3fbde2e2 --- /dev/null +++ b/internal/redact/redact_fused_test.go @@ -0,0 +1,26 @@ +package redact + +import "testing" + +// Fused env names with no separator ("MYAPITOKEN") must still be recognised +// as secret-bearing: segment-only matching silently skips them, so the value +// behind MYAPITOKEN is never registered and never redacted. +func TestSensitiveName_FusedForms(t *testing.T) { + cases := []string{ + "MYAPITOKEN", + "MyApiToken", + "OPENAIAPIKEY", + "GITHUBACCESSTOKEN", + } + for _, name := range cases { + if !sensitiveName(name) { + t.Errorf("sensitiveName(%q) = false, want true (fused secret name)", name) + } + } + // Non-secret names stay clean. + for _, name := range []string{"CAPITOL", "RAPID", "TOKENBUCKET_RATE"} { + if sensitiveName(name) { + t.Errorf("sensitiveName(%q) = true, want false", name) + } + } +} diff --git a/internal/session/audit.go b/internal/session/audit.go index ee4c7238..09bb6443 100644 --- a/internal/session/audit.go +++ b/internal/session/audit.go @@ -17,6 +17,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "os" "path/filepath" "regexp" @@ -80,6 +81,14 @@ func boundedAuditResources(content string) []string { return resources } +// auditReadError marks a failed audit-log READ (permissions, I/O) as distinct +// from a corrupt-log unmarshal failure. Reads must abort mutations so a +// transient error cannot cause the next save to overwrite history. +type auditReadError struct{ err error } + +func (e auditReadError) Error() string { return e.err.Error() } +func (e auditReadError) Unwrap() error { return e.err } + // RecordIngest appends an ingest entry for a session. func (s *AuditStore) RecordIngest(sessionID string, turn int, source, content string) error { if err := ValidateSessionID(sessionID); err != nil { @@ -88,6 +97,10 @@ func (s *AuditStore) RecordIngest(sessionID string, turn int, source, content st s.mu.Lock() defer s.mu.Unlock() log, lerr := s.loadLocked(sessionID) + var re auditReadError + if errors.As(lerr, &re) { + return lerr + } if lerr != nil { // Unparseable (torn/corrupt) log: keep the evidence aside and // start a fresh log rather than silently discarding it. @@ -113,6 +126,10 @@ func (s *AuditStore) RecordTurn(sessionID string, turn AuditTurn) error { s.mu.Lock() defer s.mu.Unlock() log, lerr := s.loadLocked(sessionID) + var re auditReadError + if errors.As(lerr, &re) { + return lerr + } if lerr != nil { s.preserveCorruptLocked(sessionID) } @@ -135,7 +152,13 @@ func (s *AuditStore) loadLocked(sessionID string) (AuditLog, error) { path := filepath.Join(s.dir, sessionID+".json") data, err := os.ReadFile(path) if err != nil { - return AuditLog{SessionID: sessionID}, nil + if os.IsNotExist(err) { + return AuditLog{SessionID: sessionID}, nil + } + // Any other read failure (permissions, I/O) must surface: treating + // it as "no history yet" would let a transient error silently + // rewrite the audit trail on the next save. + return AuditLog{SessionID: sessionID}, auditReadError{err} } var log AuditLog if err := json.Unmarshal(data, &log); err != nil { diff --git a/internal/session/audit_readerr_test.go b/internal/session/audit_readerr_test.go new file mode 100644 index 00000000..5655bdb1 --- /dev/null +++ b/internal/session/audit_readerr_test.go @@ -0,0 +1,31 @@ +package session + +import ( + "os" + "path/filepath" + "testing" +) + +// A read failure on the audit log (permissions, I/O error) must surface as an +// error instead of being treated as "no history yet" — otherwise a transient +// read error causes RecordTurn/RecordIngest to silently rewrite history. +// A directory in place of the log file gives a portable non-NotExist read +// failure (permission bits are not reliable under sandboxes/CI roots). +func TestAuditStore_ReadErrorPropagates(t *testing.T) { + root := t.TempDir() + s := NewAuditStore(root) + if err := os.MkdirAll(filepath.Join(root, "audit"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "audit", "20260927-probe01.json"), 0755); err != nil { + t.Fatal(err) + } + + if _, err := s.Load("20260927-probe01"); err == nil { + t.Fatal("Load on unreadable audit file = nil error, want error") + } + err := s.RecordTurn("20260927-probe01", AuditTurn{Turn: 1, UserMessage: "hi"}) + if err == nil { + t.Fatal("RecordTurn on unreadable audit file = nil error, want error (history overwrite)") + } +} diff --git a/internal/session/bughunt_save_restore_test.go b/internal/session/bughunt_save_restore_test.go new file mode 100644 index 00000000..a396ad79 --- /dev/null +++ b/internal/session/bughunt_save_restore_test.go @@ -0,0 +1,150 @@ +package session + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/BackendStack21/go-vector/pkg/vector" +) + +// saveLocked mutates sess.Messages in place (redaction + capacity trim). If +// the index write fails afterwards, the deferred rollback restored only +// Revision/Generation — the caller's snapshot stayed trimmed/redacted while +// the on-disk revision never advanced, so the next Save hit ErrConflict +// forever. A failed save must leave the caller's Messages untouched. +func TestRED_FailedIndexSaveRestoresMessages(t *testing.T) { + dir := t.TempDir() + store, err := NewStoreWithDir(dir) + if err != nil { + t.Fatal(err) + } + secret := "sk-test-abc123secretkeyvalue" + original := "handle with care " + secret + sess := &Session{ + ID: generateID(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Task: original, + Messages: []Message{ + {Role: "user", Content: original}, + }, + } + if err := store.Save(sess); err != nil { + t.Fatal(err) + } + // Sabotage the index write: replace the index path with a directory so + // the atomic rename fails on the second save. + idxPath := filepath.Join(dir, "index.json") + if err := os.Remove(idxPath); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(idxPath, 0o755); err != nil { + t.Fatal(err) + } + + msgsBefore := append([]Message(nil), sess.Messages...) + err = store.Save(sess) + if err == nil { + t.Fatalf("expected the sabotaged index write to fail") + } + if !errors.Is(err, os.ErrExist) && !strings.Contains(err.Error(), "index") { + t.Fatalf("unexpected error class: %v", err) + } + + // The caller's in-memory snapshot must be intact after the failed save. + if len(sess.Messages) != len(msgsBefore) { + t.Fatalf("failed save mutated Messages: %d -> %d", len(msgsBefore), len(sess.Messages)) + } + if sess.Messages[0].Content != original { + t.Fatalf("failed save redacted/truncated the caller's Messages in place: %q", sess.Messages[0].Content) + } + + // Repair and retry: the next Save must succeed (no stuck ErrConflict). + if err := os.Remove(idxPath); err != nil { + t.Fatal(err) + } + if err := store.Save(sess); err != nil { + t.Fatalf("retry Save after repaired index must succeed, got: %v", err) + } +} + +// blockingEmbedder simulates a slow remote embedding backend: Embed blocks +// until released. While Search is inside Embed, Add must complete — the +// embed call must not run under the index mutex. +type blockingEmbedder struct { + mu sync.Mutex + release chan struct{} + started chan struct{} + onceDone bool +} + +func (b *blockingEmbedder) Fit(corpus []string) error { return nil } +func (b *blockingEmbedder) Embed(text string) (vector.Vector, error) { + if !b.onceDone { + b.onceDone = true + close(b.started) + <-b.release + } + return vector.Vector{1, 2, 3}, nil +} +func (b *blockingEmbedder) EmbedAll(texts []string) ([]vector.Vector, error) { + out := make([]vector.Vector, len(texts)) + for i := range texts { + out[i] = vector.Vector{1, 2, 3} + } + return out, nil +} +func (b *blockingEmbedder) Fingerprint() string { return "blocking" } +func (b *blockingEmbedder) SaveState(path string) {} +func (b *blockingEmbedder) LoadState(path string) bool { return false } + +func TestRED_SearchEmbedDoesNotBlockAdd(t *testing.T) { + emb := &blockingEmbedder{ + release: make(chan struct{}), + started: make(chan struct{}), + } + vi := &VectorIndex{ + emb: emb, + ready: true, + store: newTestVectorStore(), + } + vi.store.Add("seed", vector.Vector{1, 2, 3}) + + searchDone := make(chan struct{}) + go func() { + defer close(searchDone) + _, _ = vi.Search("query", 5) + }() + + select { + case <-emb.started: + case <-time.After(2 * time.Second): + t.Fatal("Search never reached Embed") + } + + // Add must complete while Search is parked inside Embed. + addDone := make(chan error, 1) + go func() { + addDone <- vi.Add("sess-blocking", []Message{{Role: "user", Content: "hello"}}) + }() + select { + case err := <-addDone: + if err != nil { + t.Fatalf("Add failed while Search was embedding: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Add blocked on the index mutex while Search was embedding") + } + + close(emb.release) + <-searchDone +} + +func newTestVectorStore() *vector.Store { + return vector.NewStore(vector.CosineDistance) +} diff --git a/internal/session/session.go b/internal/session/session.go index ce965b54..4a66abc4 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -539,7 +539,7 @@ func redactMessageFP(m Message) string { return hex.EncodeToString(h[:8]) } -func (s *Store) saveLocked(sess *Session) error { +func (s *Store) saveLocked(sess *Session) (err error) { // Reject malformed or traversal-bearing session IDs before the ID is used // to build a filesystem path. A planted session file with an embedded // "id":"../config" must not cause a subsequent Save/Append to overwrite @@ -588,7 +588,25 @@ func (s *Store) saveLocked(sess *Session) error { } sess.Revision++ committed := false + // saveLocked mutates sess in place (secret redaction, capacity trim, + // boundary advance). A failed save must leave the caller's snapshot + // untouched: otherwise the memory copy stays trimmed while the on-disk + // revision never advanced (or diverged), and the next Save can hit + // ErrConflict forever with unsaved turns. Snapshot everything the + // mutation touches and restore it on any error return. + var ( + snapshotTask = sess.Task + snapshotMessages = sess.Messages + snapshotBoundary = sess.RedactBoundary + snapshotBoundaryFP = sess.RedactBoundaryFP + ) defer func() { + if err != nil { + sess.Task = snapshotTask + sess.Messages = snapshotMessages + sess.RedactBoundary = snapshotBoundary + sess.RedactBoundaryFP = snapshotBoundaryFP + } if !committed { sess.Revision = previousRevision sess.Generation = previousGeneration diff --git a/internal/session/vector_index.go b/internal/session/vector_index.go index 804e7eed..9de2fa11 100644 --- a/internal/session/vector_index.go +++ b/internal/session/vector_index.go @@ -289,13 +289,23 @@ type SearchResult struct { // to keyword search. If the index was not ready, one rebuild is attempted // (subject to the cool-down). func (vi *VectorIndex) Search(query string, k int) ([]SearchResult, error) { + // The embed call may hit a slow remote backend; it must not run under + // the index mutex — one slow search would stall every Add/Save. The + // cheap readiness checks run under the lock; the embed runs outside; + // the store lookup re-takes the lock so it never races a concurrent Add. vi.mu.Lock() - defer vi.mu.Unlock() - if !vi.ready { _ = vi.rebuildLocked() } - if !vi.ready || vi.store == nil || vi.store.Len() == 0 { + ready := vi.ready + hasVectors := vi.store != nil && vi.store.Len() > 0 + // Respect the cool-down on the ready path too (see Add): a down backend + // must not be re-hit on every search — degrade to the keyword fallback. + inCooldown := !vi.failedAt.IsZero() && time.Since(vi.failedAt) < rebuildRetryInterval + emb := vi.emb + vi.mu.Unlock() + + if !ready || !hasVectors || inCooldown { return nil, nil } if k <= 0 { @@ -304,16 +314,19 @@ func (vi *VectorIndex) Search(query string, k int) ([]SearchResult, error) { if k > 20 { k = 20 } - // Respect the cool-down on the ready path too (see Add): a down backend - // must not be re-hit on every search — degrade to the keyword fallback. - if !vi.failedAt.IsZero() && time.Since(vi.failedAt) < rebuildRetryInterval { - return nil, nil - } - vec, err := vi.emb.Embed(query) + vec, err := emb.Embed(query) if err != nil { // Degrade to the keyword fallback rather than surfacing an error. + vi.mu.Lock() vi.failedAt = time.Now() + vi.mu.Unlock() + return nil, nil + } + + vi.mu.Lock() + defer vi.mu.Unlock() + if vi.store == nil { return nil, nil } diff --git a/internal/skills/cache.go b/internal/skills/cache.go index a80f027f..6905ac2a 100644 --- a/internal/skills/cache.go +++ b/internal/skills/cache.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "time" ) @@ -177,9 +178,12 @@ func loadPersistentCache(dir string) (fileCache, map[string]Skill) { } // savePersistentCache writes the current fileTimes and prevSkills to disk. -// Errors are silently ignored — the cache is an optimization, not a -// correctness requirement. Atomic write via temp file + rename. -func savePersistentCache(dir string, fc fileCache, prev map[string]Skill) { +// Entries belonging to skill directories other than the user dir and the +// active project dir are dropped, so the persisted cache never accumulates +// entries for deleted or switched-away projects. Errors are silently +// ignored — the cache is an optimization, not a correctness requirement. +// Atomic write via temp file + rename. +func savePersistentCache(dir, projectDir string, fc fileCache, prev map[string]Skill) { if dir == "" { return } @@ -188,6 +192,13 @@ func savePersistentCache(dir string, fc fileCache, prev map[string]Skill) { Skills: make(map[string]cachedSkill, len(fc)), } for path, mtime := range fc { + if projectDir != "" && strings.HasPrefix(path, projectDir+string(filepath.Separator)) { + // keep current project entries + } else if strings.HasPrefix(path, dir+string(filepath.Separator)) { + // keep user-dir entries + } else { + continue + } if skill, ok := prev[path]; ok { cache.Skills[path] = cachedSkill{ MTime: mtime, diff --git a/internal/skills/cache_gc_test.go b/internal/skills/cache_gc_test.go new file mode 100644 index 00000000..05fff06d --- /dev/null +++ b/internal/skills/cache_gc_test.go @@ -0,0 +1,45 @@ +package skills + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Persisted cache entries for other projects' skill dirs accumulated +// forever — save must keep only user-dir entries plus the current project. +func TestSavePersistentCache_DropsOtherProjectEntries(t *testing.T) { + userDir := t.TempDir() + projectA := filepath.Join(t.TempDir(), "project-a") + projectB := filepath.Join(t.TempDir(), "project-b") + + fc := fileCache{ + filepath.Join(userDir, "alpha", "SKILL.md"): time.Unix(1, 0), + filepath.Join(projectA, "beta", "SKILL.md"): time.Unix(2, 0), + filepath.Join(projectB, "gamma", "SKILL.md"): time.Unix(3, 0), + } + prev := map[string]Skill{ + filepath.Join(userDir, "alpha", "SKILL.md"): {Name: "alpha"}, + filepath.Join(projectA, "beta", "SKILL.md"): {Name: "beta"}, + filepath.Join(projectB, "gamma", "SKILL.md"): {Name: "gamma"}, + } + + savePersistentCache(userDir, projectB, fc, prev) + + data, err := os.ReadFile(cachePath(userDir)) + if err != nil { + t.Fatalf("read cache: %v", err) + } + s := string(data) + if strings.Contains(s, projectA) { + t.Errorf("persisted cache still contains project-A path:\n%s", s) + } + if !strings.Contains(s, filepath.Join(userDir, "alpha")) { + t.Errorf("persisted cache lost user-dir entry:\n%s", s) + } + if !strings.Contains(s, filepath.Join(projectB, "gamma")) { + t.Errorf("persisted cache lost current project entry:\n%s", s) + } +} diff --git a/internal/skills/tools.go b/internal/skills/tools.go index 7ba633cd..1eaa7506 100644 --- a/internal/skills/tools.go +++ b/internal/skills/tools.go @@ -223,7 +223,7 @@ func (sm *SkillManager) reloadLocked() { // Persist cache for next process invocation. // Only the user dir is cached (global skills); project-level skills // are re-scanned on each project switch. - savePersistentCache(sm.UserDir, sm.fileTimes, sm.prevSkills) + savePersistentCache(sm.UserDir, sm.ProjectDir, sm.fileTimes, sm.prevSkills) // Scan first so flagged auto-load skills are demoted before the // trigger matchers are built. From a51146386139da74f027ce022e534a25e2b2725b Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:57:34 +0200 Subject: [PATCH 2/3] fix: address adversarial panel findings on PR #272 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session: saveLocked snapshot is now an element copy — trimToFileCapLocked compacts the slice in place, so a header-copy snapshot restored shifted contents after a failed post-trim save. - redact: fused-name suffix match uses >= so bare PASSWORD/TOKEN/SECRET env names register as sensitive. - serve: dead-latched WS write states are swept past 4096 entries (dead + not held by a parked sender), bounding memory on connection-churning clients; the sticky-dead invariant below the cap is unchanged. Deferred (next hunt): budget clamp can silently mask real overruns under repeated negative external deltas (needs per-delta clamp + event); patchPreviewDiff hunk header byte/line math; fused-name plurals. --- cmd/odek/serve.go | 28 ++++++++++++++++++++++++++-- internal/redact/redact.go | 4 +++- internal/session/session.go | 8 ++++++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 79c724a8..aeed7798 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -2805,8 +2805,13 @@ func connWriter(conn *golangws.Conn) *connWriteState { // write may still be blocked inside the conn; deleting the entry lets the // next connWriter create fresh live state and issue a concurrent Send on a // *golangws.Conn that is not concurrency-safe — interleaved torn frames. -// States are tiny and keyed per connection, so retaining them for the -// process lifetime is bounded by distinct connections served. +// To keep long-lived serve processes from growing without bound, a sweep +// kicks in past wsWriterStatesCap: dead entries not currently held by a +// parked sender (mutex acquirable) are dropped. A dropped conn pointer can +// only reappear via a writeWSJSON on the already-closed conn, which mints +// fresh state whose Send fails immediately on the closed socket — harmless. +const wsWriterStatesCap = 4096 + func releaseConnWriter(conn *golangws.Conn) { if v, ok := wsConnWriters.Load(conn); ok { w := v.(*connWriteState) @@ -2814,6 +2819,25 @@ func releaseConnWriter(conn *golangws.Conn) { w.dead = true w.mu.Unlock() } + sweepConnWriters() +} + +func sweepConnWriters() { + n := 0 + wsConnWriters.Range(func(_, _ any) bool { n++; return n <= wsWriterStatesCap+1 }) + if n <= wsWriterStatesCap { + return + } + wsConnWriters.Range(func(k, v any) bool { + w := v.(*connWriteState) + if w.mu.TryLock() { + if w.dead { + wsConnWriters.Delete(k) + } + w.mu.Unlock() + } + return true + }) } func writeWSJSON(conn *golangws.Conn, data any) { diff --git a/internal/redact/redact.go b/internal/redact/redact.go index 850956ce..6d58ccaa 100644 --- a/internal/redact/redact.go +++ b/internal/redact/redact.go @@ -323,7 +323,9 @@ func sensitiveName(name string) bool { return r }, name)) for _, word := range []string{"APIKEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS", "PRIVATEKEY", "ACCESSKEY", "SECRETKEY"} { - if len(fused) > len(word) && strings.HasSuffix(fused, word) { + // >= so a bare PASSWORD/TOKEN/SECRET env name is sensitive too; + // over-matching only over-redacts, which is the safe direction. + if len(fused) >= len(word) && strings.HasSuffix(fused, word) { return true } } diff --git a/internal/session/session.go b/internal/session/session.go index 4a66abc4..d465ee55 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -595,8 +595,12 @@ func (s *Store) saveLocked(sess *Session) (err error) { // ErrConflict forever with unsaved turns. Snapshot everything the // mutation touches and restore it on any error return. var ( - snapshotTask = sess.Task - snapshotMessages = sess.Messages + snapshotTask = sess.Task + // Element copy, not a header copy: trimToFileCapLocked compacts the + // slice IN PLACE, which would corrupt the shared backing array under + // a header-copy snapshot and restore shifted/garbled contents after + // a failed post-trim save. + snapshotMessages = append([]Message(nil), sess.Messages...) snapshotBoundary = sess.RedactBoundary snapshotBoundaryFP = sess.RedactBoundaryFP ) From 206bfecf0cc14bd4000e1319abf8ed7abdcabb3d Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:05:51 +0200 Subject: [PATCH 3/3] chore: remove workflow-artifact naming and history-narrating comments Test files and functions renamed from bug-hunt workflow markers to descriptive names (bughunt/redbugs/bugfix/TestRED_ dropped); comments rewritten to state current behavior as invariants instead of narrating the historical defect. Semantics unchanged; all touched packages green. --- cmd/odek/perf_tools_multigrep_root_test.go | 4 ++-- ...gfix_ws_token_test.go => serve_ws_token_test.go} | 6 ++---- ...elope_bughunt_test.go => parse_envelope_test.go} | 13 +++++-------- internal/artifact/ref.go | 8 ++++---- ...{expandenv_bughunt_test.go => expandenv_test.go} | 6 ++---- internal/config/loader.go | 6 +++--- .../{redbugs5_test.go => denylist_trust_test.go} | 11 +++++------ ...t_save_restore_test.go => save_rollback_test.go} | 12 +++++------- internal/skills/cache_gc_test.go | 4 ++-- 9 files changed, 30 insertions(+), 40 deletions(-) rename cmd/odek/{serve_bugfix_ws_token_test.go => serve_ws_token_test.go} (95%) rename internal/artifact/{parse_envelope_bughunt_test.go => parse_envelope_test.go} (64%) rename internal/config/{expandenv_bughunt_test.go => expandenv_test.go} (65%) rename internal/danger/{redbugs5_test.go => denylist_trust_test.go} (72%) rename internal/session/{bughunt_save_restore_test.go => save_rollback_test.go} (88%) diff --git a/cmd/odek/perf_tools_multigrep_root_test.go b/cmd/odek/perf_tools_multigrep_root_test.go index 10d19cca..4c6b38d1 100644 --- a/cmd/odek/perf_tools_multigrep_root_test.go +++ b/cmd/odek/perf_tools_multigrep_root_test.go @@ -6,8 +6,8 @@ import ( "testing" ) -// A nonexistent walk root previously returned count:0 with no error — -// the walk's root error was swallowed by the callback. +// A nonexistent walk root must surface an error, not a silent count:0 +// result for a path that was never scanned. func TestMultiGrep_NonexistentRootIsError(t *testing.T) { tool := &multiGrepTool{} out, _ := tool.Call(`{"patterns":["x"],"path":"/nonexistent-dir-xyz-123456"}`) diff --git a/cmd/odek/serve_bugfix_ws_token_test.go b/cmd/odek/serve_ws_token_test.go similarity index 95% rename from cmd/odek/serve_bugfix_ws_token_test.go rename to cmd/odek/serve_ws_token_test.go index 7a16a65e..f7ead358 100644 --- a/cmd/odek/serve_bugfix_ws_token_test.go +++ b/cmd/odek/serve_ws_token_test.go @@ -24,7 +24,7 @@ import ( // *websocket.Conn — which is not concurrency-safe. The dead state must be // sticky: the same state pointer must come back and every later write must // fast-fail. -func TestRED_WriteTimeoutDeadConnStaysDead(t *testing.T) { +func TestWriteTimeoutDeadConnStaysDead(t *testing.T) { old := wsWriteTimeout.Load() wsWriteTimeout.Store(int64(150 * time.Millisecond)) t.Cleanup(func() { wsWriteTimeout.Store(old) }) @@ -143,9 +143,7 @@ func (p *wsPipe) bytesWritten() int { // validateSessionTokenStrict must compare BEFORE any mint+persist: with an // empty stored token, an unauthenticated probe (wrong token, DELETE) must get // a plain 401 and the session file on disk must be byte-for-byte unchanged. -// Minting-and-saving on every probe rewrote the legacy file and rotated the -// token under attacker control. -func TestRED_TokenProbeDoesNotRewriteLegacySession(t *testing.T) { +func TestTokenProbeDoesNotRewriteLegacySession(t *testing.T) { dir := t.TempDir() store, err := session.NewStoreWithDir(dir) if err != nil { diff --git a/internal/artifact/parse_envelope_bughunt_test.go b/internal/artifact/parse_envelope_test.go similarity index 64% rename from internal/artifact/parse_envelope_bughunt_test.go rename to internal/artifact/parse_envelope_test.go index b0c62c46..ae8eb421 100644 --- a/internal/artifact/parse_envelope_bughunt_test.go +++ b/internal/artifact/parse_envelope_test.go @@ -6,12 +6,9 @@ import ( "testing" ) -// An MCP server that prepends a UTF-8 BOM (or other tooling that does) -// produces envelope text whose first byte is not '{' — ParseEnvelope used to -// return (nil, nil) ("plain text"), and the mcpclient then delivered the raw -// envelope JSON with file:// refs to the model without any artifact-root -// validation. A BOM-prefixed envelope must parse like any other. -func TestRED_ParseEnvelopeToleratesBOM(t *testing.T) { +// A UTF-8 BOM before the envelope JSON must not turn the envelope into +// "plain text": envelopes parse regardless of a leading BOM. +func TestParseEnvelopeToleratesBOM(t *testing.T) { env := &Envelope{Schema: SchemaToolResult, Text: "hello"} raw, err := json.Marshal(env) if err != nil { @@ -23,7 +20,7 @@ func TestRED_ParseEnvelopeToleratesBOM(t *testing.T) { t.Fatalf("BOM-prefixed envelope must parse, got error: %v", err) } if got == nil { - t.Fatal("BOM-prefixed envelope was treated as plain text (nil, nil) — fail-open for envelope JSON with file:// refs") + t.Fatal("BOM-prefixed envelope was treated as plain text (nil, nil) instead of parsing") } if got.Text != "hello" { t.Fatalf("unexpected text: %q", got.Text) @@ -32,7 +29,7 @@ func TestRED_ParseEnvelopeToleratesBOM(t *testing.T) { // Junk-prefixed text is still plain text, but text that merely has the // schema marker inside must not be misparsed either way. -func TestRED_ParseEnvelopeJunkPrefixStaysPlain(t *testing.T) { +func TestParseEnvelopeJunkPrefixStaysPlain(t *testing.T) { env := &Envelope{Schema: SchemaToolResult, Text: "hi"} raw, _ := json.Marshal(env) got, err := ParseEnvelope("note: " + string(raw)) diff --git a/internal/artifact/ref.go b/internal/artifact/ref.go index effc7799..30fd0bed 100644 --- a/internal/artifact/ref.go +++ b/internal/artifact/ref.go @@ -81,10 +81,10 @@ type Envelope struct { // claim the payload must back up. func ParseEnvelope(text string) (*Envelope, error) { trimmed := strings.TrimSpace(text) - // A UTF-8 BOM in front of the envelope JSON made the '{' probe miss and - // the whole envelope fall through as "plain text" — the raw JSON with - // file:// refs then reached the model without artifact-root validation. - // Strip the BOM so an envelope is detected wherever tooling adds one. + // Strip a UTF-8 BOM: some tooling (including MCP servers) prepends one, + // and a BOM would make the '{' probe miss so the raw envelope JSON with + // file:// refs reaches the model as "plain text" without artifact-root + // validation. trimmed = strings.TrimPrefix(trimmed, "\uFEFF") if len(trimmed) == 0 || trimmed[0] != '{' { return nil, nil diff --git a/internal/config/expandenv_bughunt_test.go b/internal/config/expandenv_test.go similarity index 65% rename from internal/config/expandenv_bughunt_test.go rename to internal/config/expandenv_test.go index 6309622e..834ffa45 100644 --- a/internal/config/expandenv_bughunt_test.go +++ b/internal/config/expandenv_test.go @@ -3,10 +3,8 @@ package config import "testing" // A '$' followed by a non-identifier byte must be emitted verbatim without -// consuming the following byte: "cost: $ 5" stays "cost: $ 5" (the space -// after the $ was previously eaten), "$9.99" stays "$9.99" (the '9' was -// eaten), and shell-sigil-lookalikes like "$?" emit "$?" unharmed. -func TestRED_ExpandEnvKeepsCharAfterBareDollar(t *testing.T) { +// consuming the following byte: "cost: $ 5", "$9.99" and "$?" stay intact. +func TestExpandEnvKeepsCharAfterBareDollar(t *testing.T) { cases := map[string]string{ "cost: $ 5": "cost: $ 5", "$9.99": "$9.99", diff --git a/internal/config/loader.go b/internal/config/loader.go index 4e030622..79f0437f 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1075,9 +1075,9 @@ func expandEnv(s string) string { i = j + 1 if name == "" { - // $ followed by non-identifier: emit the '$' verbatim. The - // following byte is NOT consumed — eating it silently corrupted - // config values ("cost: $ 5" → "cost: $5", "$9.99" → "$.99"). + // $ followed by non-identifier: emit the '$' verbatim and do + // NOT consume the following byte — "$ 5", "$9.99" and "$?" + // must survive expansion byte-for-byte. buf.WriteByte('$') continue } diff --git a/internal/danger/redbugs5_test.go b/internal/danger/denylist_trust_test.go similarity index 72% rename from internal/danger/redbugs5_test.go rename to internal/danger/denylist_trust_test.go index 7b3d445f..38c05814 100644 --- a/internal/danger/redbugs5_test.go +++ b/internal/danger/denylist_trust_test.go @@ -4,9 +4,8 @@ import ( "testing" ) -// Denylist prefix matching trimmed only the edges; internal whitespace runs -// ('gitpush', 'git\t push') bypassed a 'git push' denylist -// entry and fell through to the class-based action. +// Internal whitespace runs collapse to single spaces, so 'git push' and +// 'git\t push' still match the 'git push' denylist entry. func TestActionForCommand_DenylistInternalWhitespace(t *testing.T) { cfg := DangerousConfig{ Classes: map[RiskClass]Action{NetworkEgress: Allow}, @@ -19,9 +18,9 @@ func TestActionForCommand_DenylistInternalWhitespace(t *testing.T) { } } -// SetTrustAll previously short-circuited promptLocked for every class, -// including the classes TrustShortcutAllowed explicitly excludes -// (UnreadExec, Persistence, Destructive, Blocked, Unknown, ToolBatch). +// SetTrustAll skips prompts only for classes TrustShortcutAllowed permits; +// UnreadExec, Persistence, Destructive, Blocked, Unknown and ToolBatch +// always prompt. func TestTrustAll_DoesNotSkipExcludedClasses(t *testing.T) { a := NewTTYApprover(&DangerousConfig{}) a.TTYPath = "/nonexistent-tty-for-test" diff --git a/internal/session/bughunt_save_restore_test.go b/internal/session/save_rollback_test.go similarity index 88% rename from internal/session/bughunt_save_restore_test.go rename to internal/session/save_rollback_test.go index a396ad79..bac0354e 100644 --- a/internal/session/bughunt_save_restore_test.go +++ b/internal/session/save_rollback_test.go @@ -12,12 +12,10 @@ import ( "github.com/BackendStack21/go-vector/pkg/vector" ) -// saveLocked mutates sess.Messages in place (redaction + capacity trim). If -// the index write fails afterwards, the deferred rollback restored only -// Revision/Generation — the caller's snapshot stayed trimmed/redacted while -// the on-disk revision never advanced, so the next Save hit ErrConflict -// forever. A failed save must leave the caller's Messages untouched. -func TestRED_FailedIndexSaveRestoresMessages(t *testing.T) { +// A failed save must leave the caller's snapshot untouched: saveLocked +// mutates sess.Messages in place, and without a full restore the memory +// copy stays trimmed/redacted while the on-disk revision never advanced. +func TestFailedIndexSaveRestoresMessages(t *testing.T) { dir := t.TempDir() store, err := NewStoreWithDir(dir) if err != nil { @@ -103,7 +101,7 @@ func (b *blockingEmbedder) Fingerprint() string { return "blocking" } func (b *blockingEmbedder) SaveState(path string) {} func (b *blockingEmbedder) LoadState(path string) bool { return false } -func TestRED_SearchEmbedDoesNotBlockAdd(t *testing.T) { +func TestSearchEmbedDoesNotBlockAdd(t *testing.T) { emb := &blockingEmbedder{ release: make(chan struct{}), started: make(chan struct{}), diff --git a/internal/skills/cache_gc_test.go b/internal/skills/cache_gc_test.go index 05fff06d..de279465 100644 --- a/internal/skills/cache_gc_test.go +++ b/internal/skills/cache_gc_test.go @@ -8,8 +8,8 @@ import ( "time" ) -// Persisted cache entries for other projects' skill dirs accumulated -// forever — save must keep only user-dir entries plus the current project. +// Save must keep only user-dir entries plus the current project's skill +// dir; entries for other projects are dropped. func TestSavePersistentCache_DropsOtherProjectEntries(t *testing.T) { userDir := t.TempDir() projectA := filepath.Join(t.TempDir(), "project-a")