Skip to content

Commit c82f246

Browse files
Apply persistent rules when processes start (#21)
Apply persistent rules when processes start Wire persistent process rules into runtime process discovery so existing enabled rules can apply automatically while ThreadPilot is running. - Add PersistentRuleAutoApplyService for runtime rule auto-apply - Hook auto-apply into ProcessMonitorManagerService snapshot and process-start paths - Add ApplyPersistentRulesOnProcessStart app setting enabled by default - Use existing PersistentRulesEngine for CpuSelection, legacy affinity, CPU priority and memory priority - Add per-process/rule cooldown to avoid repeated apply spam - Clear cooldown state when processes exit or disappear from snapshots - Preserve safe protected-process and access-denied behavior without bypass claims - Preserve cancellation semantics during shutdown/stop paths - Add tests for matching rules, disabled/no-match cases, cooldown, retry, PID reuse, failures, feature flag and monitor integration No Windows Service, registry/IFEO persistence, installer changes, rules editor, version bump or tag changes.
1 parent dd45b38 commit c82f246

8 files changed

Lines changed: 899 additions & 5 deletions

Models/ApplicationSettingsModel.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel
164164
[ObservableProperty]
165165
private bool enableFallbackPolling = true;
166166

167+
[ObservableProperty]
168+
private bool applyPersistentRulesOnProcessStart = true;
169+
167170
// Advanced Settings
168171
[ObservableProperty]
169172
private bool enableDebugLogging = false;
@@ -249,6 +252,7 @@ public void CopyFrom(ApplicationSettingsModel other)
249252
this.FallbackPollingIntervalMs = other.FallbackPollingIntervalMs;
250253
this.EnableWmiMonitoring = other.EnableWmiMonitoring;
251254
this.EnableFallbackPolling = other.EnableFallbackPolling;
255+
this.ApplyPersistentRulesOnProcessStart = other.ApplyPersistentRulesOnProcessStart;
252256

253257
// Advanced Settings
254258
this.EnableDebugLogging = other.EnableDebugLogging;
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
/*
2+
* ThreadPilot - persistent rule runtime auto-apply coordinator.
3+
*/
4+
namespace ThreadPilot.Services
5+
{
6+
using System.Collections.Concurrent;
7+
using Microsoft.Extensions.Logging;
8+
using ThreadPilot.Models;
9+
10+
public interface IPersistentRuleAutoApplyService
11+
{
12+
Task<IReadOnlyList<PersistentRuleAutoApplyResult>> ApplyForDiscoveredProcessesAsync(
13+
IEnumerable<ProcessModel> processes,
14+
CancellationToken cancellationToken = default);
15+
16+
Task<IReadOnlyList<PersistentRuleAutoApplyResult>> ApplyForProcessStartAsync(
17+
ProcessModel process,
18+
CancellationToken cancellationToken = default);
19+
20+
void MarkProcessExited(int processId);
21+
}
22+
23+
public sealed record PersistentRuleAutoApplyResult
24+
{
25+
public bool Success { get; init; }
26+
27+
public string RuleId { get; init; } = string.Empty;
28+
29+
public int ProcessId { get; init; }
30+
31+
public string ProcessName { get; init; } = string.Empty;
32+
33+
public string? ErrorCode { get; init; }
34+
35+
public string UserMessage { get; init; } = string.Empty;
36+
37+
public string TechnicalMessage { get; init; } = string.Empty;
38+
39+
public bool IsAccessDenied { get; init; }
40+
41+
public bool IsAntiCheatLikely { get; init; }
42+
43+
public bool IsProcessExited { get; init; }
44+
45+
public static PersistentRuleAutoApplyResult FromApplyResult(PersistentRuleApplyResult result) =>
46+
new()
47+
{
48+
Success = result.Success,
49+
RuleId = result.RuleId,
50+
ProcessId = result.ProcessId,
51+
ProcessName = result.ProcessName,
52+
ErrorCode = result.ErrorCode,
53+
UserMessage = result.IsAntiCheatLikely
54+
? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning
55+
: result.UserMessage,
56+
TechnicalMessage = result.TechnicalMessage,
57+
IsAccessDenied = result.IsAccessDenied,
58+
IsAntiCheatLikely = result.IsAntiCheatLikely,
59+
IsProcessExited = result.IsProcessExited,
60+
};
61+
}
62+
63+
public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplyService
64+
{
65+
private static readonly TimeSpan DefaultCooldown = TimeSpan.FromSeconds(30);
66+
67+
private readonly IPersistentProcessRuleStore ruleStore;
68+
private readonly IPersistentProcessRuleMatcher matcher;
69+
private readonly IPersistentRulesEngine rulesEngine;
70+
private readonly IApplicationSettingsService settingsService;
71+
private readonly ILogger<PersistentRuleAutoApplyService> logger;
72+
private readonly Func<DateTimeOffset> nowProvider;
73+
private readonly TimeSpan cooldown;
74+
private readonly ConcurrentDictionary<RuleAttemptKey, DateTimeOffset> recentAttempts = new();
75+
76+
public PersistentRuleAutoApplyService(
77+
IPersistentProcessRuleStore ruleStore,
78+
IPersistentProcessRuleMatcher matcher,
79+
IPersistentRulesEngine rulesEngine,
80+
IApplicationSettingsService settingsService,
81+
ILogger<PersistentRuleAutoApplyService> logger)
82+
: this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown)
83+
{
84+
}
85+
86+
public PersistentRuleAutoApplyService(
87+
IPersistentProcessRuleStore ruleStore,
88+
IPersistentProcessRuleMatcher matcher,
89+
IPersistentRulesEngine rulesEngine,
90+
IApplicationSettingsService settingsService,
91+
ILogger<PersistentRuleAutoApplyService> logger,
92+
Func<DateTimeOffset> nowProvider,
93+
TimeSpan cooldown)
94+
{
95+
this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore));
96+
this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher));
97+
this.rulesEngine = rulesEngine ?? throw new ArgumentNullException(nameof(rulesEngine));
98+
this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
99+
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
100+
this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider));
101+
this.cooldown = cooldown <= TimeSpan.Zero ? DefaultCooldown : cooldown;
102+
}
103+
104+
public async Task<IReadOnlyList<PersistentRuleAutoApplyResult>> ApplyForDiscoveredProcessesAsync(
105+
IEnumerable<ProcessModel> processes,
106+
CancellationToken cancellationToken = default)
107+
{
108+
ArgumentNullException.ThrowIfNull(processes);
109+
110+
var snapshot = processes
111+
.Where(IsProcessEligible)
112+
.GroupBy(process => process.ProcessId)
113+
.Select(group => group.First())
114+
.ToList();
115+
this.ClearAttemptsForMissingProcesses(snapshot.Select(process => process.ProcessId).ToHashSet());
116+
117+
if (!this.IsEnabled() || snapshot.Count == 0)
118+
{
119+
return Array.Empty<PersistentRuleAutoApplyResult>();
120+
}
121+
122+
var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false);
123+
if (rules.Count == 0)
124+
{
125+
return Array.Empty<PersistentRuleAutoApplyResult>();
126+
}
127+
128+
var results = new List<PersistentRuleAutoApplyResult>();
129+
foreach (var process in snapshot)
130+
{
131+
cancellationToken.ThrowIfCancellationRequested();
132+
results.AddRange(await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false));
133+
}
134+
135+
return results;
136+
}
137+
138+
public async Task<IReadOnlyList<PersistentRuleAutoApplyResult>> ApplyForProcessStartAsync(
139+
ProcessModel process,
140+
CancellationToken cancellationToken = default)
141+
{
142+
ArgumentNullException.ThrowIfNull(process);
143+
144+
if (!this.IsEnabled() || !IsProcessEligible(process))
145+
{
146+
return Array.Empty<PersistentRuleAutoApplyResult>();
147+
}
148+
149+
var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false);
150+
return await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false);
151+
}
152+
153+
public void MarkProcessExited(int processId)
154+
{
155+
foreach (var key in this.recentAttempts.Keys.Where(key => key.ProcessId == processId))
156+
{
157+
this.recentAttempts.TryRemove(key, out _);
158+
}
159+
}
160+
161+
private async Task<IReadOnlyList<PersistentRuleAutoApplyResult>> ApplyForProcessAsync(
162+
ProcessModel process,
163+
IReadOnlyList<PersistentProcessRule> rules,
164+
CancellationToken cancellationToken)
165+
{
166+
var now = this.nowProvider();
167+
var candidates = rules
168+
.Where(rule => rule.IsEnabled && this.matcher.IsMatch(rule, process))
169+
.ToList();
170+
171+
if (candidates.Count == 0)
172+
{
173+
return Array.Empty<PersistentRuleAutoApplyResult>();
174+
}
175+
176+
var selectedRules = candidates
177+
.Where(rule => this.TryRecordAttempt(process.ProcessId, rule, now))
178+
.ToList();
179+
180+
if (selectedRules.Count == 0)
181+
{
182+
this.logger.LogDebug(
183+
"Persistent rule auto-apply suppressed by cooldown for process {ProcessName} (PID: {ProcessId})",
184+
process.Name,
185+
process.ProcessId);
186+
return Array.Empty<PersistentRuleAutoApplyResult>();
187+
}
188+
189+
var selectedSignatures = selectedRules
190+
.Select(GetRuleSignature)
191+
.ToHashSet(StringComparer.Ordinal);
192+
193+
try
194+
{
195+
// Runtime auto-apply only runs while ThreadPilot is open; it does not use registry,
196+
// IFEO, services, or protected-process bypass techniques.
197+
var applyResults = await this.rulesEngine
198+
.ApplyMatchingRulesAsync(
199+
process,
200+
rule => selectedSignatures.Contains(GetRuleSignature(rule)),
201+
cancellationToken)
202+
.ConfigureAwait(false);
203+
204+
var results = applyResults.Select(PersistentRuleAutoApplyResult.FromApplyResult).ToList();
205+
foreach (var result in results)
206+
{
207+
this.LogResult(result);
208+
}
209+
210+
return results;
211+
}
212+
catch (Exception ex) when (ex is not OperationCanceledException)
213+
{
214+
this.logger.LogWarning(
215+
ex,
216+
"Persistent rule auto-apply failed for process {ProcessName} (PID: {ProcessId})",
217+
process.Name,
218+
process.ProcessId);
219+
220+
return selectedRules
221+
.Select(rule => new PersistentRuleAutoApplyResult
222+
{
223+
Success = false,
224+
RuleId = rule.Id,
225+
ProcessId = process.ProcessId,
226+
ProcessName = process.Name,
227+
UserMessage = "ThreadPilot could not apply the saved rule.",
228+
TechnicalMessage = ex.Message,
229+
})
230+
.ToList();
231+
}
232+
}
233+
234+
private bool TryRecordAttempt(int processId, PersistentProcessRule rule, DateTimeOffset now)
235+
{
236+
var key = new RuleAttemptKey(processId, GetRuleSignature(rule));
237+
if (this.recentAttempts.TryGetValue(key, out var lastAttempt) &&
238+
now - lastAttempt < this.cooldown)
239+
{
240+
return false;
241+
}
242+
243+
this.recentAttempts[key] = now;
244+
return true;
245+
}
246+
247+
private void ClearAttemptsForMissingProcesses(HashSet<int> currentProcessIds)
248+
{
249+
foreach (var key in this.recentAttempts.Keys.Where(key => !currentProcessIds.Contains(key.ProcessId)))
250+
{
251+
this.recentAttempts.TryRemove(key, out _);
252+
}
253+
}
254+
255+
private void LogResult(PersistentRuleAutoApplyResult result)
256+
{
257+
if (result.Success)
258+
{
259+
this.logger.LogInformation(
260+
"Applied saved persistent rule {RuleId} to process {ProcessName} (PID: {ProcessId})",
261+
result.RuleId,
262+
result.ProcessName,
263+
result.ProcessId);
264+
return;
265+
}
266+
267+
var logLevel = result.IsAccessDenied || result.IsAntiCheatLikely || result.IsProcessExited
268+
? LogLevel.Debug
269+
: LogLevel.Warning;
270+
this.logger.Log(
271+
logLevel,
272+
"Persistent rule {RuleId} was not applied to process {ProcessName} (PID: {ProcessId}): {Message}",
273+
result.RuleId,
274+
result.ProcessName,
275+
result.ProcessId,
276+
result.UserMessage);
277+
}
278+
279+
private bool IsEnabled() =>
280+
this.settingsService.Settings.ApplyPersistentRulesOnProcessStart;
281+
282+
private static bool IsProcessEligible(ProcessModel process) =>
283+
process.ProcessId > 0 && !string.IsNullOrWhiteSpace(process.Name);
284+
285+
private static string GetRuleSignature(PersistentProcessRule rule) =>
286+
string.Join(
287+
"|",
288+
string.IsNullOrWhiteSpace(rule.Id) ? rule.Name : rule.Id,
289+
rule.UpdatedAt.ToUniversalTime().Ticks);
290+
291+
private readonly record struct RuleAttemptKey(int ProcessId, string RuleSignature);
292+
}
293+
}

Services/PersistentRulesEngine.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public interface IPersistentRulesEngine
1111
{
1212
Task<IReadOnlyList<PersistentRuleApplyResult>> ApplyMatchingRulesAsync(
1313
ProcessModel process,
14+
Predicate<PersistentProcessRule>? ruleFilter = null,
1415
CancellationToken cancellationToken = default);
1516
}
1617

@@ -49,14 +50,17 @@ public PersistentRulesEngine(
4950

5051
public async Task<IReadOnlyList<PersistentRuleApplyResult>> ApplyMatchingRulesAsync(
5152
ProcessModel process,
53+
Predicate<PersistentProcessRule>? ruleFilter = null,
5254
CancellationToken cancellationToken = default)
5355
{
5456
ArgumentNullException.ThrowIfNull(process);
5557

5658
var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false);
5759
var results = new List<PersistentRuleApplyResult>();
5860

59-
foreach (var rule in rules.Where(rule => this.matcher.IsMatch(rule, process)))
61+
foreach (var rule in rules.Where(rule =>
62+
(ruleFilter == null || ruleFilter(rule)) &&
63+
this.matcher.IsMatch(rule, process)))
6064
{
6165
cancellationToken.ThrowIfCancellationRequested();
6266
results.Add(await this.ApplyRuleAsync(rule, process, cancellationToken).ConfigureAwait(false));

0 commit comments

Comments
 (0)