diff --git a/docs/superpowers/plans/2026-05-10-applens-v1-platform-ledger.md b/docs/superpowers/plans/2026-05-10-applens-v1-platform-ledger.md new file mode 100644 index 0000000..a286726 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-applens-v1-platform-ledger.md @@ -0,0 +1,170 @@ +# AppLens V1 Platform Ledger Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the AppLens-local evidence ledger and platform-host foundation without merging external apps or building MCP/A2A in v1. + +**Architecture:** AppLens remains snapshot-centric. A new backend ledger writes append-only JSONL events under `%LOCALAPPDATA%\AppLens\ledger\events.jsonl` and maintains a SQLite index under `%LOCALAPPDATA%\AppLens\ledger\index.sqlite`; the desktop orchestrator appends events after scans and Tune actions. Static module manifests and status checks describe LLM, Oracle, Mailbox, and Zero without running live jobs. + +**Tech Stack:** .NET, WinUI, xUnit, `Microsoft.Data.Sqlite`, JSONL, `%LOCALAPPDATA%` runtime storage. + +--- + +## Scope + +Implement now: + +- Local runtime storage root resolver with `%LOCALAPPDATA%\AppLens` default. +- Evidence ledger event model with future-proof identity/scope/privacy/policy fields. +- JSONL append log as truth. +- SQLite index as rebuildable local query layer. +- Backend tests for append, readback, index creation, and corrupt-line tolerance. +- Desktop orchestration hooks for scan completion and Tune action completion. +- Static app/module manifest model and status model for LLM, Oracle, Mailbox, and Zero. +- UI additions in the current single-page dashboard for ledger storage/status and hosted-app statuses. + +Defer: + +- MCP adapter. +- A2A adapter. +- Full participant registry. +- Double-handshake grants. +- Cryptographic signing. +- Live Oracle research, LLM lane start/stop, Mailbox sync/send. +- Mailbox, Oracle, AppLens-LLM, or Stigmergent code edits. +- Broad Tune action expansion unless it fits safely after the ledger is complete. + +## File Structure + +- Create `src/AppLens.Backend/RuntimeStorage.cs` + - Resolves AppLens runtime root and ledger paths. +- Create `src/AppLens.Backend/BlackboardEvent.cs` + - Defines the ledger event contract, enums, artifact refs, provenance, and policy result. +- Create `src/AppLens.Backend/BlackboardStore.cs` + - Implements `IBlackboardStore`, append, read, SQLite index, and rebuild. +- Create `src/AppLens.Backend/ModuleManifest.cs` + - Defines static hosted-module manifest and status contracts. +- Create `src/AppLens.Backend/ModuleStatusService.cs` + - Computes blocked/available statuses without live jobs. +- Modify `src/AppLens.Backend/AppLens.Backend.csproj` + - Add `Microsoft.Data.Sqlite`. +- Modify `src/AppLens.Desktop/MainWindow.xaml` + - Add compact ledger/status section in the existing single-page dashboard. +- Modify `src/AppLens.Desktop/MainWindow.xaml.cs` + - Instantiate ledger/status services and append events after scan and Tune action completion. +- Create `tests/AppLens.Backend.Tests/BlackboardStoreTests.cs` + - TDD coverage for local ledger behavior. +- Create `tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs` + - TDD coverage for static status checks using temp dirs. +- Modify `tests/AppLens.Backend.Tests/AppLens.Backend.Tests.csproj` + - Add SQLite dependency if needed by test project restore. + +## Task 1: Runtime Storage Root + +**Files:** +- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\RuntimeStorage.cs` +- Test: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\RuntimeStorageTests.cs` + +- [ ] Write a failing test proving explicit roots create expected ledger paths. +- [ ] Run `dotnet test .\tests\AppLens.Backend.Tests\AppLens.Backend.Tests.csproj --filter RuntimeStorageTests`. +- [ ] Implement `AppLensRuntimeStorage` with `Root`, `LedgerDirectory`, `EventsJsonl`, and `IndexSqlite`. +- [ ] Run the focused test until green. + +## Task 2: Ledger Event Contract + +**Files:** +- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\BlackboardEvent.cs` +- Test: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\BlackboardEventTests.cs` + +- [ ] Write a failing test for creating a scan-completed event with schema version, participant identity, module id, app id, scope id, correlation id, data state, privacy state, and lifecycle state. +- [ ] Run the focused test and verify it fails because the model does not exist. +- [ ] Implement event records and enums. +- [ ] Run focused tests until green. + +## Task 3: JSONL Append Store + +**Files:** +- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\BlackboardStore.cs` +- Test: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\BlackboardStoreTests.cs` + +- [ ] Write a failing test that appends an event and reads it back from JSONL. +- [ ] Write a failing test that a corrupt JSONL line is skipped while valid lines still load. +- [ ] Run focused tests and verify failures. +- [ ] Implement `IBlackboardStore.AppendAsync` and `ReadAllAsync`. +- [ ] Run focused tests until green. + +## Task 4: SQLite Index + +**Files:** +- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\AppLens.Backend.csproj` +- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\BlackboardStore.cs` +- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\BlackboardStoreTests.cs` + +- [ ] Add `Microsoft.Data.Sqlite` to the backend project. +- [ ] Write a failing test proving appending an event creates `index.sqlite` and indexes event id, type, module id, app id, created time, data state, privacy state, and summary. +- [ ] Run focused tests and verify failure. +- [ ] Implement SQLite initialization and indexing. +- [ ] Run focused tests until green. + +## Task 5: Module Manifests And Status + +**Files:** +- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\ModuleManifest.cs` +- Create: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Backend\ModuleStatusService.cs` +- Test: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\tests\AppLens.Backend.Tests\ModuleStatusServiceTests.cs` + +- [ ] Write failing tests for Oracle blocked when repo path is absent. +- [ ] Write failing tests for Mailbox blocked when config is absent. +- [ ] Write failing tests for LLM blocked when CLI/package path is absent. +- [ ] Write failing tests for Zero blocked when no raw/import source is configured. +- [ ] Implement static manifests and file-only status checks. +- [ ] Run focused tests until green. + +## Task 6: Scan Completion Ledger Hook + +**Files:** +- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Desktop\MainWindow.xaml.cs` +- Optional test seam: backend factory method in `BlackboardEvent.cs` + +- [ ] Add a backend test for a scan-completed event factory using a small `AuditSnapshot`. +- [ ] Run focused test and verify failure. +- [ ] Implement the factory method. +- [ ] Wire desktop scan completion to append the event after `AuditService.RunAsync` returns. +- [ ] Run backend tests. + +## Task 7: Tune Action Ledger Hook + +**Files:** +- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Desktop\MainWindow.xaml.cs` +- Optional test seam: backend factory method in `BlackboardEvent.cs` + +- [ ] Add a backend test for Tune action-completed event factory using a `TuneActionRecord`. +- [ ] Run focused test and verify failure. +- [ ] Implement the factory method. +- [ ] Wire desktop Tune action completion to append one event per returned action record. +- [ ] Run backend tests. + +## Task 8: Dashboard Status Section + +**Files:** +- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Desktop\MainWindow.xaml` +- Modify: `C:\Users\codyl\Desktop\csiOS\Projects\AppLens\src\AppLens.Desktop\MainWindow.xaml.cs` + +- [ ] Add a compact storage/status section to the existing single-page dashboard. +- [ ] Show runtime root, ledger file path, indexed event count if available, and module statuses. +- [ ] Keep the current single-page shape; do not add navigation. +- [ ] Build the desktop project. + +## Task 9: Verification + +**Files:** +- No new files. + +- [ ] Run `dotnet test C:\Users\codyl\Desktop\csiOS\Projects\AppLens\AppLensDesktop.sln`. +- [ ] Run `dotnet build C:\Users\codyl\Desktop\csiOS\Projects\AppLens\AppLensDesktop.sln`. +- [ ] Inspect `git diff --stat`. +- [ ] Confirm no files changed in AppLens-LLM, Oracle, Mailbox, or Stigmergent. + +## Self-Review + +This plan covers the approved v1 platform foundation: local runtime root, JSONL + SQLite ledger, future-proof schema hooks, manifest/status boundaries, scan/Tune persistence, and current UI shape. It intentionally defers MCP, A2A, full registry, double-handshake, live external jobs, and broader Tune side effects until the ledger and host contracts are stable. diff --git a/src/AppLens.Backend/AppLens.Backend.csproj b/src/AppLens.Backend/AppLens.Backend.csproj index dced380..97c6fb0 100644 --- a/src/AppLens.Backend/AppLens.Backend.csproj +++ b/src/AppLens.Backend/AppLens.Backend.csproj @@ -8,6 +8,7 @@ + diff --git a/src/AppLens.Backend/BlackboardEvent.cs b/src/AppLens.Backend/BlackboardEvent.cs new file mode 100644 index 0000000..d783a4d --- /dev/null +++ b/src/AppLens.Backend/BlackboardEvent.cs @@ -0,0 +1,188 @@ +using System.Text.Json.Serialization; + +namespace AppLens.Backend; + +public sealed class BlackboardEvent +{ + public string SchemaVersion { get; init; } = "1.0"; + public string EventId { get; init; } = $"evt-{Guid.NewGuid():N}"; + public BlackboardEventType EventType { get; init; } + public string ParticipantIdentity { get; init; } = "applens-desktop"; + public BlackboardParticipantKind ParticipantKind { get; init; } = BlackboardParticipantKind.FirstPartyModule; + public string ModuleId { get; init; } = ""; + public string AppId { get; init; } = "applens-desktop"; + public string ScopeId { get; init; } = "local_workstation"; + public string? GrantId { get; init; } + public string CorrelationId { get; init; } = $"corr-{Guid.NewGuid():N}"; + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now; + public BlackboardLifecycleState LifecycleState { get; init; } = BlackboardLifecycleState.Created; + public BlackboardDataState DataState { get; init; } = BlackboardDataState.Validated; + public BlackboardPrivacyState PrivacyState { get; init; } = BlackboardPrivacyState.RawPrivate; + public string Summary { get; init; } = ""; + public Dictionary Payload { get; init; } = []; + public List ArtifactRefs { get; init; } = []; + public BlackboardProvenance Provenance { get; init; } = new(); + public BlackboardPolicyResult? PolicyResult { get; init; } + public string? CanonicalHash { get; init; } + public string? SignerKeyId { get; init; } + public string? Signature { get; init; } + + public static BlackboardEvent ForScanCompleted(AuditSnapshot snapshot, string? correlationId = null) => + new() + { + EventType = BlackboardEventType.ScanCompleted, + ModuleId = "report", + CorrelationId = correlationId ?? $"corr-scan-{Guid.NewGuid():N}", + CreatedAt = snapshot.GeneratedAt, + Summary = $"Scan completed with readiness {snapshot.Readiness.Score}/100 {snapshot.Readiness.Rating}.", + Payload = new Dictionary + { + ["readiness_score"] = snapshot.Readiness.Score.ToString(), + ["readiness_rating"] = snapshot.Readiness.Rating, + ["finding_count"] = snapshot.Findings.Count.ToString(), + ["tune_plan_count"] = snapshot.TunePlan.Count.ToString(), + ["review_count"] = snapshot.Readiness.ReviewCount.ToString(), + ["optional_count"] = snapshot.Readiness.OptionalCount.ToString(), + ["admin_required_count"] = snapshot.Readiness.AdminRequiredCount.ToString() + }, + Provenance = new BlackboardProvenance + { + Source = "AuditService.RunAsync", + Tool = "AppLens", + ToolVersion = typeof(AuditService).Assembly.GetName().Version?.ToString() ?? "unknown" + } + }; + + public static BlackboardEvent ForTuneAction(TuneActionRecord action, string? correlationId = null) + { + var allowed = action.Status == TuneActionStatus.Succeeded; + return new BlackboardEvent + { + EventType = BlackboardEventType.TuneActionCompleted, + ModuleId = "tune", + CorrelationId = correlationId ?? $"corr-tune-{Guid.NewGuid():N}", + CreatedAt = action.CompletedAt, + DataState = action.Status switch + { + TuneActionStatus.Succeeded => BlackboardDataState.Validated, + TuneActionStatus.Blocked => BlackboardDataState.Blocked, + TuneActionStatus.Failed => BlackboardDataState.Invalidated, + TuneActionStatus.RolledBack => BlackboardDataState.Invalidated, + _ => BlackboardDataState.Blocked + }, + Summary = $"Tune action {action.Kind} for {action.Target} ended with {action.Status}.", + Payload = new Dictionary + { + ["action_id"] = action.Id, + ["plan_item_id"] = action.PlanItemId, + ["kind"] = action.Kind.ToString(), + ["status"] = action.Status.ToString(), + ["target"] = action.Target, + ["message"] = action.Message, + ["started_at"] = action.StartedAt.ToString("O"), + ["completed_at"] = action.CompletedAt.ToString("O") + }, + Provenance = new BlackboardProvenance + { + Source = "TuneActionExecutor.ExecuteAsync", + Tool = "AppLens-Tune", + ToolVersion = typeof(TuneActionExecutor).Assembly.GetName().Version?.ToString() ?? "unknown" + }, + PolicyResult = new BlackboardPolicyResult + { + Allowed = allowed, + BlockedReason = allowed ? "" : action.Message, + RequiresApproval = true, + RequiresAdmin = action.RequiresAdmin, + RiskLevel = action.RequiresAdmin ? "medium" : "low", + PolicyId = "applens-tune-v1" + } + }; + } +} + +public sealed class BlackboardArtifactRef +{ + public string Path { get; init; } = ""; + public string Kind { get; init; } = ""; + public string PrivacyState { get; init; } = "raw_private"; +} + +public sealed class BlackboardProvenance +{ + public string Source { get; init; } = ""; + public string Tool { get; init; } = ""; + public string ToolVersion { get; init; } = ""; + public string Command { get; init; } = ""; + public string NodeId { get; init; } = Environment.MachineName; + public string ModelId { get; init; } = ""; + public List InputRefs { get; init; } = []; +} + +public sealed class BlackboardPolicyResult +{ + public bool Allowed { get; init; } + public string BlockedReason { get; init; } = ""; + public bool RequiresApproval { get; init; } + public bool RequiresAdmin { get; init; } + public string RiskLevel { get; init; } = "low"; + public string PolicyId { get; init; } = ""; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BlackboardEventType +{ + CapabilityObserved, + EvidenceCaptured, + ReportGenerated, + ActionProposed, + ActionApproved, + ActionExecuted, + VerificationRecorded, + ModelRunRecorded, + BlockedState, + ScanCompleted, + TuneActionCompleted +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BlackboardParticipantKind +{ + FirstPartyModule, + NodeAgent, + McpApp, + Connector, + HumanOperator, + System, + ThirdPartyAgent +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BlackboardLifecycleState +{ + Created, + Imported, + Validated, + UsedInReport, + Invalidated, + Expired +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BlackboardDataState +{ + Validated, + Fixture, + Stale, + Blocked, + Invalidated, + Unavailable +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BlackboardPrivacyState +{ + RawPrivate, + Sanitized, + Exportable +} diff --git a/src/AppLens.Backend/BlackboardStore.cs b/src/AppLens.Backend/BlackboardStore.cs new file mode 100644 index 0000000..63e414f --- /dev/null +++ b/src/AppLens.Backend/BlackboardStore.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Sqlite; + +namespace AppLens.Backend; + +public interface IBlackboardStore +{ + Task AppendAsync(BlackboardEvent evt, CancellationToken cancellationToken = default); + + Task> ReadAllAsync(CancellationToken cancellationToken = default); + + Task GetIndexedEventCountAsync(CancellationToken cancellationToken = default); +} + +public sealed class BlackboardStore : IBlackboardStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly AppLensRuntimeStorage _storage; + + public BlackboardStore(AppLensRuntimeStorage storage) + { + _storage = storage; + } + + public async Task AppendAsync(BlackboardEvent evt, CancellationToken cancellationToken = default) + { + Directory.CreateDirectory(_storage.LedgerDirectory); + await File.AppendAllTextAsync( + _storage.EventsJsonl, + SerializeEvent(evt) + Environment.NewLine, + cancellationToken).ConfigureAwait(false); + + await IndexAsync(evt, cancellationToken).ConfigureAwait(false); + } + + public async Task> ReadAllAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_storage.EventsJsonl)) + { + return []; + } + + var events = new List(); + foreach (var line in await File.ReadAllLinesAsync(_storage.EventsJsonl, cancellationToken).ConfigureAwait(false)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + var evt = JsonSerializer.Deserialize(line, SerializerOptions); + if (evt is not null) + { + events.Add(evt); + } + } + catch (JsonException) + { + // Keep the append log readable even if a partial write or manual edit corrupts one row. + } + } + + return events; + } + + public static string SerializeEvent(BlackboardEvent evt) => + JsonSerializer.Serialize(evt, SerializerOptions); + + public async Task GetIndexedEventCountAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_storage.IndexSqlite)) + { + return 0; + } + + using var connection = new SqliteConnection($"Data Source={_storage.IndexSqlite};Pooling=False"); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); + + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM events"; + var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return Convert.ToInt32(result); + } + + private async Task IndexAsync(BlackboardEvent evt, CancellationToken cancellationToken) + { + Directory.CreateDirectory(_storage.LedgerDirectory); + using var connection = new SqliteConnection($"Data Source={_storage.IndexSqlite};Pooling=False"); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); + + using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT OR REPLACE INTO events ( + event_id, + schema_version, + event_type, + participant_identity, + participant_kind, + module_id, + app_id, + scope_id, + correlation_id, + created_at, + lifecycle_state, + data_state, + privacy_state, + summary + ) + VALUES ( + $event_id, + $schema_version, + $event_type, + $participant_identity, + $participant_kind, + $module_id, + $app_id, + $scope_id, + $correlation_id, + $created_at, + $lifecycle_state, + $data_state, + $privacy_state, + $summary + ) + """; + command.Parameters.AddWithValue("$event_id", evt.EventId); + command.Parameters.AddWithValue("$schema_version", evt.SchemaVersion); + command.Parameters.AddWithValue("$event_type", evt.EventType.ToString()); + command.Parameters.AddWithValue("$participant_identity", evt.ParticipantIdentity); + command.Parameters.AddWithValue("$participant_kind", evt.ParticipantKind.ToString()); + command.Parameters.AddWithValue("$module_id", evt.ModuleId); + command.Parameters.AddWithValue("$app_id", evt.AppId); + command.Parameters.AddWithValue("$scope_id", evt.ScopeId); + command.Parameters.AddWithValue("$correlation_id", evt.CorrelationId); + command.Parameters.AddWithValue("$created_at", evt.CreatedAt.ToString("O")); + command.Parameters.AddWithValue("$lifecycle_state", evt.LifecycleState.ToString()); + command.Parameters.AddWithValue("$data_state", evt.DataState.ToString()); + command.Parameters.AddWithValue("$privacy_state", evt.PrivacyState.ToString()); + command.Parameters.AddWithValue("$summary", evt.Summary); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TABLE IF NOT EXISTS events ( + event_id TEXT PRIMARY KEY, + schema_version TEXT NOT NULL, + event_type TEXT NOT NULL, + participant_identity TEXT NOT NULL, + participant_kind TEXT NOT NULL, + module_id TEXT NOT NULL, + app_id TEXT NOT NULL, + scope_id TEXT NOT NULL, + correlation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + data_state TEXT NOT NULL, + privacy_state TEXT NOT NULL, + summary TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_events_module_created ON events(module_id, created_at); + CREATE INDEX IF NOT EXISTS idx_events_type_created ON events(event_type, created_at); + CREATE INDEX IF NOT EXISTS idx_events_data_state ON events(data_state); + """; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/AppLens.Backend/Models.cs b/src/AppLens.Backend/Models.cs index e140fdf..164d262 100644 --- a/src/AppLens.Backend/Models.cs +++ b/src/AppLens.Backend/Models.cs @@ -12,6 +12,7 @@ public sealed class AuditSnapshot public ReadinessSummary Readiness { get; init; } = new(); public List Findings { get; init; } = []; public List TunePlan { get; init; } = []; + public List ActionLog { get; init; } = []; public List ProbeStatuses { get; init; } = []; } @@ -178,9 +179,25 @@ public sealed class ProposedAction public ProposedActionKind Kind { get; init; } = ProposedActionKind.None; public TunePlanExecutionState ExecutionState { get; init; } = TunePlanExecutionState.ReadOnlyOnly; public string Target { get; init; } = ""; + public string TargetContext { get; init; } = ""; public string Description { get; init; } = ""; } +public sealed class TuneActionRecord +{ + public string Id { get; init; } = ""; + public string PlanItemId { get; init; } = ""; + public ProposedActionKind Kind { get; init; } = ProposedActionKind.None; + public TuneActionStatus Status { get; init; } = TuneActionStatus.Blocked; + public string Target { get; init; } = ""; + public string Message { get; init; } = ""; + public string BackupDetail { get; init; } = ""; + public string VerificationStep { get; init; } = ""; + public DateTimeOffset StartedAt { get; init; } = DateTimeOffset.Now; + public DateTimeOffset CompletedAt { get; init; } = DateTimeOffset.Now; + public bool RequiresAdmin { get; init; } +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum TunePlanCategory { @@ -206,11 +223,13 @@ public enum ProposedActionKind { None, DisableStartup, + EnableStartup, SetServiceManual, StopService, ClearRebuildableCache, UninstallApplication, MoveRepo, + RunLocalAiBenchmark, ManualReview } @@ -220,7 +239,22 @@ public enum TunePlanExecutionState ReadOnlyOnly, FutureUserConsent, FutureAdminRequired, - Unsupported + Unsupported, + ReadyToRun, + RequiresUserConsent, + RequiresAdmin, + Completed, + Failed, + RolledBack +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TuneActionStatus +{ + Succeeded, + Blocked, + Failed, + RolledBack } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/AppLens.Backend/ModuleManifest.cs b/src/AppLens.Backend/ModuleManifest.cs new file mode 100644 index 0000000..7843c1c --- /dev/null +++ b/src/AppLens.Backend/ModuleManifest.cs @@ -0,0 +1,35 @@ +namespace AppLens.Backend; + +public sealed class ModuleManifest +{ + public string AppId { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string ModuleId { get; init; } = ""; + public string Version { get; init; } = "1.0"; + public string RiskLevel { get; init; } = "low"; + public List Capabilities { get; init; } = []; + public List Entrypoints { get; init; } = []; + public List DataContracts { get; init; } = []; + public List ActionContracts { get; init; } = []; + public List Privacy { get; init; } = []; + public string StatusContract { get; init; } = ""; + public List ReportRoots { get; init; } = []; +} + +public sealed class ModuleStatus +{ + public string ModuleId { get; init; } = ""; + public string AppId { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public ModuleAvailability Availability { get; init; } = ModuleAvailability.Blocked; + public string Reason { get; init; } = ""; + public string ExpectedSource { get; init; } = ""; + public string NextAction { get; init; } = ""; +} + +public enum ModuleAvailability +{ + Available, + Blocked, + Unavailable +} diff --git a/src/AppLens.Backend/ModuleStatusService.cs b/src/AppLens.Backend/ModuleStatusService.cs new file mode 100644 index 0000000..b340e03 --- /dev/null +++ b/src/AppLens.Backend/ModuleStatusService.cs @@ -0,0 +1,191 @@ +namespace AppLens.Backend; + +public sealed class ModuleStatusPaths +{ + public string AppLensLlmRoot { get; init; } = ConfiguredPath("APPLENS_LLM_ROOT", DefaultProjectPath("AppLens-LLM")); + public string OracleRoot { get; init; } = DefaultOraclePath(); + public string MailboxRoot { get; init; } = ConfiguredPath("APPLENS_MAILBOX_ROOT", DefaultProjectPath("Mailbox")); + public string AppLensZeroRoot { get; init; } = ConfiguredPath("APPLENS_ZERO_ROOT", DefaultProjectPath("AppLens-Zero")); + + private static string DefaultProjectPath(string projectName) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Desktop", + "csiOS", + "Projects", + projectName); + } + + private static string DefaultOraclePath() + { + return ConfiguredPath( + "APPLENS_ORACLE_ROOT", + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Desktop", + "csiOS", + "Oracle")); + } + + private static string ConfiguredPath(string environmentVariable, string fallback) + { + var configured = Environment.GetEnvironmentVariable(environmentVariable); + if (!string.IsNullOrWhiteSpace(configured)) + { + return configured; + } + + return fallback; + } +} + +public sealed class ModuleStatusService +{ + private readonly ModuleStatusPaths _paths; + + public ModuleStatusService(ModuleStatusPaths? paths = null) + { + _paths = paths ?? new ModuleStatusPaths(); + } + + public List GetStatuses() => + [ + LlmStatus(), + OracleStatus(), + MailboxStatus(), + ZeroStatus() + ]; + + public List GetManifests() => + [ + new ModuleManifest + { + AppId = "applens-llm", + DisplayName = "AppLens-LLM", + ModuleId = "llm", + RiskLevel = "medium", + Capabilities = ["lane-status", "scorecard-import", "fit-report-import"], + Entrypoints = ["status_command", "run_job", "import_reports"], + DataContracts = ["runtime-lanes", "blackboard-record", "model-fit-scorecard", "fit-report", "benchmark-record"], + ActionContracts = ["bounded-local-command"], + Privacy = ["raw_private", "sanitized", "exportable"], + StatusContract = "file-only" + }, + new ModuleManifest + { + AppId = "oracle-workbench", + DisplayName = "Oracle", + ModuleId = "oracle", + RiskLevel = "medium", + Capabilities = ["research-status", "read-only-job", "report-import"], + Entrypoints = ["status_command", "run_job", "import_reports"], + DataContracts = ["research-report", "run-record", "data-provenance"], + ActionContracts = ["read-only-research-job"], + Privacy = ["raw_private", "exportable", "invalidated"], + StatusContract = "file-only" + }, + new ModuleManifest + { + AppId = "mailbox", + DisplayName = "Mailbox", + ModuleId = "mailbox", + RiskLevel = "medium", + Capabilities = ["open_ui", "status_http", "folder-status"], + Entrypoints = ["open_ui", "status_http"], + DataContracts = ["filesystem-mailbox", "markdown-message", "attachment-artifact"], + ActionContracts = ["open-only-v1"], + Privacy = ["raw_private", "sanitized"], + StatusContract = "file-or-http-status" + }, + new ModuleManifest + { + AppId = "applens-zero", + DisplayName = "AppLens-Zero", + ModuleId = "zero", + RiskLevel = "high", + Capabilities = ["lab-readiness", "passive-import", "authorized-session"], + Entrypoints = ["import_reports"], + DataContracts = ["lab-authorization", "edge-device-profile", "capture-readiness"], + ActionContracts = ["benign-check-only"], + Privacy = ["raw_private", "sanitized"], + StatusContract = "file-only" + } + ]; + + private ModuleStatus LlmStatus() + { + var pyproject = Path.Combine(_paths.AppLensLlmRoot, "pyproject.toml"); + var cli = Path.Combine(_paths.AppLensLlmRoot, "src", "applens_llm", "cli.py"); + if (!File.Exists(pyproject) || !File.Exists(cli)) + { + return Blocked("llm", "applens-llm", "AppLens-LLM", "AppLens-LLM package path is unavailable.", _paths.AppLensLlmRoot); + } + + return Available("llm", "applens-llm", "AppLens-LLM", "Package and CLI source detected."); + } + + private ModuleStatus OracleStatus() + { + var pyproject = Path.Combine(_paths.OracleRoot, "pyproject.toml"); + if (!File.Exists(pyproject)) + { + return Blocked("oracle", "oracle-workbench", "Oracle", "Oracle repo or pyproject.toml is unavailable.", _paths.OracleRoot); + } + + return Available("oracle", "oracle-workbench", "Oracle", "Repo and CLI project detected."); + } + + private ModuleStatus MailboxStatus() + { + var config = Path.Combine(_paths.MailboxRoot, "config.toml"); + var server = Path.Combine(_paths.MailboxRoot, "mailbox", "server.py"); + if (!File.Exists(server)) + { + return Blocked("mailbox", "mailbox", "Mailbox", "Mailbox server source is unavailable.", _paths.MailboxRoot); + } + + if (!File.Exists(config)) + { + return Blocked("mailbox", "mailbox", "Mailbox", "Mailbox config.toml is unavailable.", _paths.MailboxRoot); + } + + return Available("mailbox", "mailbox", "Mailbox", "Config and server source detected."); + } + + private ModuleStatus ZeroStatus() + { + var docs = Path.Combine(_paths.AppLensZeroRoot, "docs", "AppLens-Platform-Host-Design.md"); + var raw = Path.Combine(_paths.AppLensZeroRoot, "raw"); + if (!File.Exists(docs) || !Directory.Exists(raw)) + { + return Blocked("zero", "applens-zero", "AppLens-Zero", "Zero docs or import raw folder is unavailable.", _paths.AppLensZeroRoot); + } + + return Available("zero", "applens-zero", "AppLens-Zero", "Docs and raw import folder detected."); + } + + private static ModuleStatus Available(string moduleId, string appId, string displayName, string reason) => + new() + { + ModuleId = moduleId, + AppId = appId, + DisplayName = displayName, + Availability = ModuleAvailability.Available, + Reason = reason, + ExpectedSource = "", + NextAction = "Review module status in AppLens." + }; + + private static ModuleStatus Blocked(string moduleId, string appId, string displayName, string reason, string expectedSource) => + new() + { + ModuleId = moduleId, + AppId = appId, + DisplayName = displayName, + Availability = ModuleAvailability.Blocked, + Reason = reason, + ExpectedSource = expectedSource, + NextAction = "Connect or configure the local app path before running jobs." + }; +} diff --git a/src/AppLens.Backend/RuntimeStorage.cs b/src/AppLens.Backend/RuntimeStorage.cs new file mode 100644 index 0000000..1acb357 --- /dev/null +++ b/src/AppLens.Backend/RuntimeStorage.cs @@ -0,0 +1,36 @@ +namespace AppLens.Backend; + +public sealed class AppLensRuntimeStorage +{ + private AppLensRuntimeStorage(string root) + { + Root = Path.GetFullPath(root); + LedgerDirectory = Path.Combine(Root, "ledger"); + EventsJsonl = Path.Combine(LedgerDirectory, "events.jsonl"); + IndexSqlite = Path.Combine(LedgerDirectory, "index.sqlite"); + } + + public string Root { get; } + + public string LedgerDirectory { get; } + + public string EventsJsonl { get; } + + public string IndexSqlite { get; } + + public static AppLensRuntimeStorage Default() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return FromRoot(Path.Combine(localAppData, "AppLens")); + } + + public static AppLensRuntimeStorage FromRoot(string root) + { + if (string.IsNullOrWhiteSpace(root)) + { + throw new ArgumentException("Runtime storage root is required.", nameof(root)); + } + + return new AppLensRuntimeStorage(root); + } +} diff --git a/src/AppLens.Backend/TuneActionExecutor.cs b/src/AppLens.Backend/TuneActionExecutor.cs new file mode 100644 index 0000000..53e9c2c --- /dev/null +++ b/src/AppLens.Backend/TuneActionExecutor.cs @@ -0,0 +1,383 @@ +using System.Diagnostics; +using System.Security.Principal; +using System.ServiceProcess; +using Microsoft.Win32; + +namespace AppLens.Backend; + +public sealed class TuneActionExecutor +{ + private readonly ITuneActionRuntime _runtime; + + public TuneActionExecutor() + : this(new WindowsTuneActionRuntime()) + { + } + + public TuneActionExecutor(ITuneActionRuntime runtime) + { + _runtime = runtime; + } + + public async Task ExecuteAsync( + TunePlanItem item, + bool userApproved, + CancellationToken cancellationToken = default) + { + var startedAt = DateTimeOffset.Now; + var action = item.ProposedAction; + + if (!IsExecutable(action.ExecutionState, action.Kind)) + { + return Record(item, TuneActionStatus.Blocked, startedAt, "This tune-plan item is not executable in this build."); + } + + if (!userApproved) + { + return Record(item, TuneActionStatus.Blocked, startedAt, "Action blocked because Tune consent was not granted."); + } + + if ((item.RequiresAdmin || RequiresAdmin(action.ExecutionState)) && !_runtime.IsAdministrator) + { + return Record(item, TuneActionStatus.Blocked, startedAt, "Action requires an elevated AppLens-Tune session."); + } + + try + { + return action.Kind switch + { + ProposedActionKind.ClearRebuildableCache => await ClearRebuildableCacheAsync(item, startedAt, cancellationToken) + .ConfigureAwait(false), + ProposedActionKind.SetServiceManual => await SetServiceManualAsync(item, startedAt, cancellationToken) + .ConfigureAwait(false), + ProposedActionKind.StopService => await StopServiceAsync(item, startedAt, cancellationToken) + .ConfigureAwait(false), + ProposedActionKind.DisableStartup => await DisableStartupAsync(item, startedAt, cancellationToken) + .ConfigureAwait(false), + ProposedActionKind.EnableStartup => await EnableStartupAsync(item, startedAt, cancellationToken) + .ConfigureAwait(false), + _ => Record(item, TuneActionStatus.Blocked, startedAt, "This action kind is not executable in this build.") + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Record(item, TuneActionStatus.Failed, startedAt, ex.Message); + } + } + + private async Task ClearRebuildableCacheAsync( + TunePlanItem item, + DateTimeOffset startedAt, + CancellationToken cancellationToken) + { + var path = item.ProposedAction.Target; + if (!ActionTargetPolicy.IsClearableCacheTarget(path)) + { + return Record(item, TuneActionStatus.Blocked, startedAt, "Cache cleanup target is outside AppLens-Tune's allowlist."); + } + + var bytesDeleted = await _runtime.ClearDirectoryContentsAsync(path, cancellationToken).ConfigureAwait(false); + return Record(item, TuneActionStatus.Succeeded, startedAt, $"Cleared {Formatting.Size(bytesDeleted)} from rebuildable cache contents."); + } + + private async Task SetServiceManualAsync( + TunePlanItem item, + DateTimeOffset startedAt, + CancellationToken cancellationToken) + { + await _runtime.SetServiceStartModeManualAsync(item.ProposedAction.Target, cancellationToken).ConfigureAwait(false); + return Record(item, TuneActionStatus.Succeeded, startedAt, "Service start mode was set to Manual."); + } + + private async Task StopServiceAsync( + TunePlanItem item, + DateTimeOffset startedAt, + CancellationToken cancellationToken) + { + await _runtime.StopServiceAsync(item.ProposedAction.Target, cancellationToken).ConfigureAwait(false); + return Record(item, TuneActionStatus.Succeeded, startedAt, "Service was stopped."); + } + + private async Task DisableStartupAsync( + TunePlanItem item, + DateTimeOffset startedAt, + CancellationToken cancellationToken) + { + await _runtime.DisableStartupEntryAsync( + item.ProposedAction.Target, + item.ProposedAction.TargetContext, + cancellationToken) + .ConfigureAwait(false); + return Record(item, TuneActionStatus.Succeeded, startedAt, "Startup entry was disabled."); + } + + private async Task EnableStartupAsync( + TunePlanItem item, + DateTimeOffset startedAt, + CancellationToken cancellationToken) + { + await _runtime.EnableStartupEntryAsync( + item.ProposedAction.Target, + item.ProposedAction.TargetContext, + cancellationToken) + .ConfigureAwait(false); + return Record(item, TuneActionStatus.Succeeded, startedAt, "Startup entry was enabled."); + } + + private static TuneActionRecord Record( + TunePlanItem item, + TuneActionStatus status, + DateTimeOffset startedAt, + string message) + { + return new TuneActionRecord + { + Id = Guid.NewGuid().ToString("N"), + PlanItemId = item.Id, + Kind = item.ProposedAction.Kind, + Status = status, + Target = item.ProposedAction.Target, + Message = message, + BackupDetail = item.BackupPlan, + VerificationStep = item.VerificationStep, + StartedAt = startedAt, + CompletedAt = DateTimeOffset.Now, + RequiresAdmin = item.RequiresAdmin || RequiresAdmin(item.ProposedAction.ExecutionState) + }; + } + + private static bool IsExecutable(TunePlanExecutionState state, ProposedActionKind kind) => + kind is not ProposedActionKind.None and not ProposedActionKind.ManualReview and not ProposedActionKind.MoveRepo and not ProposedActionKind.UninstallApplication && + state is TunePlanExecutionState.ReadyToRun + or TunePlanExecutionState.RequiresUserConsent + or TunePlanExecutionState.RequiresAdmin; + + private static bool RequiresAdmin(TunePlanExecutionState state) => + state is TunePlanExecutionState.RequiresAdmin or TunePlanExecutionState.FutureAdminRequired; +} + +public interface ITuneActionRuntime +{ + bool IsAdministrator { get; } + + Task ClearDirectoryContentsAsync(string path, CancellationToken cancellationToken = default); + + Task SetServiceStartModeManualAsync(string serviceName, CancellationToken cancellationToken = default); + + Task StopServiceAsync(string serviceName, CancellationToken cancellationToken = default); + + Task DisableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default); + + Task EnableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default); +} + +public sealed class WindowsTuneActionRuntime : ITuneActionRuntime +{ + public bool IsAdministrator + { + get + { + using var identity = WindowsIdentity.GetCurrent(); + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + } + + public Task ClearDirectoryContentsAsync(string path, CancellationToken cancellationToken = default) => + Task.Run(() => + { + if (!Directory.Exists(path)) + { + return 0L; + } + + var bytes = 0L; + foreach (var file in Directory.EnumerateFiles(path)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + bytes += new FileInfo(file).Length; + File.Delete(file); + } + catch + { + // Keep cleanup best-effort; inaccessible files should not abort the whole tune run. + } + } + + foreach (var directory in Directory.EnumerateDirectories(path)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var info = new DirectoryInfo(directory); + if ((info.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + { + continue; + } + + bytes += DirectorySize(directory, cancellationToken); + Directory.Delete(directory, recursive: true); + } + catch + { + // Keep cleanup best-effort for locked cache trees. + } + } + + return bytes; + }, cancellationToken); + + public async Task SetServiceStartModeManualAsync(string serviceName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(serviceName)) + { + throw new InvalidOperationException("Service name is missing."); + } + + using var process = new Process + { + StartInfo = new ProcessStartInfo("sc.exe") + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + process.StartInfo.ArgumentList.Add("config"); + process.StartInfo.ArgumentList.Add(serviceName); + process.StartInfo.ArgumentList.Add("start="); + process.StartInfo.ArgumentList.Add("demand"); + + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) ? output.Trim() : error.Trim()); + } + } + + public Task StopServiceAsync(string serviceName, CancellationToken cancellationToken = default) => + Task.Run(() => + { + using var service = new ServiceController(serviceName); + if (service.Status == ServiceControllerStatus.Stopped) + { + return; + } + + if (!service.CanStop) + { + throw new InvalidOperationException("Service does not report that it can be stopped."); + } + + service.Stop(stopDependentServices: false); + service.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(20)); + }, cancellationToken); + + public Task DisableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default) => + SetStartupEntryStateAsync(entryName, location, enabled: false, cancellationToken); + + public Task EnableStartupEntryAsync(string entryName, string location, CancellationToken cancellationToken = default) => + SetStartupEntryStateAsync(entryName, location, enabled: true, cancellationToken); + + private static Task SetStartupEntryStateAsync(string entryName, string location, bool enabled, CancellationToken cancellationToken = default) => + Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(entryName)) + { + throw new InvalidOperationException("Startup entry name is missing."); + } + + var root = location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase) + ? Registry.LocalMachine + : Registry.CurrentUser; + + foreach (var path in new[] + { + @"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run", + @"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32" + }) + { + using var key = root.OpenSubKey(path, writable: true); + if (key?.GetValue(entryName) is not byte[] bytes || bytes.Length == 0) + { + continue; + } + + bytes[0] = enabled ? (byte)0x02 : (byte)0x03; + key.SetValue(entryName, bytes, RegistryValueKind.Binary); + return; + } + + throw new InvalidOperationException("Startup approval entry was not found."); + }, cancellationToken); + + private static long DirectorySize(string root, CancellationToken cancellationToken) + { + var bytes = 0L; + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + bytes += new FileInfo(file).Length; + } + catch + { + // Ignore inaccessible cache files while estimating cleanup. + } + } + + return bytes; + } +} + +public static class ActionTargetPolicy +{ + public static bool IsClearableCacheTarget(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var fullPath = Normalize(path); + return ClearableCacheRoots().Any(root => IsSameOrChild(fullPath, Normalize(root))); + } + + private static IEnumerable ClearableCacheRoots() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + + yield return Path.GetTempPath(); + yield return Path.Combine(localAppData, "Temp"); + yield return Path.Combine(localAppData, "pip", "Cache"); + yield return Path.Combine(localAppData, "NuGet", "Cache"); + yield return Path.Combine(localAppData, "uv", "cache"); + yield return Path.Combine(localAppData, "Yarn", "Cache"); + yield return Path.Combine(appData, "npm-cache"); + yield return Path.Combine(appData, "Code", "Cache"); + yield return Path.Combine(programData, "chocolatey", "lib-bad"); + } + + private static bool IsSameOrChild(string path, string root) + { + var normalizedPath = EnsureTrailingSeparator(path); + var normalizedRoot = EnsureTrailingSeparator(root); + return normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase); + } + + private static string Normalize(string path) => + Path.GetFullPath(Environment.ExpandEnvironmentVariables(path)); + + private static string EnsureTrailingSeparator(string path) => + path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar; +} diff --git a/src/AppLens.Backend/TunePlanBuilder.cs b/src/AppLens.Backend/TunePlanBuilder.cs index 7c4773e..9f614ed 100644 --- a/src/AppLens.Backend/TunePlanBuilder.cs +++ b/src/AppLens.Backend/TunePlanBuilder.cs @@ -19,7 +19,9 @@ public List Build(AuditSnapshot snapshot) } AddStartupPlanItems(snapshot, items); + AddStartupEntryActionItems(snapshot, items); AddServicePlanItems(snapshot, items); + AddStoragePlanItems(snapshot, items); AddLocalAiPlanItem(snapshot, items); return items @@ -42,9 +44,9 @@ public List Build(AuditSnapshot snapshot) Risk = TunePlanRisk.Info, Title = finding.Title, Evidence = finding.Detail, - Guidance = "Keep AppLens-Tune in read-only mode for V1. Exports remain user-controlled.", - BackupPlan = "No backup needed because no system changes are made.", - VerificationStep = "Confirm the exported report says the scan was read-only.", + Guidance = "Keep scan evidence local and user-controlled. AppLens observes first; AppLens-Tune acts only after explicit consent.", + BackupPlan = "No backup needed for this privacy finding.", + VerificationStep = "Confirm exported reports redact user, machine, and profile-path details unless raw details are selected.", ProposedAction = new ProposedAction { Kind = ProposedActionKind.None, @@ -84,9 +86,9 @@ private static TunePlanItem StartupFinding(Finding finding) ProposedAction = new ProposedAction { Kind = isStable ? ProposedActionKind.None : ProposedActionKind.DisableStartup, - ExecutionState = isStable ? TunePlanExecutionState.ReadOnlyOnly : TunePlanExecutionState.FutureUserConsent, + ExecutionState = isStable ? TunePlanExecutionState.ReadOnlyOnly : TunePlanExecutionState.RequiresUserConsent, Target = finding.Title, - Description = isStable ? "No action." : "Future approved action: disable the startup entry without uninstalling the app." + Description = isStable ? "No action." : "Approved action: disable the startup entry without uninstalling the app." } }; } @@ -100,16 +102,16 @@ private static TunePlanItem ServiceFinding(Finding finding) Risk = TunePlanRisk.Medium, Title = finding.Title, Evidence = finding.Detail, - Guidance = "Review the related service or vendor utility. V1 does not stop services or change start modes.", + Guidance = "Review the related service or vendor utility before changing its baseline start behavior.", RequiresAdmin = true, BackupPlan = "Before any future action, record service name, display name, current status, and start type.", VerificationStep = "After any future approved change, reboot or sign out and confirm the service state and related processes.", ProposedAction = new ProposedAction { Kind = ProposedActionKind.SetServiceManual, - ExecutionState = TunePlanExecutionState.FutureAdminRequired, + ExecutionState = TunePlanExecutionState.RequiresAdmin, Target = finding.Title, - Description = "Future approved action: change selected non-critical services to Manual when an admin permits it." + Description = "Approved admin action: change selected non-critical services to Manual." } }; } @@ -129,9 +131,9 @@ private static TunePlanItem StorageFinding(Finding finding) ProposedAction = new ProposedAction { Kind = ProposedActionKind.ClearRebuildableCache, - ExecutionState = TunePlanExecutionState.FutureUserConsent, + ExecutionState = TunePlanExecutionState.RequiresUserConsent, Target = finding.Title, - Description = "Future approved action: clear only confirmed rebuildable cache locations." + Description = "Approved action: clear only confirmed rebuildable cache locations." } }; } @@ -196,17 +198,74 @@ private static void AddStartupPlanItems(AuditSnapshot snapshot, List items) + { + foreach (var entry in snapshot.Tune.StartupEntries.Where(IsSupportedStartupDisableAction)) + { + var requiresAdmin = entry.Location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase); + items.Add(new TunePlanItem + { + Id = StableId("startup-entry", entry.Name, entry.Location), + Category = requiresAdmin ? TunePlanCategory.AdminRequired : TunePlanCategory.UserChoice, + Risk = TunePlanRisk.Low, + Title = $"Disable startup entry: {entry.Name}", + Evidence = $"{entry.Name} is {entry.State} from {entry.Location}.", + Guidance = "Disable this only when the user confirms the app does not need to start at sign-in.", + RequiresAdmin = requiresAdmin, + BackupPlan = "Record the startup entry name, command, location, and approval state before disabling it.", + VerificationStep = "Re-scan after the next sign-in and confirm the startup entry is disabled.", + ProposedAction = new ProposedAction + { + Kind = ProposedActionKind.DisableStartup, + ExecutionState = requiresAdmin + ? TunePlanExecutionState.RequiresAdmin + : TunePlanExecutionState.RequiresUserConsent, + Target = entry.Name, + TargetContext = entry.Location, + Description = "Approved action: disable this startup entry without uninstalling the app." + } + }); + } + + foreach (var entry in snapshot.Tune.StartupEntries.Where(IsSupportedStartupEnableAction)) + { + var requiresAdmin = entry.Location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase); + items.Add(new TunePlanItem + { + Id = StableId("startup-entry-enable", entry.Name, entry.Location), + Category = requiresAdmin ? TunePlanCategory.AdminRequired : TunePlanCategory.UserChoice, + Risk = TunePlanRisk.Low, + Title = $"Enable startup entry: {entry.Name}", + Evidence = $"{entry.Name} is {entry.State} from {entry.Location}.", + Guidance = "Enable this only when the user confirms the app should start at sign-in.", + RequiresAdmin = requiresAdmin, + BackupPlan = "Record the startup entry name, command, location, and approval state before enabling it.", + VerificationStep = "Re-scan after the next sign-in and confirm the startup entry is enabled.", + ProposedAction = new ProposedAction + { + Kind = ProposedActionKind.EnableStartup, + ExecutionState = requiresAdmin + ? TunePlanExecutionState.RequiresAdmin + : TunePlanExecutionState.RequiresUserConsent, + Target = entry.Name, + TargetContext = entry.Location, + Description = "Approved action: enable this startup entry." + } + }); + } + } + private static void AddServicePlanItems(AuditSnapshot snapshot, List items) { var automaticReviewServices = snapshot.Tune.Services @@ -226,14 +285,41 @@ private static void AddServicePlanItems(AuditSnapshot snapshot, List items) + { + foreach (var hotspot in snapshot.Tune.StorageHotspots.Where(IsClearableCacheHotspot)) + { + items.Add(new TunePlanItem + { + Id = StableId("clear-cache", hotspot.Path), + Category = TunePlanCategory.Optional, + Risk = TunePlanRisk.Low, + Title = $"Clear rebuildable cache: {hotspot.Location}", + Evidence = $"{hotspot.Location} is using {Formatting.Size(hotspot.Bytes)} at {hotspot.Path}.", + Guidance = "Clear this only when the user accepts a rebuildable-cache cleanup. AppLens-Tune deletes contents, not the cache root.", + BackupPlan = "Record the cache path and measured size before cleanup.", + VerificationStep = "Re-scan storage hotspots and confirm the measured cache size changed.", + ProposedAction = new ProposedAction + { + Kind = ProposedActionKind.ClearRebuildableCache, + ExecutionState = TunePlanExecutionState.RequiresUserConsent, + Target = hotspot.Path, + TargetContext = hotspot.Location, + Description = "Approved action: clear this rebuildable cache." } }); } @@ -259,17 +345,54 @@ private static void AddLocalAiPlanItem(AuditSnapshot snapshot, List + !string.IsNullOrWhiteSpace(hotspot.Path) && + (hotspot.Bytes ?? 0) > 0 && + ClearableCacheLocations.Contains(hotspot.Location, StringComparer.OrdinalIgnoreCase); + + private static readonly string[] ClearableCacheLocations = + [ + @"LocalAppData\Temp", + @"LocalAppData\pip\Cache", + @"LocalAppData\NuGet\Cache", + @"LocalAppData\uv\cache", + @"LocalAppData\Yarn\Cache", + @"Roaming\npm-cache", + @"Roaming\Code\Cache", + @"ProgramData\chocolatey\lib-bad" + ]; + private static bool IsEnabled(StartupEntry entry) => entry.State.Equals("Enabled", StringComparison.OrdinalIgnoreCase) || entry.State.Equals("Unknown", StringComparison.OrdinalIgnoreCase); + private static bool IsSupportedStartupDisableAction(StartupEntry entry) => + entry.State.Equals("Enabled", StringComparison.OrdinalIgnoreCase) && + (entry.Location.StartsWith("HKCU", StringComparison.OrdinalIgnoreCase) || + entry.Location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase)) && + !ProtectedStartupEntries.Contains(entry.Name, StringComparer.OrdinalIgnoreCase); + + private static bool IsSupportedStartupEnableAction(StartupEntry entry) => + entry.State.Equals("Disabled", StringComparison.OrdinalIgnoreCase) && + (entry.Location.StartsWith("HKCU", StringComparison.OrdinalIgnoreCase) || + entry.Location.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase)) && + !ProtectedStartupEntries.Contains(entry.Name, StringComparer.OrdinalIgnoreCase); + + private static readonly string[] ProtectedStartupEntries = + [ + "SecurityHealth", + "OneDrive" + ]; + private static bool IsReviewableService(string value) => value.Contains("ASUS", StringComparison.OrdinalIgnoreCase) || value.Contains("GlideX", StringComparison.OrdinalIgnoreCase) || diff --git a/src/AppLens.Desktop/MainWindow.xaml b/src/AppLens.Desktop/MainWindow.xaml index 9ba4aa1..d6e61cf 100644 --- a/src/AppLens.Desktop/MainWindow.xaml +++ b/src/AppLens.Desktop/MainWindow.xaml @@ -18,8 +18,8 @@ - - + +