Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
**Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output.

### Added
- `a365 network gsa enable|disable|status` — turns Global Secure Access on or off for the tenant's Agent 365 environment. `NotConfigured` is reported distinctly from `Disabled`, because a tenant that has never set the value has not turned it off. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network-gsa.md](docs/commands/network-gsa.md) (#497).
- Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489).
- Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text.
- Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials.
Expand Down
4 changes: 4 additions & 0 deletions docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ There is reference documentation for each command.
| [develop-mcp list-servers](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-list-servers) | List MCP servers in a specific Dataverse environment. |
| [develop-mcp publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-publish) | Publish an MCP server to a Dataverse environment. |
| [develop-mcp unpublish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-unpublish) | Unpublish an MCP server from a Dataverse environment. |
| [network](network-gsa.md) | Configure tenant networking for Agent 365. |
| [network gsa enable](network-gsa.md#enable-and-disable) | Turn Global Secure Access on for your Agent 365 environment. |
| [network gsa disable](network-gsa.md#enable-and-disable) | Turn Global Secure Access off for your Agent 365 environment. |
| [network gsa status](network-gsa.md#status) | Show whether Global Secure Access is on for your Agent 365 environment. |
| [publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/publish) | Update manifest.json ID values and publish the package. Configure federated identity and app role assignments. |
| [query-entra](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/query-entra) | Query Microsoft Entra ID for agent information including scopes, permissions, and consent status. |
| [query-entra blueprint-scopes](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/query-entra#query-entra-blueprint-scopes) | List configured scopes and consent status for the agent blueprint. |
Expand Down
91 changes: 91 additions & 0 deletions docs/commands/network-gsa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# `a365 network gsa`

Turns **Global Secure Access** on or off for the tenant's Agent 365 Power Platform environment,
without needing the id of that environment.

## Why this command exists

Global Secure Access is a per-environment Power Platform setting. Agent 365 provisions a managed
environment for the tenant and does not publish its id, so the setting cannot be reached through
the Power Platform admin surfaces that take an environment id. These subcommands ask the Agent 365
platform to apply the change against the environment it resolves for your tenant.

## Prerequisites

- **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects
anyone else.
- An `az login` to the tenant you intend to configure.
- Public cloud only. Sovereign clouds are not supported.

The `az login` is what selects the tenant. These commands read `az account show` once per run and
authenticate against the tenant and account it reports, so `az login --tenant <id>` is how you
choose which tenant to configure when you have more than one. Nothing else is read from Azure — the
setting itself lives in Power Platform, not in your subscription. Without an explicit tenant the
Windows broker silently returns whichever account Windows prefers, which would apply a tenant-wide
setting to the wrong tenant. If the account you are signed into cannot be matched, the command
fails rather than falling back.

## Subcommands

| Command | Description |
| --- | --- |
| `a365 network gsa enable` | Turn Global Secure Access on. |
| `a365 network gsa disable` | Turn Global Secure Access off. |
| `a365 network gsa status` | Show whether Global Secure Access is on. |

### `enable` and `disable`

```bash
a365 network gsa enable [--wait] [--yes]
a365 network gsa disable [--wait] [--yes]
```

| Option | Description |
| --- | --- |
| `--wait` | Keep polling until the change appears on the environment, instead of returning while it is still being applied. |
| `--yes`, `-y` | Skip the confirmation prompt. |

Both verbs prompt before changing the tenant-wide setting; pass `--yes` in automation.
Requesting the value the environment already holds is a no-op and succeeds.

### `status`

```bash
a365 network gsa status
```

There is no operation handle to pass. Power Platform applies the change asynchronously but issues
no operation id for it, so the CLI reports progress by re-reading the setting rather than by
polling a handle.

## Statuses and exit codes

| Status | Meaning |
| --- | --- |
| `Enabled` | Global Secure Access is on. |
| `Disabled` | Global Secure Access is off. |
| `NotConfigured` | The tenant has never set the value. This is **not** the same as `Disabled`. |

A change that has been accepted but has not yet surfaced is reported as still being applied, with
the status still showing the value it has not yet displaced.

Exit code is `1` on any request error, and `0` otherwise — including a change that is still being
applied, which is a legitimate outcome when `--wait` is not passed.

## Typical flow

```bash
a365 network gsa enable --wait
a365 network gsa status
```

## Troubleshooting

| Symptom | Cause |
| --- | --- |
| `403` from the platform | Caller is not a Global or Power Platform Administrator, or the CLI app lacks consent for the `AgentTools.Gsa.*` scopes. |
| `409`, reporting a governing policy | A Power Platform policy owns this setting. Change it through that policy; the environment-level value is ignored while the policy applies. |
| `404`, reporting no environment | The tenant has no Agent 365 environment yet. |
| Status stays `NotConfigured` after `disable` | Read it again — the change is applied asynchronously and `--wait` is the way to block on it. |
| `Could not determine your Azure tenant` | No usable `az login`. Run `az login --tenant <id>` for the tenant you want to configure. |
| Sign-in prompt names the wrong account | The tenant comes from `az account show`. Run `az account set` / `az login --tenant <id>` to point at the intended tenant, then retry. |
250 changes: 250 additions & 0 deletions src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Agents.A365.DevTools.Cli.Constants;
using Microsoft.Agents.A365.DevTools.Cli.Models;
using Microsoft.Agents.A365.DevTools.Cli.Services;
using Microsoft.Extensions.Logging;
using System.CommandLine;
using System.CommandLine.Invocation;

namespace Microsoft.Agents.A365.DevTools.Cli.Commands;

/// <summary>
/// Tenant network configuration for Agent 365.
///
/// Global Secure Access is normally set per Power Platform environment, which needs the id of the
/// environment being configured. Agent 365 does not publish that id, so these subcommands ask the
/// platform to apply the setting to the environment it resolves for your tenant.
/// </summary>
public static class NetworkCommand
{
private static readonly TimeSpan DefaultWaitTimeout = TimeSpan.FromMinutes(10);

/// <summary>
/// Creates the network command and its gsa subcommand tree.
/// </summary>
public static Command CreateCommand(
ILogger logger,
IAzureCliService azureCliService,
IGsaService gsaService,
IConfirmationProvider confirmationProvider)
{
var networkCommand = new Command(CommandNames.Network, "Configure tenant networking for Agent 365");

var gsaCommand = new Command(
"gsa",
"Turn Global Secure Access on or off for your Agent 365 environment. " +
"Requires the Global Administrator or Power Platform Administrator role.");

gsaCommand.AddCommand(CreateGsaSetSubcommand(
logger, gsaService, azureCliService, confirmationProvider, enabled: true));
gsaCommand.AddCommand(CreateGsaSetSubcommand(
logger, gsaService, azureCliService, confirmationProvider, enabled: false));
gsaCommand.AddCommand(CreateGsaStatusSubcommand(logger, gsaService, azureCliService));

networkCommand.AddCommand(gsaCommand);
return networkCommand;
}

/// <summary>
/// Resolves the Azure account to act as, or logs why it could not and returns null.
/// </summary>
/// <remarks>
/// Resolved once per invocation and passed to every call that follows. <c>az account show</c>
/// reads mutable local state, so reading it again for authentication after prompting could
/// confirm one tenant and change another, and a transient CLI failure between two reads could
/// fail a command that had already succeeded at resolving the tenant.
/// </remarks>
internal static async Task<AzureAccountInfo?> ResolveAccountAsync(
ILogger logger,
IAzureCliService azureCliService)
{
var account = await azureCliService.GetCurrentAccountAsync();
if (account is null || string.IsNullOrWhiteSpace(account.TenantId))
{
logger.LogError("Could not determine your Azure tenant. Run 'az login' and try again.");
return null;
}

return account;
}

/// <summary>
/// Asks the operator to confirm a change to tenant-wide networking, naming the tenant and the
/// action so the prompt is answerable without scrolling back.
/// </summary>
internal static async Task<bool> ConfirmChangeAsync(
IConfirmationProvider confirmationProvider,
bool yes,
string action,
string tenantId)
{
if (yes)
{
return true;
}

return await confirmationProvider.ConfirmAsync(
$"{action} for tenant {tenantId}. This changes networking for every Agent 365 agent in the tenant. Continue?");
}

/// <summary>
/// Creates the gsa enable or disable subcommand. The two differ only in the value they send
/// and the words they use, so they share one builder.
/// </summary>
private static Command CreateGsaSetSubcommand(
ILogger logger,
IGsaService gsaService,
IAzureCliService azureCliService,
IConfirmationProvider confirmationProvider,
bool enabled)
{
var verb = enabled ? "enable" : "disable";
var command = new Command(
verb,
$"Turn Global Secure Access {(enabled ? "on" : "off")} for your Agent 365 environment.");

var waitOption = new Option<bool>(
"--wait",
"Keep polling until the change appears on the environment, instead of returning while " +
"it is still being applied.");

var yesOption = new Option<bool>(
["--yes", "-y"],
"Skip the confirmation prompt.");

var verboseOption = new Option<bool>(["--verbose", "-v"], "Enable verbose logging");

command.AddOption(waitOption);
command.AddOption(yesOption);
command.AddOption(verboseOption);

command.SetHandler(async (InvocationContext context) =>
{
var wait = context.ParseResult.GetValueForOption(waitOption);
var yes = context.ParseResult.GetValueForOption(yesOption);
var ct = context.GetCancellationToken();

// Resolved once, then used for both the prompt and the call, so the tenant named in
// the prompt is provably the tenant changed.
var account = await ResolveAccountAsync(logger, azureCliService);
if (account == null)
{
context.ExitCode = 1;
return;
}

if (!await ConfirmChangeAsync(
confirmationProvider, yes, $"Turn Global Secure Access {(enabled ? "on" : "off")}", account.TenantId))
{
logger.LogInformation("Cancelled.");
context.ExitCode = 1;
return;
}

var result = await gsaService.SetAsync(account, enabled, ct);
context.ExitCode = await ReportGsaAsync(logger, gsaService, account, result, wait, enabled, ct);
});

return command;
}

private static Command CreateGsaStatusSubcommand(
ILogger logger,
IGsaService gsaService,
IAzureCliService azureCliService)
{
var command = new Command(
"status",
"Show whether Global Secure Access is on for your Agent 365 environment.");

var verboseOption = new Option<bool>(["--verbose", "-v"], "Enable verbose logging");
command.AddOption(verboseOption);

command.SetHandler(async (InvocationContext context) =>
{
var ct = context.GetCancellationToken();

var account = await ResolveAccountAsync(logger, azureCliService);
if (account == null)
{
context.ExitCode = 1;
return;
}

var status = await gsaService.GetStatusAsync(account, ct);
if (status == null)
{
context.ExitCode = 1;
return;
}

LogGsaStatus(logger, status);
context.ExitCode = 0;
});

return command;
}

/// <summary>
/// Renders the outcome of a Global Secure Access change, optionally waiting for it to appear
/// first, and maps it to a process exit code.
/// </summary>
internal static async Task<int> ReportGsaAsync(
ILogger logger,
IGsaService gsaService,
AzureAccountInfo account,
GsaStatusResponse? result,
bool wait,
bool enabled,
CancellationToken cancellationToken)
{
if (result == null)
{
return 1;
}

var expectedStatus = enabled ? "Enabled" : "Disabled";

if (wait && result.Pending)
{
logger.LogInformation("The change is still being applied. Waiting for it to appear...");
result = await gsaService.WaitForStatusAsync(account, expectedStatus, DefaultWaitTimeout, cancellationToken);

if (result == null)
{
return 1;
}
}

LogGsaStatus(logger, result);

// Still pending is not a failure. The platform accepted the change and the environment
// will catch up; reporting non-zero here would break scripts that chain on success.
if (result.Pending)
{
logger.LogInformation(
"Still being applied. Check on it with: a365 network gsa status");
}

return 0;
}

private static void LogGsaStatus(ILogger logger, GsaStatusResponse status)
{
logger.LogInformation("Global Secure Access: {Status}", status.Status ?? "Unknown");

if (string.Equals(status.Status, "NotConfigured", StringComparison.OrdinalIgnoreCase))
{
// Worth spelling out: a tenant that has never set this is not the same as one that
// turned it off, and the distinction changes what an admin should do next.
logger.LogInformation("This tenant has never set Global Secure Access, so no value is stored.");
}

if (!string.IsNullOrWhiteSpace(status.Reason))
{
logger.LogWarning("Reason: {Reason}", status.Reason);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ public static class CommandNames
public const string Develop = "develop";
public const string CreateInstance = "create-instance";
public const string Logs = "logs";
public const string Network = "network";
}
Loading
Loading