Skip to content

Commit 78a70e0

Browse files
committed
Handle PR comments
Harden consent URLs, fix resource leaks, improve tests - Replace hardcoded OAuth2 `state` in admin consent URLs with random GUIDs for CSRF protection; centralize URL construction in `SetupHelpers.BuildAdminConsentUrl` - Dispose overwritten `JsonDocument` in `FederatedCredentialService` to prevent resource leaks - Improve retry logic to propagate cancellation immediately on user-initiated cancel (Ctrl+C) - Remove unused CLI option variable (`verbose`) to avoid dead code - Enhance tests: assert random state in consent URLs and add `because:` documentation to clarify test requirements
1 parent 9366a5f commit 78a70e0

9 files changed

Lines changed: 103 additions & 42 deletions

File tree

.claude/agents/pr-code-reviewer.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ For each changed file, analyze:
133133
4. **Resource Management**
134134
- Are IDisposable objects disposed? Are connections/streams closed? Any potential memory leaks?
135135
- **IMPORTANT**: For every `var x = await SomeMethod(...)` in the diff, use `Read` to look up the method's return type in the source file. If the return type implements `IDisposable`, flag missing `using` as a `high` severity `resource_leak`. Do NOT rely on the diff alone — the return type is almost never in the diff.
136+
- **IMPORTANT**: Also scan for `var x = await A(...); if (...) { ... } else { x = await B(...); }` — the first `IDisposable` value is silently leaked when the else-branch overwrites `x`. See Anti-Pattern #13.
136137

137138
5. **Null Safety**
138139
- Potential null reference exceptions?
@@ -515,6 +516,65 @@ A related dead-code smell: mocking `CommandExecutor.ExecuteAsync` to return `"fa
515516
```
516517
- **Note**: `GraphApiServiceTokenCacheTests` is the intentional exception — it owns the cache and manages `AzCliTokenAcquirerOverride` explicitly via setUp/tearDown.
517518

519+
### 12. Retry Loop Catches `TaskCanceledException` Without Early Exit
520+
A catch block that handles `TaskCanceledException` (or `OperationCanceledException`) alongside transient errors and retries all of them equally — so a user pressing Ctrl+C burns through all retry attempts before propagating.
521+
- **Pattern to catch**: `catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)` (or `OperationCanceledException`) inside a retry loop, with no check of `cancellationToken.IsCancellationRequested` before the retry delay
522+
- **Severity**: `high` — Ctrl+C appears to hang for the full retry window; partial state may continue to be applied
523+
- **Fix**:
524+
```csharp
525+
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
526+
{
527+
if (ex is TaskCanceledException && cancellationToken.IsCancellationRequested)
528+
throw; // propagate immediately — do not retry
529+
// ... retry logic ...
530+
}
531+
```
532+
533+
### 13. `IDisposable` Variable Overwritten in Else-Branch Without Prior Disposal
534+
A variable holding an `IDisposable` is overwritten in an else/fallback branch without first disposing the value assigned in the if-branch.
535+
- **Pattern to catch**: `var doc = await Primary(...); if (doc != null && ...) { use doc } else { doc = await Fallback(...); }` where the first `doc` is not disposed before reassignment
536+
- **Severity**: `high` — the primary result leaks on every code path that falls into the else-branch; in high-frequency callers this accumulates
537+
- **Fix**: Dispose explicitly before overwriting, or restructure with separate `using` scopes:
538+
```csharp
539+
var primaryDoc = await Primary(...);
540+
JsonDocument? doc;
541+
if (primaryDoc != null && ...)
542+
{
543+
doc = primaryDoc;
544+
}
545+
else
546+
{
547+
primaryDoc?.Dispose();
548+
doc = await Fallback(...);
549+
}
550+
```
551+
- **Check**: In the diff, for every pattern `var x = ...; if (...) { ... } else { x = ...; }` where the type is `IDisposable`, verify the original value is disposed in the else-branch.
552+
553+
### 14. CLI Option Value Read from `ParseResult` But Never Used in Handler
554+
An option is wired up and parsed but the variable holding its value is never referenced in the handler body — the flag appears in `--help` output but silently has no effect.
555+
- **Pattern to catch**: `var verbose = context.ParseResult.GetValueForOption(verboseOption);` (or any option) with no subsequent reference to `verbose` in the handler lambda
556+
- **Severity**: `medium` — misleads users who pass `--verbose` expecting more output
557+
- **Fix**: Either wire the variable into logging configuration (e.g., adjust log level) or remove the `GetValueForOption` call. Keeping the option declaration is acceptable so it appears in help — just don't claim to read a value you discard.
558+
559+
### 15. Hardcoded OAuth2 `state` Parameter
560+
A fixed string (e.g., `"xyz123"`, `"state"`, `"abc"`) used as the OAuth2 `state` parameter in a consent/authorization URL.
561+
- **Pattern to catch**: `$"&state=xyz123"` or any literal string in an OAuth2 URL `state=` segment
562+
- **Severity**: `medium` — the `state` parameter is designed to be a random nonce for CSRF protection; a hardcoded value eliminates that protection. Even when the URL is only displayed (not automatically followed), it sets a bad precedent and will fail audits.
563+
- **Fix**: Generate a random nonce per URL construction:
564+
```csharp
565+
$"&state={Guid.NewGuid():N}"
566+
```
567+
568+
### 16. Test Assertion Flipped Without `because:` Documenting the Requirement Change
569+
An assertion is changed from one expected value to another (e.g., `BeFalse()``BeTrue()`, `Be("old")``Be("new")`) without a `because:` string explaining what requirement changed.
570+
- **Pattern to catch**: `result.Should().BeTrue()` / `result.Should().BeFalse()` / `result.Should().Be(...)` in the diff (added lines) with no `because:` argument, especially when the surrounding context shows the original assertion had a different expected value
571+
- **Severity**: `medium` — a flipped assertion with no `because:` is indistinguishable from an implementation-tracking change (test updated to match code, not to match the requirement); the next reader cannot know if the behavior change was intentional
572+
- **Fix**: Add `because:` to document the invariant:
573+
```csharp
574+
result.Should().BeTrue(
575+
because: "McpServersMetadata.Read.All is always included even when the manifest is missing, so the method proceeds and returns true");
576+
```
577+
518578
**MANDATORY REPORTING RULE**: Whenever the diff contains any test file (`.Tests.cs`), you MUST emit a named finding for this check — even if no violation is found. The finding must appear in the review output with one of three statuses:
519579
- **`high` severity** if a violation is found (missing warmup, dead executor mock, etc.)
520580
- **`info` — FIXED** if the PR is fixing a prior violation (warmup added to previously-cold classes) — list each class fixed and its measured or estimated speedup

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ public static Command CreateCommand(
101101
command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) =>
102102
{
103103
var config = context.ParseResult.GetValueForOption(configOption)!;
104-
var verbose = context.ParseResult.GetValueForOption(verboseOption);
105104
var dryRun = context.ParseResult.GetValueForOption(dryRunOption);
106105
var skipInfrastructure = context.ParseResult.GetValueForOption(skipInfrastructureOption);
107106
var skipRequirements = context.ParseResult.GetValueForOption(skipRequirementsOption);

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -462,13 +462,7 @@ private static async Task ConfigureOauth2GrantsAsync(
462462
return (true, null);
463463
}
464464

465-
var allScopesEscaped = Uri.EscapeDataString(string.Join(' ', graphScopes));
466-
var consentUrl =
467-
$"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent" +
468-
$"?client_id={blueprintAppId}" +
469-
$"&scope={allScopesEscaped}" +
470-
$"&redirect_uri=https://entra.microsoft.com/TokenAuthorize" +
471-
$"&state=xyz123";
465+
var consentUrl = SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, graphScopes);
472466

473467
// Check if consent already exists for ALL resolved resources (Phase 2 programmatic grants satisfy this check).
474468
// Only skip browser consent if every resource has its consent in place.

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,8 +1459,9 @@ private static List<string> GetApplicationScopes(Models.Agent365Config setupConf
14591459
}
14601460
}
14611461

1462-
var applicationScopesJoined = string.Join(' ', applicationScopes);
1463-
var consentUrlGraph = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope={Uri.EscapeDataString(applicationScopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123";
1462+
var consentUrlGraph = SetupHelpers.BuildAdminConsentUrl(
1463+
tenantId, appId,
1464+
applicationScopes.Select(s => $"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"));
14641465

14651466
if (consentAlreadyExists)
14661467
{

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,19 @@ internal static List<string> PopulateAdminConsentUrls(
249249
return populated;
250250
}
251251

252+
/// <summary>
253+
/// Builds a single /v2.0/adminconsent URL from fully-qualified scope URIs.
254+
/// All callers must pass fully-qualified scopes (e.g. "https://graph.microsoft.com/User.Read").
255+
/// Each scope is individually Uri.EscapeDataString-encoded and joined with %20.
256+
/// A random GUID state parameter is generated for CSRF protection.
257+
/// </summary>
258+
internal static string BuildAdminConsentUrl(string tenantId, string clientId, IEnumerable<string> fullyQualifiedScopes)
259+
{
260+
var scopeParam = string.Join("%20", fullyQualifiedScopes.Select(Uri.EscapeDataString));
261+
var redirectEncoded = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri);
262+
return $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={clientId}&scope={scopeParam}&redirect_uri={redirectEncoded}&state={Guid.NewGuid():N}";
263+
}
264+
252265
/// <summary>
253266
/// Builds per-resource admin consent URLs for all five required resources.
254267
/// Graph and MCP scopes are taken from config; Bot API, Observability, and Power Platform
@@ -261,19 +274,9 @@ internal static List<string> PopulateAdminConsentUrls(
261274
IEnumerable<string> mcpScopes)
262275
{
263276
var urls = new List<(string, string)>();
264-
const string loginBase = "https://login.microsoftonline.com";
265277

266278
static string Build(string tenant, string client, string resourceUri, IEnumerable<string> scopes)
267-
{
268-
// /v2.0/adminconsent requires scope values in the form "<resourceUri>/<scopeName>".
269-
// Each full scope token is Uri.EscapeDataString-encoded and joined with %20 (space).
270-
// redirect_uri must be present and match a URI accepted by AAD for this endpoint.
271-
// Omitting redirect_uri causes AADSTS500113. BlueprintConsentRedirectUri is the
272-
// standard Entra Portal consent redirect URI accepted by AAD for admin consent flows.
273-
var scopeParam = string.Join("%20", scopes.Select(s => Uri.EscapeDataString($"{resourceUri}/{s}")));
274-
var redirectEncoded = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri);
275-
return $"{loginBase}/{tenant}/v2.0/adminconsent?client_id={client}&scope={scopeParam}&redirect_uri={redirectEncoded}";
276-
}
279+
=> BuildAdminConsentUrl(tenant, client, scopes.Select(s => $"{resourceUri}/{s}"));
277280

278281
var graphScopeList = graphScopes.ToList();
279282
if (graphScopeList.Count > 0)
@@ -301,23 +304,15 @@ internal static string BuildCombinedConsentUrl(
301304
IEnumerable<string> graphScopes,
302305
IEnumerable<string> mcpScopes)
303306
{
304-
const string loginBase = "https://login.microsoftonline.com";
305-
306307
var allScopes = new List<string>();
307-
308308
foreach (var s in graphScopes)
309-
allScopes.Add(Uri.EscapeDataString($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"));
309+
allScopes.Add($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}");
310310
foreach (var s in mcpScopes)
311-
allScopes.Add(Uri.EscapeDataString($"{McpConstants.Agent365ToolsIdentifierUri}/{s}"));
312-
allScopes.Add(Uri.EscapeDataString($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"));
313-
allScopes.Add(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}"));
314-
allScopes.Add(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"));
315-
316-
// Each scope token is Uri.EscapeDataString-encoded and joined with %20 (space).
317-
// redirect_uri must be present — omitting it causes AADSTS500113.
318-
var scopeParam = string.Join("%20", allScopes);
319-
var redirectEncoded = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri);
320-
return $"{loginBase}/{tenantId}/v2.0/adminconsent?client_id={blueprintClientId}&scope={scopeParam}&redirect_uri={redirectEncoded}";
311+
allScopes.Add($"{McpConstants.Agent365ToolsIdentifierUri}/{s}");
312+
allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}");
313+
allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}");
314+
allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}");
315+
return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes);
321316
}
322317

323318
/// <summary>

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,21 @@ public async Task<List<FederatedCredentialInfo>> GetFederatedCredentialsAsync(
4949
_logger.LogDebug("Retrieving federated credentials for blueprint: {ObjectId}", blueprintObjectId);
5050

5151
// Try standard endpoint first
52-
var doc = await _graphApiService.GraphGetAsync(
52+
var primaryDoc = await _graphApiService.GraphGetAsync(
5353
tenantId,
5454
$"/beta/applications/{blueprintObjectId}/federatedIdentityCredentials",
5555
cancellationToken,
5656
scopes: [AuthenticationConstants.ApplicationReadWriteAllScope]);
5757

58-
// If standard endpoint returns data with credentials, use it
59-
if (doc != null && doc.RootElement.TryGetProperty("value", out var valueCheck) && valueCheck.GetArrayLength() > 0)
58+
JsonDocument? doc;
59+
if (primaryDoc != null && primaryDoc.RootElement.TryGetProperty("value", out var valueCheck) && valueCheck.GetArrayLength() > 0)
6060
{
6161
_logger.LogDebug("Standard endpoint returned {Count} credential(s)", valueCheck.GetArrayLength());
62+
doc = primaryDoc;
6263
}
63-
// If standard endpoint returns empty or null, try Agent Blueprint-specific endpoint
6464
else
6565
{
66+
primaryDoc?.Dispose();
6667
_logger.LogDebug("Standard endpoint returned no credentials or failed, trying Agent Blueprint fallback endpoint");
6768
doc = await _graphApiService.GraphGetAsync(
6869
tenantId,

src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ public async Task<T> ExecuteWithRetryAsync<T>(
6666
}
6767
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
6868
{
69+
if (ex is TaskCanceledException && cancellationToken.IsCancellationRequested)
70+
throw;
71+
6972
lastException = ex;
7073
_logger.LogWarning("Exception: {Message}", ex.Message);
7174

@@ -172,6 +175,9 @@ public async Task<T> ExecuteWithRetryAsync<T>(
172175
}
173176
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
174177
{
178+
if (ex is TaskCanceledException && cancellationToken.IsCancellationRequested)
179+
throw;
180+
175181
lastException = ex;
176182
_logger.LogWarning("Exception: {Message}", ex.Message);
177183

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,5 +132,11 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync(
132132
consentUrl.Should().NotBeNullOrWhiteSpace("non-admin must always receive a consent URL for the tenant admin");
133133
consentUrl.Should().Contain("tenant-123", "consent URL must be scoped to the correct tenant");
134134
consentUrl.Should().Contain("blueprint-app-id", "consent URL must reference the blueprint application");
135+
136+
// state parameter must be a random GUID (not the old hardcoded "xyz123")
137+
var stateMatch = System.Text.RegularExpressions.Regex.Match(consentUrl!, @"[?&]state=([^&]+)");
138+
stateMatch.Success.Should().BeTrue(because: "consent URL must include a state parameter for CSRF protection");
139+
Guid.TryParse(stateMatch.Groups[1].Value, out _).Should().BeTrue(
140+
because: "state parameter must be a random GUID, not a hardcoded value like 'xyz123'");
135141
}
136142
}

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -460,9 +460,8 @@ public async Task ConfigureMcpPermissionsAsync_WithMissingManifest_ShouldHandleG
460460
config,
461461
false);
462462

463-
// Assert - McpServersMetadata.Read.All is always included even when the manifest is missing,
464-
// so the method proceeds and returns true (pending admin consent) rather than false.
465-
result.Should().BeTrue();
463+
result.Should().BeTrue(
464+
because: "McpServersMetadata.Read.All is always included even when the ToolingManifest is missing, so the method proceeds to configure permissions and returns true (pending admin consent)");
466465
}
467466

468467
#endregion

0 commit comments

Comments
 (0)