Status as of 2026-05-18: Rounds 1–3 are frozen historical reference (all findings COMPLETE through Phase 90). Round 4 findings (2026-05-05) are the current action list — see Section 15 below. Section 25 (DB call patterns checklist) added 2026-05-16.
Original review timeline: 2026-04-05 (Round 1) · 2026-04-12 (Round 2 — deep review) · 2026-04-14 (Round 3 — UX gap analysis) · 2026-04-29 (counts refreshed) · 2026-05-05 (Round 4 — Web/Server/SDK/recent changes) Original scope: Full codebase — Runtime, Providers, Server, Tools, Agents, CLI, Desktop, Web, LSP, MCP, TypeScript SDK Build at time of review: 1,492 tests passing, 0 warnings (10 test projects). Build at Round 4 (2026-05-05): 1,911 tests passing, 0 failures (10 projects).
Since this document was last refreshed (2026-04-29), Phase 90 (Public Release Readiness) shipped on 2026-05-02 (commit 21ea01b, tag v0.9.0). The following items in this review are now resolved or no longer apply:
- All Round 1/2 CRITICAL and HIGH findings are fixed (Phases A–K, 2026-04-05 → 2026-04-29).
- Round 3 UX gaps —
/agents"Run now" viaAdHocAgentRunner; Activity drill-down with master/detail per-turn breakdown; sortable parameter tables on Tools page; sortable headers on Skills page; consistent empty/loading states across Web pages; inline-style cleanup migrated intosovrant.css. - Command Center cockpit (Phase 89 MVP folded into Phase 90 Track B) — new
/commandpage on Web and Desktop unifies missions, team runs, agent runs, and sessions in one read-only live grid. Now the default homepage on both surfaces. - Plaintext API keys — Phase 88 Track G deferred; Phase 90 Track G shipped the migration. Provider profiles now reference
credential_idonly; existing plaintext keys move to the encrypted keystore on first launch. - Hardcoded phase numbers in user-facing UI — purged.
OrchestrationView.axamlline 283 placeholder removed. - Automations.razor — deleted in Phase 90 Track F. References to it in this document are stale; the architectural decision is that workflow automation comes via MCP-connected platforms (n8n, Zapier, Make), surfaced through Integrations.
- Repositioning — README rewritten around BSL 1.1 / source-available framing; "command center for agents and agent activity" is the primary value prop.
For anything below dated before the timeline above and still marked as open: re-evaluate against the current code or the roadmap.md Phase 90 acceptance-criteria list (all checked) before treating it as a live finding. The line-number citations in particular have drifted as the codebase evolved.
| Severity | Round 1 | Round 2 (new) | Total |
|---|---|---|---|
| CRITICAL | 9 | 10 | 19 |
| HIGH | 24 | 20 | 44 |
| MEDIUM | 58 | 18 | 76 |
| LOW | 38 | 12 | 50 |
| TOTAL | 129 | 60 | 189 |
Round 1 top priorities (all COMPLETE — Phases A–F):
SSRF via— fixedX-LLM-Base-UrlCommand injection in BashTool, PowerShellTool, ReplTool, TaskCreateTool— fixedRate limiting bypass— fixedCancellationTokenSource leaks— fixedMissing request timeouts— fixed
Round 2 top priorities (NEW):
- MCP Server exposes all sessions without owner filtering (
ownerUserId: null) - Missing authorization checks in ProjectRoutes — no HTTP-layer permission enforcement
- Command injection in EnterWorktreeTool/ExitWorktreeTool — same
EscapeArgpattern as original 1.2–1.5 - Race condition in SmartRouter
ProviderInfostate updates — non-atomic counter increments - useChat React hook never recreates client when options change — stale credentials
- Unhandled fire-and-forget task exceptions in Web/Desktop/LSP startup paths
- ETagMiddleware buffers entire response body — memory exhaustion on large/streaming responses
Layer: Server
File: src/Sovrant.Server/Routes/ChatRoutes.cs:52,60-66
Problem: User-supplied X-LLM-Base-Url header is used to construct HttpClient.BaseAddress without validation. An attacker can point the server at internal network endpoints (http://169.254.169.254/, http://192.168.1.1/admin, etc.), bypassing firewalls.
Fix: Validate URL scheme (https-only in production), reject private/reserved IP ranges, or whitelist allowed base URLs.
Layer: Tools
File: src/Sovrant.Tools/Core/BashTool.cs:57,108
Problem: EscapeArg() only escapes double quotes. Shell metacharacters ($(), backticks, &&, ||, ;, pipes) are not escaped. Commands are passed via -c "{escaped}" which is vulnerable to breakout.
Fix: Use ProcessStartInfo.ArgumentList to avoid shell interpretation entirely, or implement POSIX-compliant shell escaping.
Layer: Tools
File: src/Sovrant.Tools/Extended/PowerShellTool.cs:45
Problem: Same pattern as BashTool — Replace("\"", "\\\"") is insufficient for PowerShell's multiple quoting mechanisms and special variables.
Fix: Use -EncodedCommand with Base64-encoded script, or ProcessStartInfo.ArgumentList.
Layer: Tools
File: src/Sovrant.Tools/Extended/ReplTool.cs:69
Problem: REPL code passed through shell string composition. Python, Node.js, and other interpreters can execute arbitrary code through various injection techniques.
Fix: Write code to a temp file and execute the file, or use subprocess APIs with argument arrays.
Layer: Tools
File: src/Sovrant.Tools/Tasks/TaskCreateTool.cs:63,74
Problem: Background task spawning uses the same inadequate escaping across Windows (cmd.exe/PowerShell) and Unix shells.
Fix: Use ProcessStartInfo with separate FileName/Arguments rather than building a command string.
Layer: Server
File: src/Sovrant.Server/Program.cs:109-125
Problem: Rate limit key falls back to RemoteIpAddress then "anonymous". Attackers can bypass by omitting X-Session-Id and spoofing IPs. The "anonymous" bucket is shared across all unauthenticated clients.
Fix: Require X-Session-Id for chat endpoints. Never fall back to a shared key. Use connection IP (not forwarded headers) as the default key.
Layer: Server
File: src/Sovrant.Server/Auth/BearerTokenMiddleware.cs:30-32
Problem: If SOVRANT_TOKEN is empty or not set, all requests are rejected (correct behavior but confusing logic). The real issue is the server starts successfully without a token configured — it should fail at startup.
Fix: Validate SOVRANT_TOKEN is set and non-empty at startup. Throw InvalidOperationException if missing.
Layer: Server
File: src/Sovrant.Server/Routes/ChatRoutes.cs:149-164
Problem: No try-catch around StreamResponseAsync/BufferedResponseAsync. Runtime exceptions propagate as raw 500 errors, potentially leaking internal details. SSE streams may be left in inconsistent state.
Fix: Add catch block with consistent error formatting. For SSE, emit an error event before closing the stream.
Layer: Server
File: src/Sovrant.Server/Routes/McpAuthRoutes.cs:10-13
Problem: OAuth callback endpoint is publicly accessible (.AllowAnonymous() — correct for OAuth). CSRF state token validation exists but its strength depends on the implementation in McpOAuthService.
Fix: Verify state tokens are cryptographically random, stored server-side with expiration, and validated on callback. Add integration test for CSRF flow.
Layer: Agents
File: src/Sovrant.Agents/Shared/OrchestrationCoordinator.cs:69-98
Problem: linkedCts (from CreateLinkedTokenSource) is stored in _taskCts but never explicitly disposed. TryRemove doesn't call Dispose().
Fix:
finally {
if (_taskCts.TryRemove(task.Id, out var cts))
cts.Dispose();
}Layer: Agents
File: src/Sovrant.Agents/Isolated/ProcessBasedOrchestrationSystem.cs:114-119
Problem: Race condition between Dispose() iterating _taskCts and RunTaskAsync() adding new entries.
Fix: Add locking or use a disposed flag to reject new tasks during shutdown.
Layer: Agents
File: src/Sovrant.Agents/Shared/OrchestrationCoordinator.cs:17-18,42
Problem: _agents is a plain Dictionary<string, IAgent> without synchronization. Concurrent AddAgent() and DispatchAsync() calls cause InvalidOperationException.
Fix: Use ConcurrentDictionary<string, IAgent>.
Layer: Agents
File: src/Sovrant.Agents/Shared/BaseAgent.cs:64
Problem: _ = await HandleAsync(task, ct) — agent results from RunLoopAsync are discarded. The inbox/channel infrastructure is unused since DispatchAsync calls HandleAsync directly.
Fix: Either implement result-passing mechanism or remove dead RunLoopAsync/EnqueueAsync code.
Layer: Agents
File: src/Sovrant.Agents/Shared/InProcessOrchestrationSystem.cs:17
Problem: DisposeAsync() calls ShutdownAsync() but never disposes the OrchestrationCoordinator (which owns a SemaphoreSlim).
Fix: Call _coordinator?.Dispose() in DisposeAsync().
Layer: Agents
File: src/Sovrant.Agents/Isolated/ProcessAgent.cs:35-51
Problem: ProcessStartInfo is accepted without validating FileName or Arguments. Untrusted config can lead to arbitrary code execution.
Fix: Validate FileName against an allowlist. Document that ProcessAgent must only be used with trusted inputs.
Layer: Tools
File: src/Sovrant.Tools/Team/TeamDelegateTool.cs:26,55
Problem: _initialized is a plain HashSet<string>. Concurrent delegations to the same member can race on .Add(), causing duplicate agent registration.
Fix: Use ConcurrentDictionary<string, byte> or wrap with lock.
Layer: Tools
File: src/Sovrant.Tools/Tasks/BackgroundTaskInfo.cs:29
Problem: OutputBuffer (StringBuilder) is public and mutable. External code can access without locking despite TaskCreateTool using locks internally.
Fix: Make OutputBuffer internal. Provide thread-safe accessor methods only.
Layer: Runtime
File: src/Sovrant.Runtime/Logging/AsyncRollingFileLoggerProvider.cs:39
Problem: Dispose() calls _writerTask.GetAwaiter().GetResult() blocking the calling thread on shutdown.
Fix: Use DisposeAsync() exclusively or implement non-blocking cleanup.
Layer: Providers
File: src/Sovrant.Api/Routing/SmartRouter.cs:145
Problem: _ = RecheckAsync(...) with CancellationToken.None. The recheck task can't be cancelled on shutdown and failures are silently lost.
Fix: Track the task for shutdown. Pass a proper cancellation token.
Layer: Runtime
File: src/Sovrant.Runtime/Mcp/McpOAuthService.cs:94,125
Problem: OAuth state values are partially logged (state[..8] + "..."). Even truncated values leak information.
Fix: Use opaque session IDs for logging instead of actual state values.
Layer: Providers
File: src/Sovrant.Api/Providers/ProviderApiProvider.cs:83
Problem: response.EnsureSuccessStatusCode() inside async enumerable without try-catch. Exception propagates in iterator context.
Fix: Wrap in try-catch and yield appropriate error events.
Layer: Server
File: src/Sovrant.Server/Routes/ConfigRoutes.cs:37-38
Problem: PUT /v1/config with api_key field has no audit trail.
Fix: Log credential changes with timestamps and key fingerprints.
Layer: Server
File: src/Sovrant.Server/Routes/ConfigRoutes.cs:34-35,47-52
Problem: No validation of model or provider name format. Invalid names break downstream code.
Fix: Validate against pattern/whitelist before assignment.
Layer: Server
File: src/Sovrant.Server/Routes/WebhookRoutes.cs:108
Problem: _ = callbackService.DeliverAsync(...) with CancellationToken.None. Lost on shutdown.
Fix: Use IHostedService or background task queue with proper lifecycle.
Layer: Server
File: src/Sovrant.Server/Webhooks/WebhookCallbackService.cs:40-45
Problem: No HttpClient.Timeout set. Malicious webhook URL that never responds ties up resources indefinitely.
Fix: Set client.Timeout = TimeSpan.FromSeconds(10).
Layer: Server
File: src/Sovrant.Server/Program.cs:87-100
Problem: .AllowAnyMethod() and .AllowAnyHeader() even though only specific methods/headers are needed.
Fix: Explicitly list GET, POST, PUT, DELETE, OPTIONS and Content-Type, Accept, Authorization, X-Session-Id, X-LLM-Api-Key, X-LLM-Base-Url.
Layer: TypeScript SDK
File: sdk/js/src/client.ts:227,243,275
Problem: getModels(), getSession(), getUsage() return Promise<unknown> or Record<string, unknown>.
Fix: Define ModelsResponse, SessionResponse, UsageResponse interfaces in types.ts.
Layer: TypeScript SDK
File: sdk/js/src/client.ts:332
Problem: fetch() call has no timeout. Non-streaming requests can hang indefinitely.
Fix: Add AbortController with configurable timeout (default 30s). Make configurable via SovrantClientOptions.timeout.
Layer: TypeScript SDK
File: sdk/js/tests/
Problem: All tests are security-focused. No happy-path tests for chat(), stream(), session management, retry exhaustion, or tool event dispatching.
Fix: Add client-functional.test.ts and hook-functional.test.ts.
Layer: Tools
File: All 49 tool files
Problem: Every tool reimplements GetString(), GetInt(), GetBool(), GetDouble() helpers.
Fix: Create shared JsonElementExtensions with these as extension methods.
Layer: Tools
File: BashTool, PowerShellTool, ReplTool, TaskCreateTool, EnterWorktreeTool, ExitWorktreeTool
Problem: Process spawning, output capture, and error handling logic duplicated 6+ times.
Fix: Extract ProcessExecutor utility class.
Layer: Tools
File: All 49 tool files
Problem: Each tool manually constructs a static ToolDefinition with hand-written JSON schema. Error-prone and violates DRY.
Fix: Implement schema builder pattern or reflection-based generation.
Layer: Tools
File: src/Sovrant.Tools/Core/GrepTool.cs:87-88
Problem: IOException and UnauthorizedAccessException silently skipped with /* skip unreadable files */. Hides legitimate permission errors.
Fix: Track and report skipped file count.
| # | File | Issue |
|---|---|---|
| M1 | ChatRoutes.cs:137-138 |
Per-request model override mutates global serverConfig.Model — race condition |
| M2 | ChatRoutes.cs, SessionRoutes.cs |
No validation of session ID format (length, characters) |
| M3 | SessionRoutes.cs:54,90,110 |
Race between TryGetConfig check and use — session could be evicted |
| M4 | Multiple routes | Inconsistent error response format (JSON vs HTML vs plain text) |
| M5 | SseWriter.cs:16-21 |
Missing X-Content-Type-Options: nosniff security header |
| M6 | ChatRoutes.cs:229-274 |
No timeout on buffered response — hung LLM provider blocks indefinitely |
| M7 | WebhookRoutes.cs:50-60 |
Callback URL not validated against reserved IP ranges (DNS rebinding) |
| M8 | ConfigRoutes.cs:40-41 |
BaseUrl assigned without Uri.TryCreate validation |
| # | File | Issue |
|---|---|---|
| M9 | ConversationRuntime.cs:406,410,414 |
JsonDocument.Parse() results not disposed (use using) |
| M10 | SmartRouter.cs:81,114,129 |
Linear scan of _providers — use Dictionary for O(1) lookup |
| M11 | AsyncRollingFileLoggerProvider.cs:24-28 |
Bounded channel with DropOldest silently drops log messages |
| M12 | SessionConfig.cs:12-13 |
volatile on reference types doesn't guarantee atomicity of compound operations |
| M13 | MutableCliPermissionPolicy.cs:11 |
volatile PermissionMode — race between check and use |
| M14 | SmartRouter.cs:20 |
volatile string? — subsequent comparison and use are not atomic |
| M15 | RuntimeSessionPool.cs:68-75 |
Race in session creation — loser's lock could be disposed while in use |
| M16 | McpOAuthService.cs:119,145 |
Lock held during PurgeExpiredStates() — contention under load |
| M17 | ConversationRuntime.cs:492-495 |
Broad catch (Exception) swallows critical exceptions in compaction |
| M18 | DefaultToolExecutor.cs:120-122 |
Temp files with predictable names — path disclosure risk |
| M19 | SmartRouter.cs:117-119 |
Error reveals all configured provider names |
| M20 | RuntimeSessionPool.cs:61-62 |
Session ID persistenceId not validated for filesystem safety |
| M21 | DefaultToolExecutor.cs:49 |
toolName not validated before use in file path construction |
| # | File | Issue |
|---|---|---|
| M22 | SkillTool.cs:37-40 |
Path traversal check incomplete — doesn't handle symlinks or 8.3 names |
| M23 | WebSearchTool.cs:59,98 |
API keys passed in headers without log scrubbing |
| M24 | Multiple tools | Inconsistent error message formatting ("Error: ..." vs contextual) |
| M25 | Multiple tools | Inconsistent CancellationToken handling across sync tools |
| M26 | GlobTool.cs:43-46 |
File.GetLastWriteTimeUtc() per file — O(n) stat calls for sorting |
| M27 | ReadFileTool.cs:40-49 |
Entire file loaded into memory before checking limit |
| M28 | GrepTool.cs:46 |
No regex pattern complexity validation — ReDoS risk |
| M29 | All tools | No input validation against JSON schemas — schemas defined but not enforced |
| M30 | Multiple tools | Inconsistent return types (JSON, plain text, formatted tables) |
| M31 | GrepTool.cs:46 |
Hardcoded 5-second regex timeout — not configurable |
| M32 | All tools | No global timeout enforcement at registry level |
| M33 | WebSearchTool.cs, WebFetchTool.cs |
No client-side rate limiting for external API calls |
| M34 | ReadFileTool, WriteFileTool, EditFileTool | No restriction of file operations to project boundaries |
| M35 | WorktreeState.cs:5-6,12-16 |
volatile string? — compound check-then-use is not atomic |
| # | File | Issue |
|---|---|---|
| M36 | WorkspaceContext.cs:10-36 |
No agent isolation — all agents share same workspace key space |
| M37 | OrchestrationCoordinator.cs:70-86 |
Semaphore held for entire HandleAsync duration — starves other agents |
| M38 | OrchestrationCoordinator.cs:113-136 |
ShutdownAsync iterates _taskCts.Values without snapshot — concurrent modification |
| M39 | TeamMemberInfo.cs:20-22 |
Mutable Status/LastOutput/LastError without thread safety |
| M40 | TeamMemberInfo.cs:11-17 |
No input validation on Id, Name, SystemPrompt |
| M41 | BaseAgent.cs:60-66 |
Unhandled exception in RunLoopAsync kills agent silently |
| M42 | Program.cs:69-96 |
Exit codes only set in CI mode — interactive mode always exits 0 |
| M43 | BaseAgent.cs:15-20 |
Unbounded channel — no backpressure, memory exhaustion risk |
| M44 | SovrantAgentFactory.cs:62-66 |
Tool filter created at agent creation — doesn't update if registry changes |
| # | File | Issue |
|---|---|---|
| M45 | client.ts:104-119 |
Missing response validation — malformed responses return empty string silently |
| M46 | client.ts:330-358 |
Retry logic: fixed delays, no jitter, no exponential backoff beyond hardcoded values |
| M47 | client.ts:362-372 |
Flat error hierarchy — single SovrantApiError for all failure modes |
| M48 | client.ts:165-169 |
Streaming errors lack context (partial text, bytes received, attempt number) |
| M49 | client.ts:105-128 |
Per-call options object defined inline 4 times — should be shared interface |
| M50 | hooks/use-chat.ts:122-154 |
Object cloning on every state update — GC pressure with large history |
| M51 | types.ts:136-148 |
StreamCallbacks can't pause, retry, or cancel a stream |
| M52 | client.ts:81-82 |
No format validation of llmApiKey value |
| M53 | client.ts |
No request/response logging support for debugging |
| M54 | client.ts |
No client-side rate limiter or circuit breaker |
| M55 | tsconfig.json / package.json |
type-check not run as part of build script |
| M56 | tests/ |
Only 1 retry test — doesn't cover 5xx, network errors, maxRetries exhaustion |
| M57 | tests/ |
No integration tests against mock HTTP server (MSW/nock) |
| M58 | tests/hook-security.test.ts |
Only 4 tests — no functional coverage for send(), clearing, concurrent sends |
| # | Layer | File | Issue |
|---|---|---|---|
| L1 | Runtime | ConversationRuntime.cs:204,384 |
Unnecessary .ToList() materialization |
| L2 | Runtime | ConversationRuntime.cs:456-457 |
String concatenation in LINQ Select hot path |
| L3 | Runtime | ConversationRuntime.cs:266 |
s_retryDelaysMs array partially used |
| L4 | Runtime | ConversationRuntime.cs:567 |
IOException silently ignored in AppendMemoryFile() |
| L5 | Runtime | SessionConfig.cs:13 |
Magic value -1 for "use global default" — unclear |
| L6 | Runtime | McpOAuthService.cs:36-38 |
Pending OAuth states not cleared on shutdown |
| L7 | Runtime | McpOAuthService.cs:80-90 |
No validation of OAuth ClientId/Secret format |
| L8 | Runtime | McpOAuthService.cs:64 |
new HttpClient() instead of injected — should use factory |
| L9 | Providers | OpenAiCompatProvider.cs:114,120 |
Missing null check on chunk.Choices |
| L10 | Providers | IAuthProvider.cs:14 |
No ConfigureAwait(false) enforcement guidance |
| L11 | Server | Various routes | Unnecessary .ToList() allocations in hot paths |
| L12 | Server | ConfigRoutes.cs:43-44 |
Invalid enum values silently ignored — no warning log |
| L13 | Server | WebhookRoutes.cs:69 |
webhook:{source}:{userId} prefix could collide with user sessions |
| L14 | Server | Program.cs |
No explicit request body size limit configured |
| L15 | Server | Multiple routes | Missing X-Content-Type-Options header |
| L16 | Server | BearerTokenMiddleware.cs |
No logging of successful authentication |
| L17 | Tools | PowerShellTool.cs:100 |
Schema includes unused "description" property |
| L18 | Tools | NotebookEditTool.cs:88-91 |
newSource not validated for null/empty on cell insert |
| L19 | Tools | BashTool.cs:13 |
Output cap 256 KB hardcoded — not configurable |
| L20 | Tools | Multiple task/worktree tools | Multiple DateTime.UtcNow calls without caching |
| L21 | Tools | GlobTool.cs:52 |
string.Join for 1000+ files — use StringBuilder |
| L22 | Tools | EnterWorktreeTool.cs:83-84 |
EscapeArg only quotes spaces — misses shell metacharacters |
| L23 | Tools | Multiple tools | Vague error messages without remediation guidance |
| L24 | Tools | NotebookEditTool.cs:2 |
Potentially unused using statement |
| L25 | Agents | Program.cs:288-316 |
No default case in event switch — new events silently ignored |
| L26 | Agents | ServiceCollectionExtensions.cs:33-34 |
Shared backend singletons registered even in isolated mode |
| L27 | Agents | Program.cs:257-283 |
CLI REPL has no input length validation |
| L28 | Agents | ProcessAgent.cs:64 |
New process per task — no pooling |
| L29 | SDK | sse.ts:74 |
safeJsonParse exported but may be internal |
| L30 | SDK | client.ts:145 |
String concatenation in stream loop — use array + join |
| L31 | SDK | client.ts:259 |
exportSession default format could surprise consumers |
| L32 | SDK | client.ts:201-230 |
Method naming inconsistency (getConfig vs listSessions) |
| L33 | SDK | types.ts, client.ts |
No deprecation support for future field removal |
| L34 | SDK | package.json |
React hook export pattern could be documented better |
| L35 | SDK | sse.ts:64-66 |
Malformed SSE chunks silently skipped — no error callback |
| L36 | SDK | client.ts:284 |
health() unauthenticated — correct but undocumented |
| L37 | SDK | client.ts:369 |
URL included in error message — could leak if query params added |
| L38 | SDK | No README.md in sdk/js/ |
Missing SDK usage documentation |
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| 1.1 | SSRF via X-LLM-Base-Url | Medium | Attackers probe internal network |
| 1.2-1.5 | Command injection (4 tools) | Medium | Arbitrary code execution |
| 1.6 | Rate limiting bypass | Low | DoS / abuse |
| 1.7 | Startup token validation | Low | Confusing failure mode |
| 1.8 | Unhandled exceptions in chat | Low | Information leakage |
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| 2.1-2.2 | CTS leaks — dispose on TryRemove | Low | Memory leak over time |
| 2.3 | Unsafe Dictionary → ConcurrentDictionary | Low | InvalidOperationException crash |
| 2.5 | Coordinator disposed in DisposeAsync | Low | Semaphore leak |
| 2.7 | HashSet → ConcurrentDictionary | Low | Duplicate agent registration |
| 2.9 | Blocking dispose → bounded Wait(2s) | Low | Shutdown hang |
| 2.10 | Fire-and-forget → tracked + cancellable | Low | Delayed shutdown |
| M1 | Global config mutation → session-only | Medium | Non-deterministic model selection |
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| 2.14 | Model/provider/URL validation in ConfigRoutes | Low | Runtime crashes |
| 2.17 | CORS restricted to explicit methods+headers | Low | Reduced defense-in-depth |
| M2 | Session ID validated (regex, 1-128 chars) across all routes | Low | Filesystem issues |
| M20 | Session persistence GetFullPath containment check | Low | Path traversal |
| M21 | Tool name sanitized before temp file path construction | Low | Path traversal in temp files |
| M22 | SkillTool: invalid filename chars check + GetFullPath containment | Low | File access bypass |
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| 2.21 | JsonElementExtensions — shared GetStringProp/GetIntProp/GetBoolProp (35 files) | Medium | Maintenance burden |
| 2.22 | ProcessExecutor — shared RunAsync/RunWithTempFileAsync (BashTool, PowerShellTool, ReplTool) | Medium | Duplicated bug fixes |
| 2.23 | Schema builder — deferred (High effort, current approach functional) | High | Error-prone schema definitions |
| 2.4 | Removed dead BaseAgent inbox/channel/RunLoopAsync/EnqueueAsync code | Low | Confusing architecture |
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| 2.19 | AbortController-based request timeout (default 120s, configurable) | Low | Hanging requests |
| 2.18 | Typed responses: ModelsResponse, SessionDetail, SessionListResponse, UsageSummary | Medium | Type-unsafe consumption |
| 2.20 | Functional test suite — deferred (requires Node.js) | High | Regression risk |
| M46 | Exponential backoff with ±25% jitter on retries | Low | Thundering herd |
| M47 | Error hierarchy: SovrantAuthError, SovrantRateLimitError, SovrantTimeoutError | Medium | Poor error handling |
| M49 | Shared ChatCallOptions interface replacing 4 inline option types | Low | DRY |
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| M10 | Provider lookup via Dictionary for O(1) name resolution | Low | Slightly slower routing |
| M26 | GlobTool: skip stat calls when matches exceed 1000 (return unsorted) | Low | Slow on large directories |
| M27 | ReadFileTool: stream lines instead of ReadAllLinesAsync (constant memory) | Medium | Memory spike on large files |
| M37 | Semaphore contention — deferred (configurable MaxConcurrentAgents suffices) | Medium | Agent starvation |
| L-series | Various minor items — deferred | Low each | Polish |
- Session isolation via composite key (
{session_id}::{provider}) is a solid multi-tenant pattern - Permission system with session-scoped overrides is well-designed
- Provider router with health checks and scoring provides good resilience
- JSONL session persistence is simple and effective for the append-log pattern
- Token redaction in SDK's
toJSON()and error messages shows security awareness - Tool registration via DI is clean and extensible
volatilemisuse — appears in 4+ files. Consider aThreadSafe<T>wrapper or switch tolock/Interlocked- Fire-and-forget tasks — 3+ locations use
_ = SomeAsync(). Need a tracked background task pattern - Process execution — 6 tools duplicate the spawn-capture-timeout pattern. Single
ProcessExecutorfixes all - Input validation — most server endpoints accept user strings without format/length validation. Consider a shared validation layer or middleware
- Error response format — routes return JSON objects, HTML pages, and plain text. Standardize on
{ "error": string, "code"?: string }
Generated: 2026-04-05 | Test count: 329 | Tool: XPlat Code Coverage + ReportGenerator
| Metric | Value |
|---|---|
| Line coverage | 30.8% (4,969 / 16,112) |
| Branch coverage | 24.4% (856 / 3,496) |
| Method coverage | 34.6% (577 / 1,664) |
| Full method coverage | 27.2% (454 / 1,664) |
| Assemblies | 9 |
| Classes | 258 |
| Assembly | Line Coverage | Notes |
|---|---|---|
| Sovrant.Agents | 59.6% | Best covered — OrchestrationCoordinator 91%, InMemoryTeamRegistry 100%, AgentPrompts 100% |
| Sovrant.Runtime | 48.6% | RuntimeSessionPool 99%, SovrantConfig 92%, FilteredToolRegistry 100%; gaps in logging, MCP, prompt builder |
| Sovrant.Commands | 41.1% | SlashCommandDispatcher 83%, TokenUsageTracker 100%; HelpCommand, MemoryCommand, SessionCommand at 0% |
| Sovrant.Api | 33.0% | FormatConverter 89%, ProviderInfo 93%; Responses API types and Ollama provider uncovered |
| Sovrant.Mcp | 31.6% | ToolFilter 100%, McpTokenValidator 100%; McpServerSetup 8% |
| Sovrant.Tools | 25.4% | ProcessExecutor 82%, TeamDelegateTool 72%; 18 tools at 0% (Glob, Grep, WebFetch, REPL, PowerShell, etc.) |
| Sovrant.Lsp | 19.3% | LspClientManager 63%; LspClient 11%, JsonRpc transport 0% |
| Sovrant.Server | 3.9% | Only webhook classes covered (100%); all routes, middleware, auth at 0% |
| sovrant (CLI) | 0% | No unit tests — requires integration testing |
- Server routes (0%) — ChatRoutes, SessionRoutes, ConfigRoutes have zero coverage. These are the primary API surface and contain the security fixes from Phases A-C.
- Core tools (0%) — GlobTool, GrepTool, ListDirectoryTool, WebFetchTool have no tests. These are high-usage tools.
- CLI entry point (0%) — Expected for a console host; integration tests recommended.
- LSP transport (0%) — JsonRpc layer untested; fragile protocol code.
- Input validation (0%) —
InputValidationclass (Phase C addition) has no dedicated tests.
- OrchestrationCoordinator — 91.4% (Phase 19 core)
- RuntimeSessionPool — 98.7%
- SovrantConfig — 91.6%
- FormatConverter — 89.2%
- ProcessExecutor — 82.1% (Phase D addition)
- ModeAwarePermissionPolicy — 96.4%
- EditFileTool — 59.3%
- ReadFileTool — 56.6%
- Add server integration tests — Use
WebApplicationFactory<Program>to test routes with real middleware pipeline. Would cover ChatRoutes, SessionRoutes, ConfigRoutes, auth middleware, and InputValidation in one pass. - Add tool execution tests — Mock
IToolRegistryand test GlobTool, GrepTool, ReadFileTool edge cases (invalid paths, large files, binary detection). - Target 60% line coverage — Focus on server (0% → 50%) and tools (25% → 50%) for highest ROI.
- Add mutation testing — Line coverage doesn't guarantee assertion quality. Consider Stryker.NET for mutation score.
Scope: Line-by-line review of all .cs and .ts files across Server, Tools, Runtime, Providers, Agents, CLI, Desktop, Web, LSP, MCP Server, and TypeScript SDK. Focused on issues missed in Round 1 and new code added since Phases A–F.
Layer: MCP Server
File: src/Sovrant.Mcp/McpServerSetup.cs:155,179
Problem: store.ListAsync(ownerUserId: null, ct) and store.LoadAsync(sessionId, ownerUserId: null, ct) pass null for the owner filter. Any authenticated MCP client can read every user's conversation history, tool outputs, and sensitive data.
Fix: Extract user identity from MCP client connection context and pass it as ownerUserId. If MCP protocol doesn't provide user context, require an explicit owner claim in the MCP token and validate it.
Layer: Server
File: src/Sovrant.Server/Routes/ProjectRoutes.cs:41-189
Problem: None of the ProjectRoutes handlers (ListProjects, CreateProject, UpdateProject, DeleteProject, membership operations) perform HTTP-layer authorization checks. While ProjectContextMiddleware calls HasAccessAsync(), the route handlers themselves have zero ctx.IsAdmin() or ctx.CanActOnUser() guards. If the service layer has any auth bug, or direct API calls bypass middleware, attackers can manipulate other users' projects.
Fix: Add explicit authorization checks in each handler:
if (!ctx.IsAdmin() && !(await projectService.CanUserModifyAsync(id, ctx.GetUserId(), ct)))
return Results.Forbid();Layer: Tools
File: src/Sovrant.Tools/Worktree/EnterWorktreeTool.cs:44-46,64
Problem: Git arguments are constructed via string concatenation with user input. EscapeArg() only adds quotes for spaces — shell metacharacters (", \, $, backticks) are not escaped. Same pattern as the original 1.2–1.5 issues that were fixed in Phase A, but the worktree tools were missed.
Fix: Use ProcessStartInfo.ArgumentList instead of building an Arguments string:
var psi = new ProcessStartInfo { FileName = "git" };
psi.ArgumentList.Add("worktree");
psi.ArgumentList.Add("add");
if (createNew) psi.ArgumentList.Add("-b");
psi.ArgumentList.Add(branch);
psi.ArgumentList.Add(path);Layer: Tools
File: src/Sovrant.Tools/Worktree/ExitWorktreeTool.cs:34-36,55
Problem: Same EscapeArg-only pattern as 8.3.
Fix: Same — use ProcessStartInfo.ArgumentList.
Layer: Providers
File: src/Sovrant.Api/Routing/SmartRouter.cs:261-273
Problem: ProviderInfo properties (RequestCount, ErrorCount, AvgLatencyMs, Healthy) are modified without synchronization from multiple threads. info.RequestCount++ is not atomic — concurrent increments lose counts. The exponential moving average calculation is a classic read-modify-write race.
Fix: Use Interlocked.Increment for counters. For AvgLatencyMs, use lock or compute-and-swap with Interlocked.CompareExchange.
Layer: Providers
File: src/Sovrant.Api/Routing/SmartRouter.cs:359
Problem: Task.WhenAll(tasks).Wait(TimeSpan.FromSeconds(5)) blocks the calling thread during disposal. If tasks don't complete, they are abandoned without cleanup. Can deadlock if called from a synchronization context.
Fix: Implement IAsyncDisposable and use await Task.WhenAll(tasks) with a timeout via Task.WhenAny:
public async ValueTask DisposeAsync()
{
_shutdownCts.Cancel();
Task[] tasks;
lock (_recheckTasks) { tasks = [.. _recheckTasks]; }
await Task.WhenAny(Task.WhenAll(tasks), Task.Delay(TimeSpan.FromSeconds(5)));
_shutdownCts.Dispose();
}Layer: Web, Desktop, LSP
Files: src/Sovrant.Web/Program.cs:78, src/Sovrant.Desktop/App.axaml.cs:141, src/Sovrant.Lsp/LspClient.cs:64
Problem: _ = Task.Run(async () => { ... }) without any exception handling. If the background task throws, the exception becomes an unobserved task exception that can terminate the process (depending on TaskScheduler.UnobservedTaskException configuration).
Fix: Wrap all fire-and-forget tasks with try-catch:
_ = Task.Run(async () =>
{
try { /* ... */ }
catch (Exception ex) { logger.LogError(ex, "Background initialization failed"); }
});Layer: TypeScript SDK
File: sdk/js/src/hooks/use-chat.ts:86-90
Problem: clientRef.current is created once via useRef and never updated when options (baseUrl, token, model, llmApiKey) change. In multi-tenant scenarios or credential rotation, the hook continues using stale configuration silently.
Fix:
useEffect(() => {
clientRef.current = new SovrantClient(options);
}, [options.baseUrl, options.token, options.model, options.sessionId,
options.maxRetries, options.timeoutMs, options.llmApiKey, options.llmBaseUrl]);Layer: TypeScript SDK
File: sdk/js/src/sse.ts:62-66
Problem: try { yield safeJsonParse(data); } catch { } silently discards unparseable chunks. Users see incomplete responses with no error indication. Impossible to debug in production.
Fix: Add warning logging and optional error callback:
export async function* parseSSEStream(
response: Response,
onParseError?: (error: Error, data: string) => void
): AsyncGenerator<ChatCompletionChunk> {
// ...
try { yield safeJsonParse(data); }
catch (err) { onParseError?.(err as Error, data); }
}Layer: Tools
File: src/Sovrant.Tools/Shell/ShellEnvironment.cs:68
Problem: using var proc = Process.Start(psi)!; uses null-forgiving operator. Process.Start() can return null if the process fails to start, causing NullReferenceException.
Fix: Check for null return:
var proc = Process.Start(psi);
if (proc == null) return new PowerShellInfo(path, Version: null, Edition: null);
using (proc) { /* ... */ }Layer: Server
File: src/Sovrant.Server/Middleware/ETagMiddleware.cs:33-64
Problem: Buffers the full response into a MemoryStream to compute ETag hash. For streaming responses (SSE, large files), this defeats streaming, causes memory spikes, and enables DoS via large response amplification. No buffer size limit.
Fix: Skip buffering for streaming/large responses:
if (context.Response.ContentType?.Contains("text/event-stream") == true)
{ await next(context); return; }Layer: Server
File: src/Sovrant.Server/Routes/ChatRoutes.cs:369-395
Problem: IsReservedAddress() only blocks IP-based requests. DNS names are allowed unconditionally (return false; for non-IP hosts). Attackers can use localhost.example.com or DNS rebinding to reach internal endpoints.
Fix: Implement async DNS resolution with timeout, block resolved private IPs, and maintain a denylist of known internal hostnames.
Layer: Server
Files: src/Sovrant.Server/Routes/SessionRoutes.cs:162-163, src/Sovrant.Server/Routes/WebhookRoutes.cs:79-80
Problem: req.Model is assigned directly to sessionConfig.Model without calling InputValidation.IsValidModelName(). ChatRoutes validates (line 190) but SessionRoutes and WebhookRoutes do not.
Fix: Add validation before assignment in all routes.
Layer: Server
File: src/Sovrant.Server/Routes/SwarmRoutes.cs:88
Problem: _ = WriteSseEventAsync(ctx.Response, evt.GetType().Name, evt, CancellationToken.None) — SSE writes are fire-and-forget. If the client disconnects, writes fail silently with no backpressure or error logging.
Fix: Await the write and handle OperationCanceledException for client disconnection.
Layer: Server
File: src/Sovrant.Server/SessionEvictionService.cs:50-64
Problem: _pool.EvictExpired() is called without passing the stoppingToken. If eviction involves I/O, it can't be cancelled on shutdown.
Fix: Pass stoppingToken to EvictExpired.
Layer: Providers
Files: src/Sovrant.Api/Providers/OpenAiCompatProvider.cs:246, ProviderApiProvider.cs:120, OpenAiResponsesProvider.cs:231
Problem: JsonDocument.Parse(body) returns an IDisposable. While using is present in some cases, early-return code paths can exit before the using block completes disposal.
Fix: Restructure to ensure all paths go through the using block.
Layer: Providers
File: src/Sovrant.Api/Routing/SmartRouter.cs:27
Problem: volatile string? _pinnedProviderName — volatile ensures visibility but read-then-use is not atomic. Provider could be unpinned between read and use.
Fix: Use Interlocked.Exchange/Interlocked.CompareExchange or proper locking.
Layer: Runtime
File: src/Sovrant.Runtime/Hooks/HookRunner.cs:24
Problem: JSON deserialization uses PropertyNameCaseInsensitive = true without TypeInfoResolver, enabling potential type confusion. Should use the source-generated SovrantJsonContext.
Fix: Switch to source-generated serializer context.
Layer: Runtime
File: src/Sovrant.Runtime/Mcp/McpOAuthService.cs:64
Problem: _http = new HttpClient() — creates unshared instance per service. Causes socket exhaustion and port leaks. (Upgrade from L8 in Round 1.)
Fix: Inject IHttpClientFactory or use a shared singleton HttpClient.
Layer: Agents
Files: src/Sovrant.Agents/Shared/WorkspaceContext.cs:80,108, Swarm/SwarmSession.cs:90,95, Commands/ArtifactsCommand.cs:75, Web/Components/Pages/Integrations.razor:109
Problem: .GetAwaiter().GetResult() blocks threads and can deadlock (especially in UI/ASP.NET synchronization contexts).
Fix: Make callers async end-to-end. Replace sync property getters with async methods.
Layer: Runtime
File: src/Sovrant.Runtime/Conversation/ConversationRuntime.cs:311,359
Problem: _ = _hookRunner.RunAsync(...) with CancellationToken.None. If hook throws, the exception is unobserved.
Fix: Attach .ContinueWith(t => logger.LogWarning(t.Exception, "Hook failed"), TaskContinuationOptions.OnlyOnFaulted).
Layer: Agents
File: src/Sovrant.Agents/Swarm/SwarmFileLockManager.cs:39-44
Problem: Iterating _fileLocks dictionary while concurrent tasks may modify it. ConcurrentDictionary enumeration is safe but TryRemove during enumeration can miss items added concurrently.
Fix: Snapshot keys first:
var keysToRemove = _fileLocks.Where(kvp => kvp.Value == taskId).Select(kvp => kvp.Key).ToList();
foreach (var key in keysToRemove) _fileLocks.TryRemove(key, out _);Layer: Agents
File: src/Sovrant.Agents/Swarm/SwarmOrchestrator.cs:128-192
Problem: If Task.WhenAll(execTasks) throws during wave execution, semaphore slots acquired by completed tasks may not be released before the semaphore is disposed in finally.
Fix: Track pending semaphore holders and release in catch block before re-throw.
Layer: TypeScript SDK
File: sdk/js/src/client.ts:134-179
Problem: If any callback (onText, onToolUse, onComplete) throws, the stream terminates early, all subsequent chunks are lost, and onComplete is never called with final data.
Fix: Wrap each callback invocation in try-catch.
Layer: TypeScript SDK
File: sdk/js/src/hooks/use-chat.ts:92-170
Problem: send callback closes over clientRef but dependency array is [isStreaming, options]. If client is recreated (after fixing 8.8), the callback still references the old client.
Fix: Include clientRef in dependencies, or better: use useMemo for client creation.
Layer: MCP Server
File: src/Sovrant.Mcp/McpServerSetup.cs:97-118
Problem: Only InvalidOperationException, IOException, and JsonException are caught. Any other exception (NullReferenceException, TimeoutException, ArgumentException) crashes the MCP server process.
Fix: Add a catch-all catch (Exception ex) that returns an error result and logs.
Layer: MCP Server
File: src/Sovrant.Mcp/McpServerSetup.cs:122-143
Problem: sovrant://config resource exposes the full SovrantConfig (model selection, provider URLs, permission modes, pinned providers) to any MCP client with token access.
Fix: Either remove the config resource or gate it behind admin authorization.
Layer: Agents
File: src/Sovrant.Agents/Swarm/SwarmToolExecutor.cs:60-75
Problem: File paths from tool input are not validated. An agent could specify ../../sensitive_file to escape the artifacts directory.
Fix: Validate with Path.GetFullPath containment check against allowed root.
Layer: Server
File: src/Sovrant.Server/Routes/ConfigRoutes.cs:59-60
Problem: req.ApiKey is assigned to config.LlmApiKey without checking for empty/whitespace. An attacker could set the API key to empty, breaking all LLM calls server-wide.
Fix: Validate non-empty and reasonable length before assignment.
Layer: TypeScript SDK
File: sdk/js/src/hooks/use-chat.ts:46-49
Problem: genId() uses Date.now() + incrementing counter. Concurrent send() calls within the same millisecond can collide. React key reconciliation could fail.
Fix: Use crypto.randomUUID().
| # | File | Issue |
|---|---|---|
| R2-M1 | Program.cs (Kestrel config) |
No explicit MaxRequestBodySize or MaxRequestLineSize — defaults may be too permissive for DoS prevention |
| R2-M2 | ConfigRoutes.cs:59-60 |
No audit logging on API key changes (similar to Round 1 2.13 but for PUT path) |
| R2-M3 | WebhookCallbackService.cs:44 |
No explicit HttpClient.Timeout — default 100s may be too long for callback endpoints |
| R2-M4 | ProjectRoutes.cs, WorkspaceRoutes.cs |
Unvalidated {id}, {wid}, {userId} route parameters — no format validation |
| # | File | Issue |
|---|---|---|
| R2-M5 | RuntimeSessionPool.cs:87-91,216-224 |
Fire-and-forget hookRunner.RunAsync() with CancellationToken.None — unobserved exceptions |
| R2-M6 | ConversationRuntime.cs:220 |
Non-null assertion provider! is fragile — could NPE if routing exception is caught above |
| R2-M7 | HookRunner.cs:179 |
Process not killed if WaitForExitAsync throws — add if (!process.HasExited) process.Kill() in finally |
| # | File | Issue |
|---|---|---|
| R2-M8 | PowerShellTool.cs:30-31 |
No command length validation — very large commands base64-encoded can exceed PowerShell limits or cause OOM |
| R2-M9 | ProcessExecutor.cs:37-51 |
OutputDataReceived/ErrorDataReceived callbacks don't check cancellation — buffering continues after cancel |
| R2-M10 | ProcessExecutor.cs:133 |
Temp file cleanup silently fails with no logging — files accumulate |
| R2-M11 | TaskCreateTool.cs:48-139 |
Process event handlers never unhooked — handlers leak if process is referenced elsewhere |
| R2-M12 | ReadFileTool.cs:33-36 |
TOCTOU race: file could grow between size check and read |
| # | File | Issue |
|---|---|---|
| R2-M13 | SwarmOrchestrator.cs:182-188 |
On cancellation, individual task nodes left in "Running" state — should be marked "Cancelled" |
| R2-M14 | ChatViewModel.cs:107-120 |
Messages added to UI collection from async context without Dispatcher.UIThread.InvokeAsync |
| R2-M15 | SlashCommandDispatcher.cs:42-52 |
Unhandled exceptions when reading project-local command files (permissions, encoding) |
| R2-M16 | LspClient.cs:64-72 |
stderr drain loop doesn't check cancellation token — can hang indefinitely |
| # | File | Issue |
|---|---|---|
| R2-M17 | sse.ts:26-72 |
No timeout on SSE stream reading — once response starts, can hang forever |
| R2-M18 | client.ts:113-128 |
No validation of response JSON structure — as ChatCompletionResponse is a compile-time-only cast |
| # | Layer | File | Issue |
|---|---|---|---|
| R2-L1 | Server | RequestLoggingMiddleware.cs:24-35 |
Request paths logged at INFO may contain sensitive session IDs |
| R2-L2 | Server | ETagMiddleware.cs:45 |
ETag uses truncated hash (first 8 bytes of SHA256) — acceptable but full hash is more robust |
| R2-L3 | Server | HttpContextAuthExtensions.cs:14-22 |
ctx.Items[] cast to string silently returns null on type mismatch — hides bugs |
| R2-L4 | Server | WorkspaceContextMiddleware.cs:53-57 |
Missing workspace silently allowed — null propagates to downstream handlers |
| R2-L5 | Tools | Multiple tools | Inconsistent error message formats: "Error: ..." vs "error:" vs raw exception messages |
| R2-L6 | Tools | WebFetchTool.cs:50-53 |
ReadAsStringAsync loads full response before truncation — use stream-based bounded read |
| R2-L7 | Tools | SkillCreateTool.cs:73 |
StartsWith(..., OrdinalIgnoreCase) for path comparison doesn't respect Unix case-sensitivity |
| R2-L8 | Providers | IntentClassifier.cs:35-45 |
Source-generated regexes have no timeout; only Regex.IsMatch call uses 5-second timeout |
| R2-L9 | Agents | WorkspaceContext.cs:55-93 |
Deprecated GetOrCreateArtifactsDirectory() method still blocks with .GetAwaiter().GetResult() |
| R2-L10 | SDK | sse.ts:3-4 |
MAX_BUFFER_SIZE = 10 MB is excessive for browser environments — consider 1 MB |
| R2-L11 | SDK | types.ts:186-198 |
StreamCallbacks don't indicate if async callbacks are supported — `void |
| R2-L12 | MCP | McpServerSetup.cs:72-118 |
No logging of tool executions — blind spot for observability |
| # | Issue | Effort | Risk if Unfixed | Status |
|---|---|---|---|---|
| 8.1 | MCP ownerUserId: null data exposure |
Medium | Any MCP client reads all users' conversations | ✅ Already fixed (ownerUserId resolved via GetOwnerUserId) |
| 8.2 | ProjectRoutes missing authorization | Medium | Unauthorized project manipulation | ✅ Fixed — ListProjects/CreateProject now require workspace membership |
| 8.3-8.4 | Worktree tool command injection | Low | Arbitrary code execution via git args | ✅ Already fixed (ArgumentList used, not string concat) |
| 9.17 | MCP config resource info disclosure | Low | Internal config leaked to clients | ✅ Fixed — sovrant://config resource removed from MCP |
| 9.19 | API key can be set to empty via PUT | Low | All LLM calls break server-wide | ✅ Already fixed (empty/whitespace validation in place) |
| # | Issue | Effort | Risk if Unfixed | Status |
|---|---|---|---|---|
| 8.5 | ProviderInfo non-atomic state updates | Medium | Lost metrics, incorrect routing decisions | ✅ Already fixed (Interlocked + lock) |
| 8.6 | SmartRouter blocking dispose | Low | Deadlock on shutdown | ✅ Already fixed (IAsyncDisposable with timeout) |
| 8.7 | Unhandled fire-and-forget exceptions | Low | Process crash on background task failure | ✅ Already fixed (try-catch in all paths) |
| 8.10 | ShellEnvironment null dereference | Low | NullReferenceException on failed process start | ✅ Already fixed (null check on Process.Start) |
| 9.12 | SwarmFileLockManager race | Low | Incorrect lock state | ✅ Already fixed (snapshot keys with ToList) |
| 9.13 | SwarmOrchestrator semaphore leak | Medium | Resource exhaustion under errors | ✅ Already fixed (try/finally per-task) |
| 9.10 | Blocking .GetAwaiter().GetResult() |
Medium | Thread starvation, deadlocks | ✅ Fixed — 4 blocking calls converted to async (McpServerSetup, ArtifactsCommand, Integrations.razor, OpenRouterCostModel) |
| # | Issue | Effort | Risk if Unfixed | Status |
|---|---|---|---|---|
| 9.1 | ETagMiddleware response buffering | Low | Memory exhaustion on streaming | ✅ Already fixed (CacheablePrefixes allow-list excludes streaming) |
| 9.2 | SSRF DNS rebinding gap | Medium | Internal endpoint probing via DNS | ✅ Already fixed (DNS resolved, all IPs checked against IsReservedIp) |
| 9.3 | Missing model validation in Session/Webhook routes | Low | Invalid model names persisted | ✅ Already fixed (IsValidModelName called in both routes) |
| 9.4 | Fire-and-forget SSE writes | Low | Silent data loss | ✅ Fixed — added logging on SSE write failure, pass cancellation token |
| 9.5 | SessionEviction missing cancellation | Low | Delayed shutdown | ✅ Not needed — EvictExpired is sync in-memory, completes instantly |
| R2-M1 | Kestrel request size limits | Low | DoS amplification | ✅ Already fixed (10MB body, 16KB request line) |
| # | Issue | Effort | Risk if Unfixed | Status |
|---|---|---|---|---|
| 8.8 | useChat stale client | Medium | Stale credentials/config in multi-tenant | ✅ Already fixed (useEffect recreates client on options change) |
| 8.9 | SSE silent chunk loss | Low | Invisible data loss | ✅ Already fixed (onParseError callback added) |
| 9.14 | Callback exceptions crash stream | Low | Partial responses | ✅ Already fixed (callbacks wrapped in try-catch) |
| 9.15 | useCallback dependency array | Low | Stale closure bugs | ✅ Already fixed (options in deps; clientRef is a ref — correct React pattern) |
| 9.16 | MCP incomplete exception handling | Low | Server crash on unexpected errors | ✅ Already fixed (catch-all Exception handler in CallToolAsync) |
| 9.20 | Message ID collision | Low | React key conflicts | ✅ Already fixed (crypto.randomUUID with Date.now fallback) |
| # | Issue | Effort | Risk if Unfixed | Status |
|---|---|---|---|---|
| 9.6 | JsonDocument disposal paths | Low | Memory pressure | ✅ Already fixed (all using blocks, no early-return leaks) |
| 9.7-9.8 | Volatile misuse, unsafe deserialization | Medium | Thread safety, type confusion | ✅ Already fixed (volatile appropriate for simple ref; deserialization uses controlled types) |
| 9.9 | HttpClient singleton in McpOAuth | Low | Socket exhaustion | ✅ Already fixed (IHttpClientFactory with named "McpOAuth" client) |
| 9.11 | Hook fire-and-forget exceptions | Low | Unobserved failures | ✅ Already fixed (ContinueWith observes exceptions) |
| 9.18 | SwarmToolExecutor path traversal | Low | File access outside sandbox | ✅ Already fixed (Path.GetFullPath containment check) |
| R2-M series | Remaining medium items | Low each | Various | ✅ All verified — timeout, validation, dispatcher, cleanup all fixed |
| R2-L series | Low items | Low each | Polish | ✅ Minor items — no code changes needed |
-
Fire-and-forget epidemic — Round 1 identified 3 locations; Round 2 found 8+ more across Server (webhooks, SSE), Runtime (hooks, sessions), Desktop, Web, and LSP startup. A project-wide
SafeFireAndForgetextension method orIBackgroundTaskQueuewould address all at once. -
Blocking-over-async —
GetAwaiter().GetResult()appears in 5+ files (WorkspaceContext, SwarmSession, ArtifactsCommand, Integrations.razor). This pattern risks deadlocks in UI and ASP.NET contexts. Need async-all-the-way refactor for these call chains. -
Authorization at wrong layer — ProjectRoutes and MCP resources rely entirely on service-layer or middleware auth. HTTP handlers should have their own authorization checks as defense-in-depth. Missing in at least 2 critical paths.
-
Process spawning still inconsistent — Despite
ProcessExecutorfrom Phase D, worktree tools still use the oldEscapeArg+ string concatenation pattern. All process-spawning code should route throughProcessExecutoror useArgumentList. -
SSE/streaming not treated as special — ETagMiddleware buffers streaming responses, SSE writes are fire-and-forget, and SDK SSE parsing silently drops errors. Streaming paths need explicit bypass/handling throughout the pipeline.
| Pattern | Round 1 Status | Round 2 Assessment |
|---|---|---|
volatile misuse |
Identified in 4 files | Still present in SmartRouter (9.7). Partially addressed but systemic fix still needed |
| Fire-and-forget tasks | 3 locations identified | Worsened: 8+ new locations found. Systemic solution required |
| Process execution duplication | ProcessExecutor created in Phase D |
Worktree tools not migrated (8.3-8.4). Needs completion |
| Input validation gaps | Phase C added InputValidation |
New gaps in SessionRoutes, WebhookRoutes, ProjectRoutes (9.3, R2-M4). Need enforcement middleware |
| Error response format | Inconsistent (Round 1) | Still inconsistent. No changes observed |
Scope: Full UX audit of CLI, Desktop (Avalonia), and Web (Blazor) against Claude Code, Cursor, Windsurf, opencode, Aider, and Continue.dev. Method: Codebase exploration of streaming paths, tool execution, session management, configuration, rendering, and developer workflow integration. Baseline: v1.3.0+ (50 tools, 96 endpoints + SignalR hub, 1,492 tests, 5 delivery modes, V001–V016 migrations)
| Area | Assessment | Details |
|---|---|---|
| Error resilience & retry | Strong | 3-attempt provider retry with exponential backoff (1s/2s/4s), tool-level exception handling, tool call loop detection, routing fallback on provider failure |
| Context window management | Excellent | LlmContextCompactor with budget-aware LLM summarization, naive fallback, preserves recent 3+ outcomes verbatim, 25% budget reserved for summary |
| Tool execution robustness | Strong | 256 KB bash output cap, 50 KB general cap with temp file offload, configurable timeouts (120s default), governance pre/post checks, tool confirmation gate |
| Provider routing | Unique | SmartRouter with health/latency/cost scoring + intent-aware model tier routing. No competitor has comparable intelligent routing |
| Enterprise multi-tenant | Unique | Per-request LLM keys, API token issuance, session TTL/LRU, rate limiting, workspace/project scoping. No competitor ships this self-hosted |
Severity: HIGH
Affected: Desktop (ChatView.axaml thinking indicator), Web (Chat.razor thinking phrases)
Problem: During tool execution, both UIs cycle through generic phrases ("Thinking really hard...", "Consulting the oracle..."). Claude Code, Cursor, and Windsurf show the specific tool being executed: "Reading src/Auth.cs...", "Running bash...", "Searching for 'login'...". The infrastructure exists — RuntimeEvent.ToolUseRequested fires with the tool name, and Desktop already has IsExecutingTools + ExecutionStatusText — but the thinking indicator competes visually and doesn't yield to it.
Fix: When a ToolUseRequested event arrives, immediately stop the thinking indicator and show the tool execution status. Format as: "{ToolIcon} Running {ToolName}..." with the tool's input summary (filename for Read, command for Bash, pattern for Grep).
Effort: Small.
Impact: Immediate perception of "this tool knows what it's doing."
Severity: HIGH
Affected: Desktop (SafeMarkdownPresenter), Web (Blazor markdown rendering)
Problem: No copy button on code blocks in either UI. This is the single most-used interaction in a coding tool's output. Every competitor has it. Its absence immediately signals "not polished."
Fix: Desktop: add a button overlay on code block borders in SafeMarkdownPresenter that calls Clipboard.SetTextAsync(). Web: add a button with JS interop navigator.clipboard.writeText().
Effort: Small–Medium.
Impact: High — most frequently needed action in a coding assistant.
Severity: HIGH
Affected: CLI, Desktop, Web — all session management paths
Problem: Sessions are all session-{guid}. Users can't find past conversations. Claude Code, Cursor, and opencode let you name, search, and browse sessions by content or date. The data infrastructure is there — SQLite with FTS5 full-text search — but there's no UI or CLI surface for naming or searching.
Fix: (a) Add title column to sessions table (or use existing metadata). (b) Add /rename <title> slash command. (c) Surface session list in Desktop/Web sidebar with title, date, message count. (d) Add GET /v1/sessions?q= search parameter using FTS5. (e) Auto-generate title from first user message if no explicit name.
Effort: Medium.
Impact: High — users currently have no way to find past work.
Status: (a)–(c)+(e) completed in prior phases. (d) FTS5 search endpoint added: SearchAsync on ISessionStore, SqliteSessionStore FTS5 MATCH query, GET /v1/sessions?q= wired in SessionRoutes.
Severity: HIGH
Affected: ConversationRuntime — system prompt construction
Problem: The agent has zero ambient git awareness. It doesn't know what branch you're on, what's staged, or what changed recently unless it explicitly runs git status via Bash. Claude Code and Cursor automatically inject git branch, status, and recent commits into the agent's context.
Fix: At session init (or first turn in a git repo), run git rev-parse --abbrev-ref HEAD, git status --short, and git log --oneline -5 and inject the results into the system prompt as a "Current Repository State" block. Refresh on each turn if the working directory is a git repo.
Effort: Medium.
Impact: High — contextual awareness is what separates a chat box from a coding partner.
Severity: MEDIUM
Affected: Desktop (SafeMarkdownPresenter), Web (Blazor)
Problem: Code blocks render as monospace text on a dark background with no language-specific coloring. Competitors all have full syntax highlighting. This is the second-most visible quality signal after the copy button.
Fix: Desktop: integrate a TextMate grammar library or AvaloniaEdit's highlighting engine. Web: add Prism.js or highlight.js via JS interop, triggered after markdown render.
Effort: Medium.
Severity: MEDIUM
Affected: Desktop, Web
Problem: Only Enter to send, Shift+Enter for newline, and Ctrl+K for command palette exist. No Ctrl+L (clear), Ctrl+N (new chat), Escape (stop/cancel), or documented shortcut reference. Competitors have extensive keybinding systems.
Fix: Define a shortcut map covering at minimum: Ctrl+L (clear chat), Ctrl+N (new session), Escape (stop current turn — now possible with Stop button CTS), Ctrl+Shift+C (copy last code block). Wire into both AXAML KeyBindings and Blazor @onkeydown.
Effort: Small–Medium.
Status: Desktop ChatView.axaml.cs — OnKeyDown override handles Escape (stop), Ctrl+L (clear), Ctrl+N (new session), Ctrl+Shift+C (copy last code block via regex). Web Chat.razor — HandleKeyDown + HandleGlobalKeyDown with same shortcuts via @onkeydown.
Severity: MEDIUM
Affected: Runtime — tool execution layer
Problem: Every file write/edit the agent makes is permanent. opencode commits each agent action to a git stash or temporary branch, allowing /undo to revert and /redo to reapply. This dramatically lowers the trust barrier for giving agents write permissions. Sovrant has EnterWorktree/ExitWorktree for branch isolation but no per-action undo.
Fix: Before each Write/Edit tool execution, snapshot the affected file(s) via git stash push -m "sovrant:{toolUseId}" or a lightweight checkpoint. Implement /undo and /redo slash commands that walk the checkpoint stack.
Effort: Medium–Large.
Severity: MEDIUM
Affected: ProcessExecutor, BashTool, PowerShellTool
Problem: Bash tool waits for the command to complete, then returns the full output. Claude Code and Cursor stream bash output live — you see build progress, test runner output, etc. in real-time. This matters especially for long-running commands (builds, test suites, installations).
Fix: ProcessExecutor should yield stdout/stderr lines as they arrive via IAsyncEnumerable<string> rather than buffering to completion. The runtime's tool result path would need to support incremental output events.
Effort: Medium.
Severity: MEDIUM
Affected: CLI (Program.cs), Server (Program.cs)
Problem: If LLM_API_KEY is unset, the CLI fails on first LLM call with a cryptic provider error. Competitors detect this at startup and guide the user. Desktop has a setup wizard, but CLI and Server don't validate config upfront.
Fix: Add a pre-flight check at CLI/Server boot: verify LLM_API_KEY is set (or OLLAMA_BASE_URL for local models), validate the URL format of LLM_BASE_URL, and print a clear message with setup instructions on failure.
Effort: Small.
Severity: LOW
Affected: Desktop (ChatView.axaml tool use template), Web (ChatMessage component)
Problem: Tool results (especially large grep/read output) take up enormous vertical space. Competitors collapse them by default with "Show output" toggles. Sovrant shows everything inline, pushing the actual response text off-screen.
Fix: Default tool result cards to collapsed state (showing tool name + status only). Add an expand/collapse toggle. Show first 3–5 lines as preview.
Effort: Small–Medium.
Severity: LOW
Affected: Runtime — session context management
Problem: Claude Code and Cursor detect when files change on disk and update context. Sovrant has no file watcher. When the user edits files manually between turns, the agent operates on stale information.
Fix: Add FileSystemWatcher on the working directory (filtered to source files). On change, inject a brief "Files changed since last turn: {list}" note into the next system prompt. Debounce to avoid noise.
Effort: Medium–Large.
Severity: LOW
Affected: CLI (REPL output), Desktop, Web
Problem: Before applying Edit/Write, no visual diff is shown. opencode and Claude Code show colored unified diffs so the user can verify changes before they land. Sovrant shows the raw tool result after the fact.
Fix: CLI: render a colored unified diff using Spectre.Console markup before the edit is applied. Desktop/Web: add a diff component showing old vs new with red/green highlighting.
Effort: Medium.
Severity: LOW
Problem: Markdig supports tables but SafeMarkdownPresenter doesn't render them. Tables appear as raw pipe-delimited text.
Effort: Medium.
Severity: LOW Problem: No inline image rendering for screenshots or diagrams referenced by the agent. Effort: Medium.
Severity: LOW
Problem: No sovrant config validate to check env vars, API key validity, provider reachability before running.
Effort: Small.
Severity: LOW Problem: Export exists as flat markdown only. No shareable link, no import, no JSON export with full metadata. Effort: Small–Medium.
The following order maximizes perceived quality improvement per unit of effort:
| Order | Item | Effort | Impact | Rationale |
|---|---|---|---|---|
| 1 | 14.1 Streaming tool status | Small | High | Already wired — just needs UI connection. Instant perception boost. |
| 2 | 14.2 Code block copy button | Small–Med | High | Most-used interaction. Absence is the #1 "unpolished" signal. |
| 3 | 14.9 First-time setup validation | Small | Medium | Prevents frustrating first impressions for new users. |
| 4 | 14.6 Keyboard shortcuts | Small–Med | Medium | Low effort, disproportionate "feels professional" signal. |
| 5 | 14.3 Session naming & search | Medium | High | Unlocks the value of persistent sessions. |
| 6 | 14.4 Git context injection | Medium | High | Makes the agent feel like a coding partner, not a chat box. |
| 7 | 14.10 Collapsible tool output | Small–Med | Medium | Reduces visual noise, keeps focus on the response. |
| 8 | 14.5 Syntax highlighting | Medium | Medium | Visual quality of every code response improves. |
| 9 | 14.8 Streaming bash output | Medium | Medium | Critical for long-running commands (builds, tests). |
| 10 | 14.7 /undo / /redo |
Med–Large | High | Trust barrier reduction for write permissions. |
| 11 | 14.12 Structured diff preview | Medium | Medium | Trust + verification before destructive edits. |
| 12 | 14.11 File watching | Med–Large | Medium | "Work alongside me" use case enabler. |
Bottom line: Items 1–4 can ship in a single sprint and bring the UX from "functional prototype" to "credible tool." Items 5–8 close the remaining visible gap with competitors. Items 9–12 move into "best in class" territory.
Scope: Web (Blazor), Server (ASP.NET Core routes + middleware), TypeScript SDK, and six files modified in the Phase 90+ sessions (MCP intent gate, capability catalog, free-badge model picker, model persistence fix). Build at time of review: 1,911 tests passing, 0 failures. Prior findings: All Rounds 1–3 findings verified COMPLETE. No re-reporting of closed items.
Layer: Web, Desktop
Files: src/Sovrant.Web/Components/Layout/TopContextBar.razor:343,352 · src/Sovrant.Desktop/ViewModels/SidebarViewModel.cs:240-243,290-292
Problem: When a user selects a provider profile, the API key is written to the process environment via Environment.SetEnvironmentVariable("LLM_API_KEY", ...). In Blazor Server (embedded mode), all concurrent user sessions share one process — so session A's API key becomes readable by session B between requests. In Desktop the risk is lower (single user), but env vars persist for the lifetime of the process and are readable by any child process spawned by the agent (Bash, PowerShell, REPL).
Fix: Do not write API keys to global env vars. Pass the key through SovrantConfig (singleton) or a scoped IAuthProvider only. The AuthProvider.ApiKey property already exists for this purpose.
Note: Sovrant.Web is designed as a single-user local app, so the immediate risk is low — but this is a blocker before any hosted/multi-user deployment.
Layer: Web
File: src/Sovrant.Web/Components/Layout/TopContextBar.razor:191-203,228
Problem: _providerProfiles (a List<ProviderProfileItem>) holds plaintext API keys retrieved from ICredentialStore for all configured providers. This list lives in the Blazor component's render state for the lifetime of the SignalR circuit. If the browser DevTools are open, an attacker with local access can enumerate SignalR messages and see the full list. The TreeGroup objects (lines 228, 332) also carry ApiKey fields that persist in component state.
Fix: Retrieve credentials on-demand (at model-selection time) rather than loading all of them upfront. Clear ApiKey from TreeGroup after use. At minimum, use [JSInvokable] isolation to avoid exposing the full profile list in component state.
Layer: Desktop
File: src/Sovrant.Desktop/ViewModels/SidebarViewModel.cs (FetchModelIdsAsync)
Problem: Web's FetchModelIdsAsync added a HashSet-based dedup (Round 4 fix, commit 13de129) to handle OpenRouter returning duplicate model IDs. Desktop's equivalent method was not updated with the same fix — it still appends all returned IDs without deduplication, so OpenRouter duplicates produce double entries in the Desktop model dropdown.
Fix: Apply the same HashSet<string>(StringComparer.OrdinalIgnoreCase) guard to Desktop's FetchModelIdsAsync, identical to the Web implementation.
Layer: Runtime
File: src/Sovrant.Runtime/Conversation/ConversationRuntime.cs (_mcpHintInjected field, MCP hint injection block)
Problem: _mcpHintInjected is a plain bool field. RuntimeSessionPool reuses ConversationRuntime instances per session, and concurrent requests to the same session (possible via parallel API calls) can both see _mcpHintInjected == false, both call BuildMcpHint(), and both append the hint to _systemPrompt — resulting in duplicate MCP hints in the prompt.
Fix: Guard the hint injection with lock (this) or use Interlocked.CompareExchange on an int flag.
Layer: Runtime
File: src/Sovrant.Runtime/Conversation/ConversationRuntime.cs (hint injection path)
Problem: BuildMcpHint() is called without a try-catch. If the MCP server registry throws (e.g., store unavailable), the exception propagates and aborts the turn entirely rather than degrading gracefully (no hint, conversation continues normally).
Fix: Wrap the call:
string? hint = null;
try { hint = BuildMcpHint(); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed to build MCP hint; continuing without it"); }Layer: Runtime
File: src/Sovrant.Runtime/Conversation/ConversationRuntime.cs (BuildSystemPrompt)
Problem: BuildSystemPrompt checks if (catalog is not null) then accesses catalog.Skills.Count and catalog.AgentTemplateNames.Count. The ICapabilityCatalog interface declares both as IReadOnlyList<T>, but a future implementation returning null for either property would cause NullReferenceException.
Fix: Add null guards: catalog.Skills?.Count > 0. Also ensure CapabilityCatalog documents the contract that it must never return null for either property.
Layer: Runtime
File: src/Sovrant.Runtime/ServiceCollectionExtensions.cs (ApplyUserPreferencesAsync)
Problem: Values read from IUserPreferenceStore (model name, MaxTokens, BaseUrl) are applied directly to SovrantConfig with only type-conversion checks (e.g., int.TryParse for MaxTokens). A corrupted or malicious preference store can inject an invalid model name (bypassing InputValidation.IsValidModelName) or an extreme MaxTokens value.
Fix: Validate with InputValidation.IsValidModelName(model) before assigning. Clamp MaxTokens to a reasonable range (e.g., 1–2,000,000).
Layer: Runtime
File: src/Sovrant.Runtime/ServiceCollectionExtensions.cs (startup path)
Problem: await ApplyUserPreferencesAsync(services, userId, ct) is awaited directly during runtime initialization with no exception handler. If the preference store or credential store is unavailable at boot, the entire runtime init fails hard. The preference load is a best-effort operation — failure should fall through to defaults.
Fix:
try { await ApplyUserPreferencesAsync(services, userId, ct); }
catch (Exception ex) { logger.LogWarning(ex, "Could not apply user preferences; using defaults"); }Layer: Desktop
File: src/Sovrant.Desktop/ViewModels/SidebarViewModel.cs (constructor)
Problem: _ = LoadProviderProfilesAsync() and similar calls in the ViewModel constructor have no exception handling. If either method throws, the exception becomes an unobserved task exception which can terminate the process under certain TaskScheduler.UnobservedTaskException configurations.
Fix: Attach a continuation:
_ = LoadProviderProfilesAsync().ContinueWith(
t => Log.Warning(t.Exception, "Failed to load provider profiles"),
TaskContinuationOptions.OnlyOnFaulted);The following server endpoints have no corresponding method in sdk/js/src/client.ts. Priority-ordered by user-facing importance:
| Priority | Method | Endpoint | Missing SDK Method |
|---|---|---|---|
| HIGH | GET | /v1/artifacts |
listArtifacts() |
| HIGH | GET | /v1/artifacts/{runId}/{**path} |
getArtifact(runId, path) |
| HIGH | DELETE | /v1/artifacts/{runId} |
deleteArtifact(runId) |
| HIGH | POST | /v1/missions/{id}/run |
runMission(id) — create exists, run does not |
| HIGH | POST | /v1/teams/{id}/runs |
startTeamRun(teamId, prompt) |
| MED | GET/PUT | /v1/workspaces/{id}/config |
getWorkspaceConfig() / updateWorkspaceConfig() |
| MED | GET/POST/DELETE | /v1/workspaces/{id}/memory |
getWorkspaceMemory() / addWorkspaceMemory() / deleteWorkspaceMemory() |
| MED | GET/PUT | /v1/projects/{id}/config |
getProjectConfig() / updateProjectConfig() |
| MED | POST | /v1/projects/{id}/archive |
archiveProject(id) |
| MED | POST | /v1/projects/{id}/unarchive |
unarchiveProject(id) |
| LOW | GET | /v1/engine/runs/{id}/trace |
getEngineTrace(runId) |
| LOW | POST | /v1/engine/runs/recover |
recoverEngineRuns() |
| LOW | GET | /v1/evals/{suite}/history |
getEvalHistory(suite) |
| LOW | GET/POST/DELETE | /v1/knowledge/{kind}/{slug} |
Full knowledge CRUD |
Correction (full route audit below): The initial SDK gap estimate was inaccurate. The full route audit (Section 19) found SDK coverage is near-complete — nearly all endpoints have matching methods. True gaps are limited to Knowledge routes and Command Center.
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| 15.1 | Remove env var API key writes | Low | Credential leak in multi-user/hosted scenarios |
| 15.2 | Credential exposure in Blazor component state | Medium | API keys in browser-accessible circuit state |
| 15.4 | _mcpHintInjected race condition |
Low | Duplicate MCP hints under concurrent load |
| 15.5 | BuildMcpHint exception not caught |
Low | Turn aborted on MCP registry error |
| 16.3 | ApplyUserPreferencesAsync not try-caught |
Low | Hard boot failure if pref store unavailable |
| 19-C1 | ConfigRoutes PUT missing admin check | Low | Any token holder can change global model/provider |
| 19-C3 | EngineRoutes recover missing auth | Low | Any token holder can force-recover engine runs |
| 19-C5 | WorkspaceRoutes missing access checks | Medium | Any token holder can read/modify any workspace |
| 19-H3 | MissionRoutes missing ownership checks | Medium | Cross-user mission access |
| 19-H4 | SwarmRoutes missing ownership checks | Medium | Cross-user swarm access |
| 19-H5 | TeamRoutes missing ownership checks | Medium | Cross-user team modification |
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| 15.3 | Desktop FetchModelIdsAsync dedup | Low | Duplicate models in Desktop dropdown |
| 16.1 | ICapabilityCatalog null guard | Low | NPE on future implementation change |
| 16.2 | Preference validation before apply | Low | Config corruption via store |
| 16.4 | Fire-and-forget in SidebarViewModel | Low | Silent startup failures |
| 19-C2 | ChatRoutes SSRF DNS race | Medium | Internal endpoint probing via timing race |
| 19-C4 | ArtifactRoutes path traversal | Low | File read outside artifact directory |
| 19-H2 | EngineRoutes DELETE ownership | Low | Any user deletes any engine trace |
| 19-H6 | KnowledgeRoutes YAML injection | Low | Malicious YAML saved to disk |
| 19-M series | Validation gaps (CommandCenter, Config BaseUrl, Webhook SSRF, token expiry) | Low each | Various |
| # | Issue | Effort | Risk if Unfixed |
|---|---|---|---|
| 19-SDK | Add Command Center SDK method | Low | No SDK access to /v1/command-center/state |
| 19-SDK | Add Knowledge CRUD SDK methods | Low | No SDK access to knowledge routes |
| 19-SDK | Add MCP server list SDK method | Low | No SDK access to /v1/mcp/servers |
Scope: All 25 server route files audited line-by-line. BearerTokenMiddleware confirmed solid (constant-time comparison, correct path exclusions). SDK client.ts fully inventoried — coverage is near-complete.
File: src/Sovrant.Server/Routes/ConfigRoutes.cs:43-94
Problem: PUT /v1/config has no ctx.IsAdmin() check. Any authenticated user (even a read-only per-user token) can change the global model, base URL, and permission mode — affecting all sessions server-wide.
Fix: Add if (!ctx.IsAdmin()) return Results.Forbid(); as the first line of the PUT handler.
File: src/Sovrant.Server/Routes/ChatRoutes.cs:85-92
Problem: IsReservedAddressAsync() resolves the hostname and checks the IP, but the HTTP client makes a second DNS resolution when the request actually fires. An attacker using dynamic DNS can pass the validation check (DNS points to a public IP) then switch to an internal IP before the actual connection. This is a classic TOCTOU SSRF bypass.
Fix: Pin the resolved IP at validation time and pass it directly to the HttpClient via a custom SocketsHttpHandler that bypasses DNS on the actual connection, or use a DNS-pinning cache with short TTL.
File: src/Sovrant.Server/Routes/EngineRoutes.cs:58-64
Problem: POST /v1/engine/runs/recover has no authorization check. Any authenticated user can force-close or recover crashed engine runs that may belong to other users or system processes.
Fix: Add if (!ctx.IsAdmin()) return Results.Forbid();
File: src/Sovrant.Server/Routes/ArtifactRoutes.cs:73
Problem: The catch-all route GET /v1/artifacts/{runId}/{**path} passes the user-supplied path directly to store.ReadAsync(handle, path, ...) without normalization. A path like ../../sensitive_file or URL-encoded variants can escape the artifact directory.
Fix: Validate path with Path.GetFullPath containment check against the artifact root for the given runId. Reject any path that doesn't stay within bounds.
File: src/Sovrant.Server/Routes/WorkspaceRoutes.cs:67-234
Problem: GET /v1/workspaces correctly scopes to the caller. But every {id} endpoint — GET, PUT, DELETE, members, invites, config, usage, memory — has no ownership or membership validation. Any authenticated user who knows a workspace ID can read its config, members, memory, and usage data, or add/remove members.
Fix: Apply the same RequireWorkspaceAccess(id, ctx) pattern used in ProjectRoutes.cs to all workspace {id} handlers.
File: src/Sovrant.Server/Routes/ArtifactRoutes.cs:59,94
Problem: ScopeFromQuery() reads workspace_id/project_id from query parameters without verifying the caller belongs to that workspace/project. User A can list or delete User B's artifacts by supplying B's workspace ID.
Fix: After resolving scope, call RequireWorkspaceAccess(scope.WorkspaceId, ctx).
File: src/Sovrant.Server/Routes/EngineRoutes.cs:67-77
Problem: DELETE /v1/engine/runs/{runtimeRunId} has no ownership check. Any authenticated user can delete any engine run trace, destroying audit data.
Fix: Either restrict to admin only, or verify the run belongs to ctx.GetUserId() before deletion.
File: src/Sovrant.Server/Routes/MissionRoutes.cs:35-125
Problem: All six mission endpoints (create, list, get, run, events, export) have no ownership validation. User A can view, run, or export User B's missions if they know the mission ID. The ownerUserId query parameter on list is not validated against the caller's identity.
Fix: On all {id} handlers: verify mission owner matches ctx.GetUserId() (or caller is admin). On list: force ownerUserId = ctx.GetUserId() unless admin.
File: src/Sovrant.Server/Routes/SwarmRoutes.cs:33-155
Problem: All four swarm endpoints have no ownership validation. ISwarmStateTracker is queried without owner filter — user A can read user B's swarm results, events, and session history.
Fix: Store swarm owner at creation time. Validate ownership on all {id} handlers. Scope /swarm/sessions list to caller.
File: src/Sovrant.Server/Routes/TeamRoutes.cs:38-220
Problem: All team and run endpoints — create, list, get, delete, members, runs, profile update — have no ownership or workspace-membership validation. User A can modify, delete, or run User B's teams. The workspaceId and userId filter parameters on list/run endpoints are not validated.
Fix: Implement team ownership checks (validate team's WorkspaceId caller membership) on all {id} handlers. Force workspaceId filter to caller's workspaces on list.
File: src/Sovrant.Server/Routes/KnowledgeAuthoringRoutes.cs:164-183
Problem: ValidateFrontmatter() uses naive string search (IndexOf("\n---")) to detect the frontmatter boundary — it doesn't parse YAML. A malicious document body can include \n---\nname: injected to pass validation while writing arbitrary YAML to disk. POST and DELETE also have no authorization checks — any user can create or delete knowledge entries.
Fix: Use a proper YAML parser for frontmatter validation. Add if (!ctx.IsAdmin()) return Results.Forbid(); on write/delete handlers, or at minimum validate workspace membership.
| # | File | Issue |
|---|---|---|
| R4-M1 | CommandCenterRoutes.cs:26 |
owner_user_id query param not validated — callers can query other users' cockpit state |
| R4-M2 | ConfigRoutes.cs:61-63 |
BaseUrl PUT path doesn't call SSRF check (only Uri.TryCreate, no reserved-IP block) |
| R4-M3 | ChatRoutes.cs:130-141 |
Session ownership bypassed when legacy static SOVRANT_TOKEN used (IsAdmin() short-circuits) |
| R4-M4 | EvalRoutes.cs:90-96 |
suiteName used in file path resolution without format validation — potential traversal |
| R4-M5 | MeRoutes.cs:50-90 |
Token ExpiresAt not bounds-checked — tokens can be issued with past or far-future expiry |
| R4-M6 | MissionRoutes.cs:48-65 |
ownerUserId list filter accepted from query string, not validated against caller identity |
| R4-M7 | TeamRoutes.cs:57-61,202-220 |
workspaceId and userId filter params on list/runs not validated |
| R4-M8 | WebhookRoutes.cs:50-61 |
callback_url validates scheme only — reserved IP addresses not blocked (SSRF) |
| R4-M9 | StatusRoutes.cs |
Exposes active provider list, session counts, routing info to any authenticated user |
| R4-M10 | Program.cs:106-121 |
CORS allows ports 3000, 5100, 5173, 8080 — any untrusted service on those ports can make credentialed requests |
| # | File | Issue |
|---|---|---|
| R4-L1 | ConfigRoutes.cs:30-36 |
GET /v1/config returns PinnedProvider — internal routing detail exposed to all users |
| R4-L2 | CostRoutes.cs:17-23 |
Invalid range values silently default to daily instead of returning 400 |
| R4-L3 | EvalRoutes.cs:12-26 |
All eval suites exposed to all authenticated users with no workspace scoping |
| R4-L4 | McpServerRoutes.cs:18-36 |
Connected MCP server names exposed to all users — helps attackers understand integration surface |
| R4-L5 | MeRoutes.cs:50-90 |
No per-user limit on number of tokens issued |
| R4-L6 | WebhookRoutes.cs:38-47 |
No field length validation on source, userId, message |
The initial Round 4 SDK gap estimate was substantially wrong. Full route audit shows SDK coverage is near-complete. True gaps:
| Endpoint | SDK Gap |
|---|---|
GET /v1/command-center/state |
No SDK method — getCommandCenterState() missing |
GET /v1/mcp/servers |
No SDK method — listMcpServers() missing |
GET /v1/knowledge/{kind}/{slug}/source |
No SDK method |
POST /v1/knowledge/{kind}/{slug} |
No SDK method |
DELETE /v1/knowledge/{kind}/{slug} |
No SDK method |
All other endpoints (workspace memory, project config/archive, mission run, team runs, artifacts, engine trace, eval history) already have SDK methods — the first estimate was incorrect.
BearerTokenMiddleware assessment: Implementation is solid. Uses CryptographicOperations.FixedTimeEquals for constant-time comparison, correctly excludes /health and OPTIONS, properly routes svt_* per-user tokens vs static bearer token. No issues found.
| Priority | Finding | File | Effort |
|---|---|---|---|
| 1 | 19-C1 ConfigRoutes PUT no admin check | ConfigRoutes.cs:49 | 1 line |
| 2 | 19-C3 EngineRoutes recover no auth | EngineRoutes.cs:59 | 1 line |
| 3 | 19-C5 WorkspaceRoutes no access checks | WorkspaceRoutes.cs:67+ | Medium |
| 4 | 20-H3 MissionRoutes no ownership | MissionRoutes.cs | Medium |
| 5 | 20-H4 SwarmRoutes no ownership | SwarmRoutes.cs | Medium |
| 6 | 20-H5 TeamRoutes no ownership | TeamRoutes.cs | Medium |
| 7 | 20-H6 KnowledgeRoutes YAML + no auth | KnowledgeAuthoringRoutes.cs | Low |
| 8 | 19-C4 ArtifactRoutes path traversal | ArtifactRoutes.cs:73 | Low |
| 9 | 20-H2 EngineRoutes DELETE no ownership | EngineRoutes.cs:67 | 1 line |
| Priority | Finding | File | Effort |
|---|---|---|---|
| 1 | R4-M2 ConfigRoutes BaseUrl no SSRF | ConfigRoutes.cs:61 | Low |
| 2 | R4-M8 WebhookRoutes callback_url SSRF | WebhookRoutes.cs:50 | Low |
| 3 | R4-M1 CommandCenter owner_user_id validation | CommandCenterRoutes.cs:26 | Low |
| 4 | R4-M4 EvalRoutes suiteName path validation | EvalRoutes.cs:90 | Low |
| 5 | R4-M5 MeRoutes token expiry bounds | MeRoutes.cs:50 | Low |
| 6 | 19-C2 ChatRoutes SSRF DNS TOCTOU | ChatRoutes.cs:85 | Medium |
Added: 2026-05-16. Prompted by SidebarViewModel.LoadSessionsAsync doing 21 queries per refresh (N+1: one ListAsync for IDs, then one LoadAsync per session to derive the label from the first entry's content). Fixed by switching to ListWithTitlesAsync which returns session_id, title, updated_at in a single SQL query.
This checklist applies on every PR that touches a ViewModel, route handler, background service, or any code that calls an ISessionStore, IWorkspaceService, IProjectService, IArtifactStore, or any other persistence interface.
Severity: MEDIUM–HIGH depending on list size
What to look for:
- Any
foreachover a collection that calls a store/service method per item ListAsync→LoadAsyncper ID (the exact pattern caught here)foreach (var id in ids) { var detail = await store.GetAsync(id); ... }
Fix: Use or add a bulk query method that returns all needed data in one call. If a bulk method doesn't exist, add one rather than tolerate N+1 in the calling code.
Example:
// BAD — N+1
var ids = await _sessionStore.ListAsync(ownerUserId: userId);
foreach (var id in ids)
{
var entries = await _sessionStore.LoadAsync(id, ownerUserId: userId);
var label = entries.FirstOrDefault(e => e.Role == "user")?.Content ?? id;
// ...
}
// GOOD — single query
var sessions = await _sessionStore.ListWithTitlesAsync(ownerUserId: userId);
foreach (var s in sessions)
{
var label = s.Title ?? s.SessionId;
// ...
}Severity: MEDIUM
What to look for:
- Property mutations on
[ObservableProperty]fields done off the UI thread after anawait IsSending = false(or similar observable state) set on a background thread continuation — Avalonia bindings may not reliably propagate cross-thread property changesObservableCollectionmutations outsideDispatcher.UIThread.InvokeAsync
Fix: Wrap all observable state updates in await Dispatcher.UIThread.InvokeAsync(() => { ... }). Keep store/service calls on the background thread (they should not block the UI thread), but marshal results back explicitly.
Example:
// BAD — IsSending mutated on background thread after ConfigureAwait(false)
finally
{
IsSending = false; // background thread — Avalonia may not pick this up
await Dispatcher.UIThread.InvokeAsync(() => { msg.CompleteStreaming(); });
}
// GOOD — all observable mutations on UI thread
finally
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
IsSending = false;
msg.CompleteStreaming();
});
}Severity: MEDIUM
What to look for:
UPDATE ... WHERE session_id = $sidcalled before the row is guaranteed to exist- Any
SetXxxAsyncthat uses a bareUPDATE— if the row isn't there yet, the call silently does nothing and callers don't know - Title/metadata writes that happen before the first
AppendAsync(which is what creates the session row viaINSERT OR IGNORE)
Fix: Methods that must work before the row exists should use INSERT OR IGNORE first, then UPDATE. Document the precondition explicitly if an UPDATE-only path is intentional.
Example:
// BAD — UPDATE silently no-ops if session row doesn't exist yet
cmd.CommandText = "UPDATE sessions SET title = $title WHERE session_id = $sid";
// GOOD — upsert: create if missing, then update
ensureCmd.CommandText = """
INSERT OR IGNORE INTO sessions (session_id, user_id, started_at, updated_at)
VALUES ($sid, $uid, strftime(...), strftime(...))
""";
// then:
cmd.CommandText = "UPDATE sessions SET title = $title WHERE session_id = $sid AND user_id = $uid";Severity: HIGH
What to look for:
- Any
Path.Combine(WorkingDirectory, ...)orPath.Combine(Directory.GetCurrentDirectory(), ...)used as a fallback for user data (artifacts, logs, config) Directory.GetCurrentDirectory()in data path construction — CWD is unpredictable and changes based on how the process is launched (IDE,dotnet run, installed binary)- Fallback paths that resolve correctly in development but land in the source tree
Fix: For any Sovrant user data, always fall back to ~/.sovrant/ (or SOVRANT_ARTIFACTS_ROOT, SOVRANT_DB_PATH, etc. env vars) — never to CWD. Use Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) as the anchor.
Example:
// BAD — CWD-relative fallback; lands in source tree in dev mode
var dir = Path.Combine(WorkingDirectory, "artifacts");
// GOOD — always resolves to ~/.sovrant/artifacts or override
var dir = Environment.GetEnvironmentVariable("SOVRANT_ARTIFACTS_ROOT")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".sovrant", "artifacts");Scope: How skills, agents, tool guides, document templates, guidelines, and PII sanitization flow from knowledge_pages into conversation context across CLI, Web, Desktop, and Server. Phase 112–113 completed the DB migration; this round audits what actually reaches the LLM and what is missing.
Build at time of review: 2,222 tests passing. Schema V037. Phase 113 (CachedKnowledgeStore) shipped same day.
| Knowledge type | Injection point | Mechanism | Status |
|---|---|---|---|
| Skills (32 built-in + user) | ConversationRuntime.BuildSystemPrompt() |
Full name + description + trigger for every skill, injected as static catalog block at session init | |
| Agent templates (25 built-in + user) | ConversationRuntime.BuildSystemPrompt() |
Names only listed — no system prompts, no role descriptions | |
Tool guides (kind='tools') |
Never injected | UserToolTemplateRegistry loaded in DI but not referenced by any context-building path |
❌ Invisible to LLM |
| Document templates | On-demand via DocumentListTemplatesTool |
LLM must explicitly call the tool; templates not surfaced proactively | |
| Language guidelines | CodeCreateTool fetches at generation time |
Injected inside the tool call, not in the base system prompt | |
| Memory (session summaries, patterns) | MemoryInjector.BuildMemorySectionAsync() at session init |
Up to 15 patterns + 10 instincts + 10 workspace memories appended to system prompt | ✅ Works |
| PII sanitization (user input) | TrustBoundary.PromptSanitizer before each turn |
Strips email, phone, SSN, CC numbers from user message | ✅ Wired for user input |
| PII sanitization (knowledge content) | Not implemented | Agent system prompts, skill bodies, and tool results are never sanitized | ❌ Gap |
| Attribution / provenance | Not implemented | RuntimeEvent.ToolUseRequested fires but no mapping to which knowledge resource drove the call |
❌ Gap |
Layer: Runtime — system prompt assembly
Files: src/Sovrant.Runtime/Conversation/ConversationRuntime.cs (BuildSystemPrompt), src/Sovrant.Tools/Templates/UserToolTemplateRegistry.cs
Problem: UserToolTemplateRegistry is a DI singleton that reads kind='tools' knowledge pages with full markdown bodies describing when and how to use each tool. Nothing in BuildSystemPrompt, CapabilityCatalog, or any per-turn injection path references these guides. The LLM has no awareness that tool guides exist.
Impact: Users who author tool guides to influence model behaviour see zero effect. The feature exists in the DB and the UI but is functionally a no-op at inference time.
Fix: Include a tool guides section in ICapabilityCatalog (alongside Skills and AgentTemplateNames) and inject it in BuildSystemPrompt. For the dynamic routing vision (see 27.4), tool guides become prime candidates for relevance-filtered injection.
Layer: Runtime — system prompt assembly
File: src/Sovrant.Runtime/Conversation/ConversationRuntime.cs:BuildSystemPrompt
Problem: All 32 (or more) skills are injected as a single catalog block on every turn, regardless of whether they are relevant to the user's intent. A coding session doesn't need the full business-process skills catalog. A document-generation request doesn't need tdd-workflow. The static injection wastes tokens (each skill description is 30–80 tokens), dilutes the LLM's attention, and increases the risk of irrelevant skill suggestions.
Note: This is a design limitation, not a bug — it was the correct initial approach. The next step is relevance filtering (see 27.4).
Layer: Runtime — per-turn context
File: src/Sovrant.Tools/Documents/DocumentListTemplatesTool.cs
Problem: Document templates are only discoverable if the LLM explicitly calls DocumentListTemplatesTool. If a user says "create a construction contract for 3 months' work" without prior context that structured templates exist, the model generates prose instead of using the purpose-built template. The model must already know to look for templates.
Impact: The document template system is underutilised in practice. Users who don't know templates exist never benefit from them.
Fix: Detect document-creation intent in the input analyzer (see 27.4) and proactively inject the relevant template summaries for the inferred industry/type. Alternatively, include a brief "document templates available" catalog hint in the system prompt when the template registry is non-empty.
Layer: Runtime — system prompt assembly
File: src/Sovrant.Runtime/Conversation/ConversationRuntime.cs:BuildSystemPrompt
Problem: The system prompt is built once at session init and contains everything — regardless of what the user will actually ask. A session that starts with a coding question and later asks for a financial report carries the full catalog for both domains throughout. SemanticIntentGate already classifies intent per turn but its classification is used only for governance, not for knowledge selection.
Vision (Phase 116): An intelligent knowledge router that:
- Runs intent classification on the incoming message (reuse
SemanticIntentGateor a lightweight keyword classifier) - Selects the top-k relevant skills, agents, and document templates based on intent signal + embedding similarity
- Injects only the selected knowledge into the per-turn context (either as a system prompt addendum or as a pre-turn injection message)
- Passes unused knowledge through the cache so subsequent turns incur no extra DB cost
Layer: TrustBoundary — sanitization pipeline
Files: src/Sovrant.Runtime/TrustBoundary/PromptSanitizer.cs, ConversationRuntime.cs
Problem: PromptSanitizer.SanitizeAsync(userMessage) is called before sending to the LLM. It strips PII from the user's text. But:
- Agent system prompts (injected into
BuildSystemPrompt) may contain PII if an admin authored a system prompt referencing real people or internal system names. These are never sanitized. - Skill bodies similarly may embed examples with real data.
- Tool results (file reads, web fetches, bash output) return to the LLM unsanitized. A
ReadFileon a credentials file sends its full plaintext to the LLM context. Fix: ExtendPromptSanitizerwith aSanitizeKnowledgeContentAsyncpath run when loading skill/agent bodies into the system prompt, and aSanitizeToolResultAsyncpath run on everyToolResultbefore it enters the conversation history.
Layer: Runtime — turn lifecycle
File: src/Sovrant.Runtime/Conversation/ConversationRuntime.cs (tool execution path)
Problem: When a turn completes, there is no record of which skills, agents, or document templates were invoked to produce the response. RuntimeEvent.ToolUseRequested fires with ToolName but does not record the knowledge resource that prompted or configured the tool call. The AuditStore logs events without knowledge provenance. Users and admins have no way to see "this response used the tdd-workflow skill and the coder agent."
Fix (Phase 116): Track knowledge attribution as part of the turn lifecycle:
- When
SkillTool.ExecuteAsyncruns, emit aKnowledgeUsed(kind: "skill", slug: "tdd-workflow")event alongsideToolUseRequested - When
AgentDelegateToolselects an agent template, emitKnowledgeUsed(kind: "agent", slug: "coder") - When
DocumentFromTemplatesToolruns, emitKnowledgeUsed(kind: "document-templates", slug: "construction-contract") - Persist these in a
knowledge_attributionstable (turn_id, kind, slug, timestamp) - Surface as a "Sources" panel in the chat UI showing which knowledge resources influenced the response
Deliverable order (each independently shippable):
| Step | What | Files touched | Effort |
|---|---|---|---|
| A | Wire tool guides into ICapabilityCatalog — add ToolGuides property, inject in BuildSystemPrompt |
CapabilityCatalog.cs, ConversationRuntime.cs, ICapabilityCatalog |
Small |
| B | PII sanitization for knowledge content — extend PromptSanitizer with SanitizeKnowledgeContentAsync; call it when building agent/skill system prompt blocks |
PromptSanitizer.cs, ConversationRuntime.cs |
Small–Medium |
| C | PII sanitization for tool results — call SanitizeToolResultAsync on every ToolResult event before it enters _history |
ConversationRuntime.cs (tool result handling) |
Small |
| D | Proactive document template surfacing — detect document-creation intent keywords and inject relevant template summaries as a pre-turn system addendum | ConversationRuntime.cs or new DocumentContextInjector |
Medium |
| E | Knowledge attribution events — emit KnowledgeUsed events from SkillTool, AgentDelegateTool, DocumentFromTemplatesTool; persist to knowledge_attributions table |
New V038+ migration, tool files, RuntimeEvent |
Medium |
| F | Dynamic knowledge routing — replace static full-catalog injection with per-turn relevance-filtered injection driven by intent classification | ConversationRuntime.cs, ICapabilityCatalog, SemanticIntentGate |
Large |
| G | Attribution UI — "Sources" panel in Web + Desktop showing which knowledge resources were used per turn | ChatMessage.razor, ChatView.axaml |
Medium |
Steps A–C are high-value, low-risk and can ship without the full routing vision. D–E are independent of F–G. F is the largest change and should be the last step.