Skip to content

Commit fd1f775

Browse files
sellakumaranclaude
andauthored
feat: add notice system for CLI security and upgrade announcements (#316)
* feat: add urgent notice system for CLI security and upgrade announcements Introduces a server-driven notice system that displays urgent messages (security advisories, critical upgrade prompts) to users on every CLI invocation, with 4-hour TTL local caching to avoid unnecessary network calls. Notices are suppressed once the user upgrades past the specified minimumVersion. Extracted shared CI/CD detection and version parsing into VersionCheckHelper. Both notice and version checks run with independent 2-second timeouts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address PR #316 review comments - Run notice and version checks concurrently via Task.WhenAll, capping worst-case startup delay at ~2s instead of ~4s - Fix NoticeServiceTests failing in CI: clear all CI env vars before tests that assert HasNotice=true, restore in finally - Fix CA2254 violation: use const string for log separator in Program.cs - Add CHANGELOG.md entry for the notice system feature Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add missing debug log and fix version log argument mismatch - Add 'No active notice' debug log when notice fetch returns empty message - Fix VersionCheckService log bug: 'Running latest version' was logging latestVersion instead of _currentVersion due to shared argument list across ternary log templates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d2ba4e9 commit fd1f775

12 files changed

Lines changed: 675 additions & 164 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
77
## [Unreleased]
88

99
### Added
10+
- Server-driven notice system: security advisories and critical upgrade prompts are displayed at startup when a maintainer updates `notices.json`. Notices are suppressed once the user upgrades past the specified `minimumVersion`. Results are cached locally for 4 hours to avoid network calls on every invocation.
1011
- `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication
1112
- `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early
1213
### Changed

notices.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"message": "",
3+
"minimumVersion": null,
4+
"expiresAt": "2000-01-01T00:00:00Z"
5+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Microsoft.Agents.A365.DevTools.Cli.Models;
5+
6+
/// <summary>
7+
/// Notice fetched from the server-side notices endpoint.
8+
/// All fields are nullable — an empty or partial response means no active notice.
9+
/// </summary>
10+
public record Notice(
11+
string? Message,
12+
string? MinimumVersion,
13+
DateTimeOffset? ExpiresAt);
14+
15+
/// <summary>
16+
/// Result of a notice check — what the caller acts on.
17+
/// </summary>
18+
public record NoticeResult(bool HasNotice, string? Message, string? UpdateCommand);
19+
20+
/// <summary>
21+
/// On-disk cache envelope for the notice, keyed by fetch timestamp.
22+
/// </summary>
23+
public record NoticeCache(DateTimeOffset CachedAt, Notice? ActiveNotice);

src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ public record VersionCheckResult(
1616
string? CurrentVersion,
1717
string? LatestVersion,
1818
string? UpdateCommand);
19+
20+
/// <summary>
21+
/// On-disk cache envelope for the version check result, keyed by fetch timestamp.
22+
/// </summary>
23+
public record VersionCheckCache(DateTimeOffset CachedAt, string? LatestVersion);

src/Microsoft.Agents.A365.DevTools.Cli/Program.cs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,51 @@ static async Task<int> Main(string[] args)
4848
ConfigureServices(services, logLevel, logFilePath);
4949
var serviceProvider = services.BuildServiceProvider();
5050

51-
// Check for updates (non-blocking, with timeout)
51+
// Notice and version checks run concurrently — worst-case startup delay is ~2s, not ~4s.
52+
using var noticeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
53+
using var versionCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
54+
55+
var noticeService = serviceProvider.GetRequiredService<INoticeService>();
56+
var versionCheckService = serviceProvider.GetRequiredService<IVersionCheckService>();
57+
58+
var noticeTask = noticeService.CheckForNoticeAsync(noticeCts.Token);
59+
var versionTask = versionCheckService.CheckForUpdatesAsync(versionCts.Token);
60+
61+
await Task.WhenAll(
62+
noticeTask.ContinueWith(_ => { }, TaskContinuationOptions.None),
63+
versionTask.ContinueWith(_ => { }, TaskContinuationOptions.None));
64+
65+
// Display notice result
5266
try
5367
{
54-
var versionCheckService = serviceProvider.GetRequiredService<IVersionCheckService>();
55-
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
56-
var result = await versionCheckService.CheckForUpdatesAsync(cts.Token);
68+
var noticeResult = await noticeTask;
69+
if (noticeResult.HasNotice)
70+
{
71+
const string separator = "------------------------------------------------------------";
72+
startupLogger.LogWarning("");
73+
startupLogger.LogWarning(separator);
74+
startupLogger.LogWarning("URGENT NOTICE");
75+
startupLogger.LogWarning(separator);
76+
startupLogger.LogWarning("{Message}", noticeResult.Message);
77+
startupLogger.LogWarning("");
78+
startupLogger.LogWarning("To update, run: {Command}", noticeResult.UpdateCommand);
79+
startupLogger.LogWarning(separator);
80+
startupLogger.LogWarning("");
81+
}
82+
}
83+
catch (OperationCanceledException)
84+
{
85+
startupLogger.LogDebug("Notice check timed out");
86+
}
87+
catch (Exception ex)
88+
{
89+
startupLogger.LogDebug(ex, "Notice check failed: {Message}", ex.Message);
90+
}
5791

92+
// Display version check result
93+
try
94+
{
95+
var result = await versionTask;
5896
if (result.UpdateAvailable)
5997
{
6098
startupLogger.LogWarning("");
@@ -194,6 +232,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini
194232
services.AddSingleton<AuthenticationService>();
195233
services.AddSingleton<IClientAppValidator, ClientAppValidator>();
196234
services.AddSingleton<IVersionCheckService, VersionCheckService>();
235+
services.AddSingleton<INoticeService, NoticeService>();
197236

198237
// Add Microsoft Agent 365 Tooling Service with environment detection
199238
services.AddSingleton<IAgent365ToolingService>(provider =>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Agents.A365.DevTools.Cli.Models;
5+
6+
namespace Microsoft.Agents.A365.DevTools.Cli.Services;
7+
8+
/// <summary>
9+
/// Service for checking whether an active notice should be shown to the user.
10+
/// </summary>
11+
public interface INoticeService
12+
{
13+
/// <summary>
14+
/// Checks for an active notice from the server-side notices endpoint.
15+
/// Results are cached locally with a TTL to avoid a network call on every invocation.
16+
/// </summary>
17+
/// <param name="cancellationToken">Cancellation token to abort the check.</param>
18+
/// <returns>Result indicating whether there is an active notice to display.</returns>
19+
Task<NoticeResult> CheckForNoticeAsync(CancellationToken cancellationToken = default);
20+
}

src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
namespace Microsoft.Agents.A365.DevTools.Cli.Services;
77

88
/// <summary>
9-
/// Service for checking if newer versions of the CLI are available.
9+
/// Service for checking if a newer version of the CLI is available on NuGet.
1010
/// </summary>
1111
public interface IVersionCheckService
1212
{
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Microsoft.Agents.A365.DevTools.Cli.Services.Internal;
5+
6+
/// <summary>
7+
/// Shared helpers used by both <see cref="Services.VersionCheckService"/> and
8+
/// <see cref="Services.NoticeService"/>. Internal to the assembly.
9+
/// </summary>
10+
internal static class VersionCheckHelper
11+
{
12+
private const string PackageId = "Microsoft.Agents.A365.DevTools.Cli";
13+
14+
/// <summary>
15+
/// Returns true if the current process is running inside a known CI/CD environment.
16+
/// Both version and notice checks are skipped in CI to avoid unnecessary network calls.
17+
/// </summary>
18+
internal static bool IsRunningInCiCd()
19+
{
20+
var ciEnvVars = new[]
21+
{
22+
"CI", // Generic CI indicator
23+
"TF_BUILD", // Azure DevOps
24+
"GITHUB_ACTIONS", // GitHub Actions
25+
"JENKINS_HOME", // Jenkins
26+
"GITLAB_CI", // GitLab CI
27+
"CIRCLECI", // CircleCI
28+
"TRAVIS", // Travis CI
29+
"TEAMCITY_VERSION", // TeamCity
30+
"BUILDKITE", // Buildkite
31+
"CODEBUILD_BUILD_ID" // AWS CodeBuild
32+
};
33+
34+
return ciEnvVars.Any(envVar => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVar)));
35+
}
36+
37+
/// <summary>
38+
/// Parses a semantic version string into a comparable <see cref="Version"/>.
39+
/// Handles formats such as "1.1.52", "1.1.0-preview.50", and "1.1.52-preview".
40+
/// Throws <see cref="FormatException"/> if parsing fails.
41+
/// </summary>
42+
internal static Version ParseVersion(string versionString)
43+
{
44+
var parsed = TryParseVersion(versionString);
45+
if (parsed == null)
46+
throw new FormatException($"Invalid version format: {versionString}");
47+
return parsed;
48+
}
49+
50+
/// <summary>
51+
/// Tries to parse a semantic version string. Returns null on failure.
52+
///
53+
/// Supported formats:
54+
/// - "1.1.52-preview" (iteration in base version number)
55+
/// - "1.1.0-preview.50" (preview number is a separate segment)
56+
/// </summary>
57+
internal static Version? TryParseVersion(string versionString)
58+
{
59+
try
60+
{
61+
// Remove build metadata (+...)
62+
var cleanVersion = versionString.Split('+')[0];
63+
64+
if (cleanVersion.Contains('-'))
65+
{
66+
var parts = cleanVersion.Split('-');
67+
var baseVersion = parts[0]; // e.g., "1.1.52" or "1.1.0"
68+
69+
if (parts.Length > 1)
70+
{
71+
var previewPart = parts[1]; // e.g., "preview" or "preview.50"
72+
73+
// Format: "1.1.0-preview.50" — append preview number as revision
74+
if (previewPart.StartsWith("preview.") && previewPart.Length > 8)
75+
{
76+
var previewNumber = previewPart.Substring(8);
77+
cleanVersion = int.TryParse(previewNumber, out var preview)
78+
? $"{baseVersion}.{preview}"
79+
: baseVersion;
80+
}
81+
else
82+
{
83+
// Format: "1.1.52-preview" — iteration is already in the base number
84+
cleanVersion = baseVersion;
85+
}
86+
}
87+
else
88+
{
89+
cleanVersion = baseVersion;
90+
}
91+
}
92+
93+
// Ensure at least 3 components for the Version constructor
94+
var versionParts = cleanVersion.Split('.');
95+
var componentsNeeded = 3 - versionParts.Length;
96+
for (var i = 0; i < componentsNeeded; i++)
97+
cleanVersion += ".0";
98+
99+
return new Version(cleanVersion);
100+
}
101+
catch
102+
{
103+
return null;
104+
}
105+
}
106+
107+
/// <summary>
108+
/// Returns the appropriate <c>dotnet tool update</c> command for the given version string.
109+
/// Appends <c>--prerelease</c> when the version is a preview build.
110+
/// </summary>
111+
internal static string GetUpdateCommand(string version)
112+
{
113+
var baseCommand = $"dotnet tool update -g {PackageId}";
114+
return version.Contains("preview", StringComparison.OrdinalIgnoreCase)
115+
? $"{baseCommand} --prerelease"
116+
: baseCommand;
117+
}
118+
}

0 commit comments

Comments
 (0)