Servy v10.2 introduces full per-service security isolation (see Security.md and Architecture.md for details), a new CLI show command, automated ACL hardening, and core engine stability improvements.
Key Highlights
- Full Per-Service Security Isolation: Custom service accounts can no longer read or modify other services' configurations, runtime states, or logs. Decryption and database access are now proxied through the isolated
Servyhost process (Servy.Host.exe) via a DACL-restricted local Named Pipe. - Per-Service Log Isolation: Each wrapped service now writes its wrapper and restarter logs to an isolated subfolder (
%ProgramData%\Servy\logs\service\<ServiceName>\). Custom service accounts hold permissions strictly within their own log subfolder and cannot access or tamper with logs from other services. - Automated Vault & Binary Hardening: Servy automatically enforces strict ACL lockdown rules on the
%ProgramData%\Servyroot vault and core binaries (.exe/.dll), removing the need for manual sysadmin hardening scripts or elevated permissions setup. - CLI
showCommand (#7018): Inspect and print service configurations directly from the terminal in a clean, human-readable format.
Complete List of Changes
- Security & IPC Proxy: Introduced
Servy.Host.exefor local IPC configuration and state requests, enforced process identity verification (GetNamedPipeClientProcessIdvs SCM PID), hardened vault permissions, added per-service log folder isolation, improved NTFS hard-link safeguards, and strengthened key-vault access rules. - CLI & PowerShell: Added the
showcommand (#7018) and fixed wildcard path resolution issues inServy.psm1. - Core Engine & Process Management: Resolved concurrency race conditions in log stream rotation and handle drain timeouts, corrected environment variable line parsing, and cleaned up unused constants.
- Desktop App & Servy Manager: Preserved node expansion states in the Dependencies tab, fixed log-tailing scroll jumps, resolved secondary-tab automation name collisions, and localized event log level displays.
- Notifications & Task Scheduler: Prevented stale-read race conditions during watermark updates, improved WinRT type load error classification, and fixed bracket truncation in service message parsing.
- CI/CD & Release Tooling: Streamlined test coverage gates, fixed SonarCloud package caching paths, filtered stress tests out of release builds, and hardened Markdown tag escaping in changelog scripts.
Breaking Changes
- Reserved Service Name (
Servy): The service name"Servy"is now reserved for the Servy Host service (Servy.Host.exe) that manages secure IPC communication. If you previously installed a Windows service named"Servy", you must rename it prior to installing v10.2. - Fixed Vault File Locations: Custom directory paths for
Servy.dband keying material are no longer supported. The configuration database and security keys must reside in%ProgramData%\Servy\dband%ProgramData%\Servy\security, where they are automatically hardened and managed by the host process.
Full Changelog
Click to expand release notes!
- feat(cli,psm1): Add
showcommand to display service configuration in a human-readable format (#7018) - feat(security): Add per-service security isolation through secure IPC, DACLs and ACLs
- feat(security): harden the vault in C# instead of Set-ServyExePermissions.ps1 and re-apply the hardening whenever a binary is extracted
- fix(security): Set-ServyExePermissions.ps1 - the handle64/handle64a probe is if/elseif, so a machine with both binaries present (after an architecture migration) leaves one unhardened and unreported (#6855)
- fix(security): Set-ServyExePermissions.ps1 - Get-Acl/Set-Acl follow NTFS hard links with no reparse/link-count check, letting the runner account redirect the admin's own hardening run onto an arbitrary file (#6866)
- fix(security): Set-ServyExePermissions.ps1 - the admin-membership warning condition is written twice, and the final-summary copy has already drifted (lost a space around -eq) (#6870)
- fix(security): Set-ServyExePermissions.ps1 - Test-ServyAdminGroupMember's S4U fast path takes a UPN, so none of the five documented -TargetAccount formats can ever reach it and the ADSI fallback always runs (#7008)
- fix(security): Set-ServyExePermissions.ps1 - the #6866 hard-link check fails open: a non-zero fsutil exit or empty output is treated as 'one link' and the file is hardened anyway (#7009)
- fix(security): Set-ServyExePermissions.ps1 - grant Modify on %ProgramData%\Servy itself and drop Delete on db\Servy.db, instead of telling users to grant Modify by hand (#7136)
- fix(security): Set-ServyExePermissions.ps1 - the vault Modify grant gives the runner Delete on db\ and on the vault folder itself, so it can rename db\ away and plant its own Servy.db, bypassing the #7136 no-Delete ACE on the file (#7140)
- fix(security): Set-ServyExePermissions.ps1 - the #7140 Deny Delete ACE on every vault folder is absent from the header, and the root audit line prints only [Modify - Allow] (#7143)
- fix(dump,restore): Servy-Restore.ps1 / Servy-Dump.ps1 - -WhatIf is honoured only by the SCM branch, so a what-if restore still overwrites the database and both scripts leave their plain-text staging directory behind (#7014)
- fix(core): grant service control and status rights on SCM handle during install and update
- fix(core): AppConfig.cs - four public constants have zero consumers; ConsoleSpinnerDelayMs' own call site still hardcodes the literal (residual of #1186) (#5499)
- fix(core): ProcessKiller.cs - two provably-unreachable defensive terms: parentStartTime's MinValue initializer (residue of #2214/#1231) and a ?? on ProcessInfoNode.Name (#5515)
- fix(core): ServiceDtoImportValidator.cs - TryValidate's 'errorMessage set on failure' invariant is prose-only; the #4397 fix's NotNullWhen never reached the repo's other Try-pattern (#5986)
- fix(core): ServiceValidationRules.cs - Validate normalizes three fields and discards the result, so the CLI and Manager import paths persist the un-normalized string that was never validated (#5987)
- fix(core): ServiceManager.cs - four 'repository is not initialized' guards re-check a readonly field the constructor already pinned non-null (#6375)
- fix(core): Domain/Service.cs - GetServiceStartupType has no caller in src, returns ServiceStartType? and documents a null for a missing service that IServiceManager's non-nullable Unknown contract never produces (#6674)
- fix(core): ServiceHelper.cs - StopServicesAsync has no fast-fail for a service that re-enters Running during the stop wait, unlike StartServicesAsync's Stopped fast-fail (#6806)
- fix(core): ProcessKiller.cs - the #6782 provenance check silently reverts to name-only trust when ExecutablePath is null, exactly the failure mode SystemProcessWrapper already documents (#6816)
- fix(core): HandleHelper.cs - the timeout path's unsynchronized errorBuilder read races the still-running async handler when Kill() fails to terminate within the drain window (#6842)
- fix(core): HandleHelper.cs - the lock-protected timeout-path errorBuilder read is atomic but can still be incomplete when Kill() does not confirm termination within the drain window (#6844)
- fix(core): RotatingStreamWriter.cs - the rotation wait-timeout recovery lets a writer reattach to a file PerformPhysicalRotation may still be moving, silently misdirecting writes into the archive (#6873)
- fix(core): Logger.cs - WriteLeveled discards a provided exception when the message argument is null or empty (#6887)
- fix(core): ServiceControllerProvider.cs - GetService throws ArgumentNullException for a blank-but-non-null serviceName, unlike every sibling IsNullOrWhiteSpace guard (#6890)
- fix(core): AppFoldersHelper.cs - rootVaultPath is the only EnsureFolders path parameter without an absolute-path guard, unlike its three siblings (#6897)
- fix(core): AppConfig.cs - the portable deployment's Windows Service ImagePath resolves from an unhardened, arbitrary extraction folder (#6923)
- fix(core): Helper.cs - WriteFileAtomicCore has no fallback for a hardened-ACL UnauthorizedAccessException, unlike its sync sibling WriteFileAtomic (#6928)
- fix(core): Helper.cs - the transient-retry catch's UnauthorizedAccessException arm is unreachable behind the preceding catch clause, in both WriteFileAtomic and WriteFileAtomicCore (#6932)
- fix(core): ServiceHelper.cs - StopServicesAsync's wait loop refreshes AFTER the delay, reintroducing the exact stale-read race #181 already fixed on the Start side (#6943)
- fix(core): ServiceManager.cs - two of GetAllServices' three 'details unavailable' sentinels are hardcoded English while the #5981 one reads a Strings resource (#6957)
- fix(core): ServiceManager.cs - ToScmStartType returns the managed enum's numeric value where UninstallServiceAsync names SERVICE_DEMAND_START for the same native parameter (#6959)
- fix(core): EnvironmentVariableParser.cs - the #6111 fix appends ' (record N).' to two resource messages that already end in a period, so both exceptions now read '... empty. (record 1).' (#6993)
- fix(core): EnvironmentVariablesValidator.cs / Strings.resx (Core) - the #6993 fix moved '(record {0})' into two resource values the validator still assigns unformatted, so Validate now reports '... (record {0}).' verbatim (#6997)
- fix(core): EnvironmentVariableParser.cs - the '(record N)' position is the raw split index, so CRLF-separated input reports the second line as record 3 and every later line as 2n-1 (#6999)
- fix(core): AppFoldersHelper.cs - the relative Data Source guard is the one check in its block that throws ArgumentException, and its ParamName names the local dbFolder (#7017)
- fix(core): ProtectedKeyProvider.cs - the #5953 fix deleted mutexSecurity.AddAccessRule, so the key-vault mutex is created with an empty MutexSecurity and the AuthenticatedUsers rule is built and never applied (#7061)
- fix(core): Helper.cs - IsVolumeMountPoint trusts DirectoryInfo.LinkTarget, which returns the junction's creator-chosen print name, so since #7100 any junction printed as 'Volume{...}' bypasses HasAncestorReparsePoint and the service's log-path guard (#7141)
- fix(core): Helper.cs - WriteFileAtomic's hardened-target fallback deletes the destination before the move, so a move that keeps failing or a cancellation during the back-off loses both the old file and the new one (#7154)
- fix(core): Helper.cs - after #7154 the hardened-target fallback moves the target aside, but its AggregateException and retry log still say "explicit delete fallback" (net48 already says "fallback move") (#7166)
- fix(infra): DapperExecutor.cs - the retry-exhaustion exception names AppConfig.Db*MaxRetries, which does not exist, and the sync log still says 'Spinning' over a Thread.Sleep (residual of #1381) (#5989)
- fix(infra): ServiceRepository.cs - the decryption-marker strip regex hand-copies two interpolated literals from the same file, so any reword silently persists the UI marker into Description (#5992)
- fix(infra): SqlConstants.cs - UpsertSet hardcodes 'Services.' nine lines below the ServicesTableName constant that owns the name (residual of #3278) (#6394)
- fix(service): ServiceHelper.cs - EnvironmentVariablesToString prints "None" for a null list that StartOptions never produces and a blank for the empty list every service without custom variables has (#6515)
- fix(service): ServiceHelper.cs - LogStartupArguments null-guards an options argument that ValidateAndLog, its only caller, dereferences on the next statement; OnStart already throws on null before either (#6516)
- fix(service): StartOptionsParser.cs - SafeParseEnvVars's ArgumentException catch arm is unreachable; EnvironmentVariableParser.Parse only ever throws FormatException (#6902)
- fix(restarter): ServiceController.cs / IServiceController.cs (Restarter) - re-declare an abstraction Servy.Core already provides as IServiceControllerWrapper, and the copy shadows the BCL type name it wraps (#5620)
- fix(restarter): ServiceRestarter.cs - the Stop/Start pre-wait timeout exceptions say 'while waiting' although WaitForStatus was never called this pass, contradicting the log line one line above (#6901)
- fix(ui): AppBootstrapper.cs - EnsureEventSourceExists and CoreSettingsLoader.Validate still run before the stopwatch starts, so the splash floor is still measured from the wrong origin (residual of #6205) (#6882)
- fix(ui): AppBootstrapper.cs - the #6559 fix restored the message-keyed debounce #6206 removed, so the 'key on the fault SITE' comment now sits above a key that includes the message (#7032)
- fix(desktop): MainViewModel.cs (Servy) - BindServiceDtoToModel is the one DTO consumer that never calls HydrateDefaults, so it keeps a third copy of the default table (30 sites, 11 as four-clause ternaries) that every helper extension leaves behind (#6592)
- fix(desktop): trings.resx / MainWindow.xaml (Servy) - 17 automation names are verbatim copies of the label they sit next to and one hook hint is stored 5 times; #5260 fixed the drift but not the copies that caused it (#5664)
- fix(desktop): ServiceCommands.cs (Servy) - OpenManagerAsync and OpenSecurityHardeningGuideAsync check the token before the try, so their OperationCanceledException re-throw arms guard nothing in production; the four Export/Import siblings check inside it (#6647)
- fix(desktop,manager): ServiceCommands.cs (Servy + Manager) - the injected XML/JSON serializers are stored but never read since #5964, so the Manager suite's five 'single-parse invariant' Deserialize asserts cannot fail (#7126)
- fix(manager): App.xaml (Manager) - the four-brush selection remap is copy-pasted between the DataGrid and TreeViewItem styles, keeping #0078D7 and White as four literals each after #4833 centralized them into this file (#5582)
- fix(manager): ConsoleViewModel.cs - the #4348 type guard is unreachable; MemberwiseClone cannot return a type other than the one it was called on (#5595)
- fix(manager): PidBadgeControl.xaml / PerformanceView.xaml / App.xaml - the #1B7F9F accent is a literal at five sites in three files, and #757575 at two, after #4833 and #5222 centralized the selection and row-hover colours (#5611)
- fix(manager): ConsoleViewModel.cs - both StartLiveTail guards re-test a condition their result object already encodes, and the stderr comment credits the dead term with preventing duplicate entries (#6005)
- fix(manager): LogsView.xaml / LogsViewModel.cs (Manager) - EventLogLevel is the only user-facing enum in either app with no localized display, so the log-level filter renders raw English enum names (#6072)
- fix(manager): Strings.resx (Manager) - Automation_LogsTab_HelpText scopes the Logs tab to 'the service' although the Logs view filters all services (#6461)
- fix(manager): Strings.resx (Manager) - three sibling string pairs drift by one word each (Console tab/root, the two already-exists confirmations, the two desktop-app errors) (#6462)
- fix(manager): MainViewModel.cs (Manager) - both callers re-catch what RefreshAllServicesAsync already swallows: the step-6 catch blocks are unreachable and 'RefreshAllServicesAsync failed.' can never be logged (#6479)
- fix(manager): DependenciesView.xaml - the cyclic (#F39C12) and unavailable (#E67E22) colours are each written as a literal in three separate styles that must agree (sibling of #5611) (#6488)
- fix(manager): DependenciesView.xaml / ConsoleView.xaml / PerformanceView.xaml - seven single-child StackPanel wrappers left behind by the #4832 PID-badge consolidation (#6499)
- fix(manager): BoolToVisibilityConverter.cs - re-implements the framework's BooleanToVisibilityConverter; the three consumers bind a plain bool, so the only difference (Binding.DoNothing fallback) is unreachable (#6563)
- fix(manager): HistoryResult.cs - constructor's must-be-UTC guard rejects Local but silently accepts Unspecified, which both existing tests happen to pass (#6823)
- fix(manager): ConsoleView.xaml.cs - deselecting a log line resumes tailing and jumps to bottom even when scrolled away, bypassing the documented at-bottom check (#6881)
- fix(manager): DependenciesView.xaml - the toolbar row's Grid declares six ColumnDefinitions but only five children ever use Grid.Column, so the trailing Auto column is dead markup (#6900)
- fix(manager): Strings.resx / MainWindow.xaml / ServiceListControl.xaml - the sidebar search box and grid reuse MainWindow's own automation Names, so two elements answer to 'Search Services' once any secondary tab is opened (#6921)
- fix(manager): DependenciesViewModel.cs - the #6006 expansion capture also runs on selection change, so branches the user opened on one service re-open by name on the next service's tree (#7006)
- fix(cli): InstallServiceOptions.cs - the --user help text still hardcodes the SERVY_PASSWORD literal while --password 20 lines below composes it from AppConfig.PasswordEnvVarName (residual of #1432) (#5888)
- fix(cli): ImportServiceCommand.cs - the Msg_ImportInstallGeneralFailure fallback at line 282 is unreachable, OperationResult.Failure rejects a blank message so a failed result always carries ErrorMessage (#6640)
- fix(cli): InstallServiceOptions.cs - six of seven EnableHealthMonitoring-gated options still omit the Only used when health monitoring is enabled caveat from their --help text (#6744)
- fix(cli): InstallServiceOptions.cs - RecoveryAction is the seventh EnableHealthMonitoring-gated option, and the #6744 fix that added the caveat to its six siblings left it out (#6829)
- fix(cli): ImportServiceCommand.cs - the injected XML/JSON serializers are stored but never read, the CLI twin that the #7126 fix left behind (#7127)
- fix(psm1): Servy.psm1 - the 28,000-character sensitive-value length cap is a magic number repeated at 8 sites (#6712)
- fix(psm1): Servy.psm1 - the 19 remaining Test-Path calls bind the caller's path to -Path, so a bracketed executable, directory or log path fails validation as not found (residual of #6686) (#7015)
- fix(psm1): Servy.psm1 - Set-ServyHardenedFileAcl's PS 2.0 directory branch builds and serializes an empty DirectorySecurity, then overwrites it with GetAccessControl (#7145)
- fix(notifications): Servy-Watermark.psm1 - a service name containing ']' is truncated by the lazy bracket match in ConvertFrom-ServyEventMessage (#6305)
- fix(notifications): ServyFailureEmail.vbs / ServyFailureNotification.vbs - the twins differ only in a hardcoded sibling filename, so #4782's convergence is maintained by hand and no test covers either file (#6683)
- fix(notifications): Servy-Watermark.psm1 - Update-Watermark's compare-then-commit still races across concurrent instances after the #1676/#4207 fixes; the unique temp file stops staging collisions but not a stale-read commit clobbering a newer one (#6750)
- fix(notifications): Write-ServyLog.ps1 - rotated-filename stamp format and 1MB default rotation size still diverge from RotatingStreamWriter.cs / AppConfig.cs (residual of #5880) (#6858)
- fix(notifications): Get-ServyLastErrors.ps1 - the LastProcessed validation catch can never fire, so a malformed watermark silently becomes a bounded first-run fetch instead of an error (#6922)
- fix(notifications): ServyFailureNotification.ps1 - a failed WinRT type load is classified TransientFailure, so a host without the toast stack never advances the watermark and logs one fallback line per Servy error forever (mirror of #5878) (#7012)
- fix(notifications): Write-ServyLog.ps1 - Test-Path, Get-Item and Get-ChildItem take the caller's log path through -Path, so a bracketed log directory silently skips rotation and pruning (the #4997/#7011 class) (#7013)
- fix(notifications): Servy-Watermark.psm1 - #7053 made Read-Watermark's guard literal but not its Get-Content, so a bracketed script directory is never read back and every run drops all but the newest event; ServyFailureEmail.ps1's config and credential reads are still on -Path (#7054)
- ci(test.yml): 'dotnet new tool-manifest' is the one unguarded call of the coverage-merge step's three; its failure is reported as 'dotnet tool install failed' (residual of #3153) (#5833)
- ci(test.yml): the coverage-upload gate expression is repeated verbatim in five step conditions, so a rule change is a five-line edit and a drifted copy skips or runs one upload silently (#6982)
- ci(test-project.yml): project_name accepts 'filename or path', but the Get-ChildItem -Filter lookup cannot match a path and silently takes the first of several partial-name matches (#6961)
- ci(test-project.yml): the project lookup stores its result in $matches, the automatic variable the next -match operator silently replaces (#6981)
- ci(publish.yml): the x64 'Run all tests' step is the only CI dotnet test call without --filter Category!=Stress, so the stress suites #6123 gated out of test.yml still run on every publish (#6962)
- ci(choco.yml): 'choco push' is the release chain's last unretried network call; both downloads and the git push honour MAX_RETRIES, the publish itself is single-shot (#5834)
- ci(choco.yml): the installer URL/filename template staged into GITHUB_ENV is rebuilt by hand in the SHA256 and chocolateyinstall steps (#6273)
- ci(security.yml): CodeQL init lists both security-extended and its superset security-and-quality; the first suite is fully contained in the second (#6278)
- ci(security.yml): gitleaks-action gets GITHUB_TOKEN specifically to comment on PRs with findings, but the job has no pull-requests: write to make that call succeed (#6835)
- ci(security.yml): MAX_RETRIES is declared in both jobs but only reaches setup-dotnet, not the dotnet restore/list/build calls (#6947)
- ci(loc.yml): the badge deploy force-pushes the loc branch with no concurrency group, so two pushes to main can leave the older commit's counts published (residual of #4177) (#5831)
- ci(loc.yml): the badge-deploy push still inlines secrets.GITHUB_TOKEN and github.repository in the run script, missed by the #4963 'last two' inventory (#6274)
- ci(sonar.yml): the 'Cache SonarCloud packages' step caches ~\sonar\cache, a path the scanner never writes, so no cache is ever saved and the JRE is re-downloaded on every run (#6960)
- ci(wiki.yml): the dotnet test/vstest filter for ProtectedVariablesDocumentationTests exits 0 when zero tests match, so a renamed or deleted test class reports the parity check green (#6865)
- ci(changelog.yml): six of the seven explicit code points in dashPattern are already inside the \p{Pd} class the same regex ends with (#6271)
- ci(changelog.yml): the generator still has no tag-escaping, so #6815's fix (a fourth CHANGELOG.md hand-patch, not the generator) does not stop the class recurring (#6834)
- ci(changelog.yml): Protect-MarkdownTags misparses 3+-backtick fences, leaving text after a fence unescaped (the #6834 fix's own gap) (#6837)
- ci(changelog.yml): Protect-MarkdownTags's early-return guard is 'return$Text' (missing space), so an empty/null $Text crashes the step instead of returning (#6838)
- ci(changelog.yml): Protect-MarkdownTags only escapes attribute-free tags, so an attributed raw XML tag still gets silently stripped by GitHub (#6839)
- ci(changelog.yml): MAX_RETRIES is declared and used for the push but the release-fetch gh api call has no retry at all (#6946)
- ci(changelog.yml): unguarded IndexOf(sectionEndTag, secStartIdx) throws ArgumentOutOfRangeException when the Servy.sln ProjectSection(SolutionItems) block is missing (#6949)
- chore: various robustness, security, inconsistency and code quality fixes
- chore(deps): update dependencies