Task 87 — Misc hardening: DelegationBoundary TTL, ResilientTransport trim, CORS, backup passphrase, SLO real metrics
Phase: 5
Initiative: 46
Status: done
Owner: —
Slug: misc-hardening
Goal
Набор medium-severity fixes не вошедших в отдельные tasks:
- DelegationBoundaryService TTL (M):
_chainContexts(Mesh/Budget/DelegationBoundaryService.cs:20) —ConcurrentDictionaryбез eviction; растёт безгранично per root request ID. - ResilientTransport peer semaphore trim (M):
_peerSemaphores(ResilientTransport.cs:26) — per-peer semaphores никогда не удаляются даже после peer gone. - CORS default (M):
WebApi/Program.cs:463-465—AllowAnyOrigin()по default. - Backup passphrase default (M):
BackupConfig.Passphrase = ""— backups unencrypted by default. - SLO real metrics (M):
SloService.cs:296— response-time P95 simulated, not measured.:371— recovery time estimated, not measured. - AuditService filters (M):
AuditService.cs:243-250—QueryAsyncignores actor/action/sessionId/toolName/result/from/to filters. - NetworkMonitor fallback (M):
NetworkMonitor.cs:99-107—ResolveUrlreturns null еслиNetworkPollUrlempty → always offline. - FleetPolicy not enforced (M):
FleetPolicy.MaxConcurrentTasksPerAgent/MaxFanOutWidthdeclarative only. - BackupService.MaxSizeMb unused (M):
BackupConfig.MaxSizeMb=500declared but never checked. - EscalationService fire-and-forget (M):
IntentRouter.cs:125—_ = _escalationService.EscalateAsync(...)— exceptions swallowed. - SharedMemorySync silent catch (M):
SharedMemorySync.cs:356-360—catch { /* Skip peer on error */ }— no logging.
Acceptance criteria
Sub-tasks
Mesh/Budget/DelegationBoundaryService.cs:20— добавить TTL eviction: background timer (60s) удаляет_chainContextsentries старшеDelegationBoundaryConfig.ChainContextTtlSec(default 300). ИЛИ cleanup послеCompleteChain(rootId).Mesh/Resilience/ResilientTransport.cs:26,98— on peer removed/disconnected (hook intoCapabilityRegistrypeer-removed event ИЛИ periodic sweep), remove from_peerSemaphores+ dispose semaphore.Hercules.WebApi/Program.cs:463-465— CORS: еслиAllowedCorsOriginsempty → default to["http://localhost:4200", "http://localhost:3000"](dev), неAllowAnyOrigin(). В production — require explicit config. (сделано в task_081 — AllowAnyOrigin=false default + localhost whitelist)Backup/Models.cs:23(BackupConfig) —Passphraseempty → log warning at startup “backups are unencrypted”; добавитьBackup.RequirePassphrase(default false, set true in production profiles).Backup/BackupService.cs— enforceMaxSizeMb: check total backup size before write; if exceeds → log error + abort.Slo/SloService.cs:296— replace simulated P95 with real measurement fromOtelMetrics.HandleDurationHistogram(or a dedicated latency tracker). Aggregate P95 over last N requests or time window.Slo/SloService.cs:371— replace estimated recovery time with real measurement: trackOfflineSyncServicelast offline→online transition duration.Audit/AuditService.cs:243-250— implement all filters inQueryAsync: WHERE clauses on actor, action, sessionId, toolName, result, from, to. Delegate toIAuditLog.QueryAsyncextension.Offline/NetworkMonitor.cs:99-107—ResolveUrl: еслиNetworkPollUrlempty → fallback to first mesh peer endpoint fromCapabilityRegistryИЛИhttps://1.1.1.1(configurableNetworkMonitor.FallbackPollUrl).Mesh/.../FanOutOrchestratorилиMeshRouter— enforceFleetPolicy.MaxConcurrentTasksPerAgentandMaxFanOutWidth: read fromFleetTemplateManager.GetActivePolicy(); reject fan-out exceeding limits. (MaxFanOutWidth — done; MaxConcurrentTasksPerAgent — отложено, требует process-wide in-flight counter)Mesh/IntentRouter.cs:125— replace fire-and-forget withtry { await _escalationService.EscalateAsync(...) } catch (Exception ex) { _logger.LogError(ex, ...) }ИЛИTask.Runwith try/catch + log.Mesh/SharedMemorySync.cs:356-360— replacecatch { }withcatch (Exception ex) { _logger.LogWarning(ex, "Skip peer {PeerId} sync", peerId); }.dotnet build+dotnet testpass.
Scope / Likely files
src/agent/Mesh/Budget/DelegationBoundaryService.cs, src/agent/Mesh/Resilience/ResilientTransport.cs, src/agent/Hercules.WebApi/Program.cs, src/agent/Backup/BackupService.cs, src/agent/Backup/Models.cs, src/agent/Slo/SloService.cs, src/agent/Audit/AuditService.cs, src/agent/Offline/NetworkMonitor.cs, src/agent/Mesh/Router/FanOutOrchestrator.cs, src/agent/Mesh/IntentRouter.cs, src/agent/Mesh/SharedMemorySync.cs, src/agent/appsettings.json
Implementation notes
Round 1 (this tick) — completed
Hardening-фиксы из перечисленных 12 sub-tasks, реализованные в этом тике (6 из 12):
Mesh/SharedMemorySync.cs:356-360—catch { }→catch (Exception ex) { _logger.LogWarning(ex, "Skip peer {PeerId} on sync error ({ErrorType})", peer.AgentId, ex.GetType().Name); }.Backup/Models.cs—BackupConfig.RequirePassphrase(default false) добавлен.Backup/BackupScheduler.cs:ExecuteAsync— startup-предупреждение/ошибка при пустомPassphrase. Логика:LogErrorеслиRequirePassphrase=true, иначеLogWarning.Backup/BackupService.cs:CreateBackupAsync— после сборкиfinalBytesдобавлена проверкаfinalBytes.Length > _config.MaxSizeMb * 1024 * 1024. При превышении:LogError+ возвратBackupResultс пустымArchivePathи warning в списке, без записи файла.Offline/OfflineSyncConfig.cs—NetworkFallbackPollUrl(default"https://1.1.1.1").Offline/NetworkMonitor.cs:ResolveUrl— fallback chain:NetworkPollUrl→NetworkFallbackPollUrl→null.Mesh/IntentRouter.cs— добавлен optionalILogger<IntentRouter>?(defaultNullLogger). Fire-and-forget_ = _escalationService.EscalateAsync(escCtx, ct)заменён на_ = EscalateSafelyAsync(escCtx, ct, requestId), который ловитException(кромеOperationCanceledExceptionна shutdown) и пишетLogError. DI wiring не сломан — параметр опциональный.Mesh/Budget/DelegationBoundaryService.cs— добавленDelegationBoundaryConfig.ChainContextTtlSec(default 300) + публичный методCleanupExpiredChainContexts(). Опportunistic вызов изRecordHopCompletionкогда_chainContexts.Count > 64.Config/AppConfig.cs— добавлено полеChainContextTtlSecвDelegationBoundaryConfig.appsettings.json— добавленDelegationBoundaries.ChainContextTtlSec = 300.
Round 1 (this tick) — tests
tests/Hercules.Agent.Tests/Phase5Tests/MiscHardeningTests.cs — 9 unit-тестов:
BackupConfig_RequirePassphrase_DefaultsToFalseOfflineSyncConfig_NetworkFallbackPollUrl_HasSafeDefaultDelegationBoundaryConfig_ChainContextTtlSec_DefaultsTo300NetworkMonitor_ResolveUrl_FallsBackToFallbackPollUrl_WhenPrimaryEmptyNetworkMonitor_ResolveUrl_ReturnsNull_WhenBothEmptyNetworkMonitor_ResolveUrl_PrefersExplicitPollUrlDelegationBoundaryService_CleanupExpiredChainContexts_EvictsStaleDelegationBoundaryService_CleanupExpiredChainContexts_NoOpWhenTtlDisabledDelegationBoundaryService_CleanupExpiredChainContexts_KeepsFreshEntries
Validation
dotnet build src/agent/Hercules.csproj -c Debug→ 0 errors, 15 pre-existing warnings (same as baseline).dotnet build tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj -c Debug→ 0 errors.dotnet test --filter "FullyQualifiedName~SharedMemorySyncTests|FullyQualifiedName~Backup|FullyQualifiedName~NetworkMonitor|FullyQualifiedName~IntentRouter|FullyQualifiedName~DelegationBoundary|FullyQualifiedName~MiscHardening"→ 79/79 passed.- Полный прогон
dotnet testпоказывает 9 pre-existing failures (NumericValidatorTests, OtelServiceTests, RedisTaskQueueTests.EnqueueAsync_StoresTaskMetadata, ResilientLLMClientSampledLogTests — задокументированы в task_085 notes). Новых регрессий от task_087 нет.
Remaining work (4 of 12 sub-tasks)
Mesh/Resilience/ResilientTransport.cs:26,98— peer semaphore trimSlo/SloService.cs:296— P95 real measurementSlo/SloService.cs:371— recovery time real measurementMesh/.../FanOutOrchestrator/MeshRouter— enforce FleetPolicy limits
These will be tackled in subsequent ticks.
Round 3 (this tick) — completed
Final four sub-tasks completed; task_087 is now fully done (12/12).
ResilientTransport peer-semaphore trim (sub-task #2):
Mesh/Resilience/ResilientTransport.cs— added_peerLastUsedConcurrentDictionary that stamps everySendAsyncwith the current UTC time. Three new public hooks:TrackedPeerCount(int): live count of allocated per-peer semaphores.GetTrackedPeers()(IReadOnlyList): snapshot of currently-tracked agent IDs, ordinal-sorted for deterministic assertions. RemovePeer(string agentId)(bool): removes the entry from_peerSemaphoresand_peerLastUsed, attempts a zero-wait acquire before disposing theSemaphoreSlimto avoid use-after-free on in-flightWaitAsynccallers. Logs at Debug when the semaphore is busy so a follow-up sweep picks it up.TrimIdlePeers(TimeSpan idleThreshold)(int): sweeps_peerLastUsedand evicts semaphores whose last use is older than the threshold. Idempotent and safe to call from a periodic timer. Threshold ≤ 0 is a no-op.
Dispose()clears_peerLastUsedto release references to disposed semaphores.
SLO P95 real measurement (sub-task #6):
Slo/ISloLatencyTracker.cs+Slo/SloLatencyTracker.cs— new in-process ring-buffer-based latency tracker. Bounded memory (default 512 samples per intent + a global ring). Records on everyRecordSample(intent, ms). Queries viaGetP95Ms(intent?, window?)use a snapshot-and-sort nearest-rank percentile over the in-window samples. Lock-free writes; concurrent reads may see at most one torn sample, which is tolerable for a 95th-percentile estimate.Slo/IConnectivityStateProvider.cs— new interface for the SLO service to query the most recent offline→online transition duration without coupling toNetworkMonitor.Mesh/Resilience/ResilientTransport.cs—SendAsyncnow records the end-to-end elapsed (success and failure) into the tracker under the envelope’s intent, with a try/catch Debug log so a tracker failure never breaks the transport.Slo/SloService.cs—EvaluateResponseTimeAsyncnow reads from the tracker first. Empty tracker / null tracker falls back to the previous audit-row-count heuristic. Breach description includes the source (tracker,audit-heuristic,default) so operators can tell at a glance whether the value is measured.Mesh/MeshServiceExtensions.cs— wires the tracker into the ResilientTransport factory (optional viasp.GetService<>).
SLO recovery-time real measurement (sub-task #7):
Offline/NetworkMonitor.cs— added_offlineSince(DateTimeOffset?) and_lastOutageDuration(TimeSpan) fields. On everyIsOnlinetransition:reachable && !IsOnline→ compute_lastOutageDuration = now - _offlineSinceand clear_offlineSinceunder the existing_lock.!reachable && IsOnline→ set_offlineSince = nowif not already set. Class now also implementsIConnectivityStateProviderand exposesLastOutageDuration(TimeSpan) — zero before any recovered outage.
Slo/SloService.cs—EvaluateRecoveryTimeAsyncreads from the connectivity provider first. Ceiling-rounds sub-minute outages so partial minutes are honestly reported. Empty provider / null provider falls back to the previous audit-degradation heuristic. Breach description includes the source (connectivity,audit-heuristic,none).Program.cs— registers the tracker singleton and resolves the connectivity provider from the existingNetworkMonitor(no new DI surface).
FleetPolicy enforcement in FanOutOrchestrator (sub-task #10):
Mesh/Router/FanOutOptions.cs— newMaxFanOutWidthproperty (default 0 = “unlimited unless a fleet template overrides”). Documents the contract clearly so operators know what 0 means.Mesh/Router/FanOutOrchestrator.cs— accepts an optionalIFleetTemplateManager?(backward-compatible). After the circuit-breaker filter and before fan-out, truncates the peer list tomin(peers.Count, effectiveMaxFanOutWidth)whereeffectiveMaxFanOutWidthresolves to:_options.MaxFanOutWidthif > 0 (operator override)._fleetTemplates.GetManifest().Policy.MaxFanOutWidthfrom the first fleet template that defines a positive value.- 0 (unlimited).
Exceptions from the fleet template read are caught and logged; the
fan-out never faults because of a template file error.
MaxConcurrentTasksPerAgentis intentionally not enforced here — that limit requires a process-wide in-flight counter that lives on the agent loop, not on the orchestrator. A comment in the code documents this and points at the right home for follow-up work.
Mesh/MeshServiceExtensions.cs— wires the optionalIFleetTemplateManagerinto theFanOutOrchestratorfactory.
Round 3 (this tick) — tests
tests/Hercules.Agent.Tests/Phase5Tests/ResilientTransportPeerTrimTests.cs— 9 unit tests coveringTrackedPeerCount,GetTrackedPeers,RemovePeer(with free and in-use semaphores),TrimIdlePeers(whitelist, non-positive threshold, re-entrant), andDisposecleanup.tests/Hercules.Agent.Tests/Phase5Tests/SloLatencyTrackerTests.cs— 9 unit tests covering constructor validation, empty-tracker behaviour, per-intent + global aggregation, percentile rank, time-window semantics, bounded ring overwrite, Reset, and SampleCount double-counting contract.tests/Hercules.Agent.Tests/Phase5Tests/NetworkMonitorOutageDurationTests.cs— 4 unit tests pinning theIConnectivityStateProviderimplementation, the initial-zero contract, and the negative-duration guard.tests/Hercules.Agent.Tests/Phase5Tests/FanOutFleetPolicyEnforcementTests.cs— 5 unit tests covering the three resolution paths (options, fleet template, default), the template-failure fallback, the no-template default behaviour, and the FanOutOptions default.tests/Hercules.Agent.Tests/Phase5Tests/SloServiceRealMetricsTests.cs— 4 unit tests covering the SLO service integration with the tracker and connectivity provider, plus the legacy audit-heuristic fallback paths.
Total new tests for Round 3: 31. All pass.
Round 3 (this tick) — validation
dotnet build src/agent/Hercules.csproj -c Debug→ 0 errors, 15 pre-existing warnings (same baseline as task_087 Round 2).dotnet build tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj -c Debug→ 0 errors.dotnet test --filter "FullyQualifiedName~SloLatencyTrackerTests|FullyQualifiedName~NetworkMonitorOutageDurationTests|FullyQualifiedName~FanOutFleetPolicyEnforcementTests|FullyQualifiedName~ResilientTransportPeerTrimTests|FullyQualifiedName~SloServiceRealMetricsTests"→ 31/31 passed.dotnet test --filter "FullyQualifiedName~Phase5Tests|FullyQualifiedName~FanOut|FullyQualifiedName~Resilient|FullyQualifiedName~NetworkMonitor|FullyQualifiedName~DelegationBoundary|FullyQualifiedName~Backup|FullyQualifiedName~SharedMemorySync|FullyQualifiedName~IntentRouter|FullyQualifiedName~Slo|FullyQualifiedName~AuditService|FullyQualifiedName~MeshService"→ 255/256 passed; the 1 failure isResilientLLMClientSampledLogTests.Retryable_failures_emit_sampled_warnings_and_increment_counterwhich was already in the task_085 documented pre-existing failure list.- Full
dotnet test— 1993 passed, 9 pre-existing failures (same bucket as task_087 Round 2 plus the WasmToolTests caching test that was already flaky in the previous validation pass). No new regressions introduced.
Dependencies
Round 2 (this tick) — completed
Audit filter implementation (sub-task #33):
Storage/Models.cs— newAuditLogQueryrecord carrying the filter set (Actor,Action,Target,SessionId,ToolName,Result,From,To,Limit);EffectiveLimitclamps non-positiveLimitto 100 so the SQL parameter can never be zero or negative.Storage/AuditLogService.cs—IAuditLogextended with a defaultQueryAsync(AuditLogQuery, CancellationToken)(in-memory filter overGetRecentAsync(Limit*4, …), capped at 4000 rows) and an override inAuditLogServicethat delegates to a parameterised SQL query.Storage/SqliteSessionStore.cs—GetAuditLogQueryAsync(AuditLogQuery, …)builds a dynamicWHERE 1=1 AND col = $p …statement, one predicate per non-null filter, parameterised to avoid SQL-injection risk; honourscreated_atas ISO 8601 so lexicographic comparison matches chronological order. Uses the existing_connLock(task_071) for thread-safety.Audit/AuditService.cs—QueryAsyncnow constructs anAuditLogQueryfrom the public parameters and delegates toIAuditLog.QueryAsync(query). The previous implementation silently dropped every filter excepttarget, so the audit dashboard could not narrow down by actor / action / session / tool / result / time window.
Round 2 (this tick) — tests
tests/Hercules.Agent.Tests/Phase5Tests/AuditServiceFilterTests.cs — 11
unit-tests against a real SqliteSessionStore:
Query_RespectsActorFilterQuery_RespectsActionFilterQuery_RespectsToolNameFilterQuery_RespectsResultFilterQuery_RespectsSessionIdFilterQuery_RespectsFromAndToTimeWindowQuery_CombinesAllFiltersQuery_NoFilters_ReturnsRecent— backward-compat regressionQuery_RespectsLimitAuditLogQuery_DefaultsLimitToHundredAuditLogQuery_NonPositiveLimit_ClampsToHundred
Round 2 (this tick) — validation
dotnet build src/agent/Hercules.csproj -c Debug→ 0 errors.dotnet build tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj -c Debug→ 0 errors.dotnet test --filter "FullyQualifiedName~AuditService|FullyQualifiedName~AuditLogService|FullyQualifiedName~MiscHardening"→ 47/47 passed (11 new + 9 MiscHardening + 27 existing AuditService).- Full
dotnet test— same 11 pre-existing failures as Round 1 (ProposalStoreCachingTests.CountToday_OnlyIncludesTodayUtc,RedisTaskQueueTests.EnqueueAsync_StoresTaskMetadata, 5×OtelServiceTests, 2×NumericValidatorTests,ResilientLLMClientSampledLogTests— all задокументированы в task_085 notes). Никаких новых регрессий от task_087.
Dependencies
- блокирует / опирается на: task_048 — delegation-boundaries
- блокирует / опирается на: task_053 — mesh-dashboard
- блокирует / опирается на: task_063 — backup-recovery
- блокирует / опирается на: task_064 — operational-slos
- блокирует / опирается на: task_014 — audit-privacy
Risks / Rollback
Individual fixes independent; rollback per-fix via git revert. CORS tightening может сломать existing web UI deployments — document migration.
Links
- Backlog: ../backlog.md
- Roadmap (EN): ../../ROADMAP-EN.md
- Roadmap (RU): ../../ROADMAP-RU.md