Skip to content

Commit 4ac667c

Browse files
tamirdresherCopilot
andcommitted
feat: Round 2 — keyed DI, BYOK delegate, routing gate, security hardening
- Add ConfigureCopilotClient delegate on SquadAgentOptions for BYOK - Add routing gate: snapshot/restore Cwd/CliPath/CliArgs after delegate (Picard C1) - Add 4 AddKeyedSquadAgent overloads with .NET 8+ keyed DI - Fix Environment credential leak: [JsonIgnore] on Environment, GitHubTokenProvider, ConfigureCopilotClient - ToString() redacts token-pattern keys (TOKEN/KEY/SECRET/HMAC/PASSWORD/CREDENTIAL) - Add 21 new tests (43 total): security redaction, keyed DI, BYOK routing gate - Update README: streaming, keyed DI, BYOK, security sections Complies with: Picard C1-C4, Worf SC-1 through SC-8. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 88424b7 commit 4ac667c

7 files changed

Lines changed: 807 additions & 22 deletions

File tree

‎src/Squad.Agents.AI/README.md‎

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88

99
Public surface in this preview:
1010

11-
- `SquadAgent` — sealed `AIAgent` wrapper over the Copilot-backed inner agent.
12-
- `SquadAgentOptions` — team root, CLI path/args, environment, token, logging, and instruction settings.
11+
- `SquadAgent` — sealed `AIAgent` wrapper over the Copilot-backed inner agent. Supports both non-streaming and streaming via `RunStreamingAsync`.
12+
- `SquadAgentOptions` — team root, CLI path/args, environment, token, logging, instruction settings, and a `ConfigureCopilotClient` delegate for advanced SDK customization.
1313
- `SquadConnectionFactory` — parses PATH or `squad://` connection strings into options.
14-
- `SquadServiceCollectionExtensions` — registers `SquadAgent` and base `AIAgent` in DI.
14+
- `SquadServiceCollectionExtensions` — registers `SquadAgent` and base `AIAgent` in DI, with standard and keyed overloads.
1515

1616
Repository: <https://github.com/bradygaster/squad>
1717

@@ -56,13 +56,77 @@ var response = await squad.RunAsync("What can this Squad team do?", session);
5656
Console.WriteLine(response.Text);
5757
```
5858

59+
## Streaming
60+
61+
`SquadAgent` supports streaming via the MAF `RunStreamingAsync` method. The inner Copilot-backed agent streams response updates as they arrive from the CLI process:
62+
63+
```csharp
64+
var squad = host.Services.GetRequiredService<SquadAgent>();
65+
var session = await squad.CreateSessionAsync();
66+
67+
await foreach (var update in squad.RunStreamingAsync("Summarize the team.", session))
68+
{
69+
Console.Write(update.Text);
70+
}
71+
```
72+
73+
## Keyed DI
74+
75+
Register multiple Squad teams in the same DI container using .NET 8+ keyed services:
76+
77+
```csharp
78+
builder.Services.AddKeyedSquadAgent("research", o =>
79+
{
80+
o.SquadFolderPath = @"C:\research-team";
81+
});
82+
83+
builder.Services.AddKeyedSquadAgent("platform", o =>
84+
{
85+
o.SquadFolderPath = @"C:\platform-team";
86+
});
87+
88+
// Resolve in a minimal API endpoint:
89+
app.MapGet("/ask-research", async ([FromKeyedServices("research")] SquadAgent agent) =>
90+
{
91+
var session = await agent.CreateSessionAsync();
92+
var response = await agent.RunAsync("What's the latest?", session);
93+
return response.Text;
94+
});
95+
```
96+
97+
Keyed registrations include `AddKeyedSquadAgent` overloads for service key, optional connection-string name, and lifetime. Keyed and non-keyed registrations can coexist.
98+
99+
## Advanced: ConfigureCopilotClient (BYOK)
100+
101+
Use `ConfigureCopilotClient` to customize the underlying `CopilotClientOptions` after Squad applies its standard values. This is the extension point for injecting custom environment variables, providing a BYOK token, or tuning SDK settings:
102+
103+
```csharp
104+
builder.Services.AddSquadAgent(o =>
105+
{
106+
o.SquadFolderPath = @"C:\team";
107+
o.ConfigureCopilotClient = clientOpts =>
108+
{
109+
// Inject a BYOK token from your own credential store
110+
clientOpts.GitHubToken = myVault.GetSecret("copilot-token");
111+
112+
// Add custom environment variables for the CLI process
113+
clientOpts.Environment = new Dictionary<string, string>
114+
{
115+
["MY_CUSTOM_VAR"] = "value"
116+
};
117+
};
118+
});
119+
```
120+
121+
> **Routing guard:** Squad enforces a hard routing gate — if the delegate changes `Cwd`, `CliPath`, or `CliArgs`, the original values are restored and a warning is logged. Configure these via `SquadAgentOptions` instead.
122+
59123
## GitHub Copilot authentication
60124

61125
The default path mirrors the base GitHub Copilot SDK examples: leave `GitHubToken` and `GitHubTokenProvider` unset, then run with a locally signed-in GitHub Copilot user. For local development, sign in before running the app, for example with `gh auth login`, the Copilot CLI sign-in flow, or a Copilot-supported local sign-in that the SDK runtime can access.
62126

63127
The SDK documents the credential priority order in [Authenticate Copilot SDK](https://github.com/github/copilot-sdk/blob/main/docs/auth/authenticate.md) and the broader [authentication overview](https://github.com/github/copilot-sdk/blob/main/docs/auth/index.md). Explicit tokens and environment variables are supported by the SDK, but they are not required for the minimal happy path.
64128

65-
Use `GitHubTokenProvider` only when the host owns token retrieval, such as Key Vault, managed identity, or CI secret flow scenarios. Use `GitHubToken` only when you already have a Copilot-supported token in process and cannot use a provider callback. In both cases, treat these as advanced escape hatches and never hardcode tokens.
129+
Use `GitHubTokenProvider` only when the host owns token retrieval, such as Key Vault, managed identity, or CI secret flow scenarios. Use `GitHubToken` only when you already have a Copilot-supported token in process and cannot use a provider callback. Use `ConfigureCopilotClient` when you need full control over the SDK's `CopilotClientOptions`, including BYOK token injection.
66130

67131
## Aspire / configuration path
68132

@@ -86,17 +150,26 @@ Parsed URI query keys: `teamRoot`, `cliPath`, `cwd`, `cliArgs` (semicolon-separa
86150
| `SquadFolderPath` | Squad team root; PATH connection strings set this and `Cwd`. |
87151
| `CliPath` / `CliArgs` | Override the Copilot CLI executable and add extra CLI flags. |
88152
| `Cwd` | Working directory for the Copilot CLI process; defaults to `SquadFolderPath`. |
89-
| `Environment` | Additional environment variables for the CLI process. Avoid user-controlled values. |
90-
| `GitHubToken` | Advanced direct token escape hatch; redacted by `ToString()`. |
153+
| `Environment` | Additional environment variables for the CLI process. Excluded from JSON serialization; token-pattern keys are redacted in `ToString()`. |
154+
| `GitHubToken` | Advanced direct token escape hatch; excluded from JSON serialization and redacted by `ToString()`. |
91155
| `GitHubTokenProvider` | Advanced callback for secure token retrieval; wins over `GitHubToken`. |
156+
| `ConfigureCopilotClient` | Advanced delegate for customizing `CopilotClientOptions`. Routing properties are guarded; see BYOK section. |
92157
| `TraceEvents` | Enables verbose SDK logging and emits a startup warning when enabled. |
93158
| `AgentName` | Display name for the resulting `AIAgent`; defaults to `Squad`. |
94159
| `Instructions` | Optional system instructions passed to the inner Copilot agent. |
95160

161+
## Security
162+
163+
- **Credential redaction:** `GitHubToken` and `Environment` values whose keys match `*TOKEN*`, `*KEY*`, `*SECRET*`, `*HMAC*`, `*PASSWORD*`, or `*CREDENTIAL*` are always redacted in `ToString()` output.
164+
- **JSON safety:** `GitHubToken`, `GitHubTokenProvider`, `Environment`, and `ConfigureCopilotClient` are marked `[JsonIgnore]` and will not appear in JSON serialization output.
165+
- **Routing guard:** The `ConfigureCopilotClient` delegate cannot change `Cwd`, `CliPath`, or `CliArgs` — changes are silently restored to prevent routing the agent to an unintended CLI process.
166+
- **TraceEvents warning:** Enabling `TraceEvents` logs a startup warning because verbose SDK traces may include sensitive operational details.
167+
- **Avoid hardcoded tokens:** Never embed tokens in source code. Use `GitHubTokenProvider` for production token retrieval from Key Vault, managed identity, or similar secure stores.
168+
96169
## Notes for v0.1-preview
97170

98171
- Default DI lifetime is scoped. An overload accepts any `ServiceLifetime`.
99-
- DI registers both `SquadAgent` and base `AIAgent`.
172+
- DI registers both `SquadAgent` and base `AIAgent` (non-keyed). Keyed DI registers both `SquadAgent` and `AIAgent` under the same key.
100173
- Multi-targeting and Aspire telemetry remain candidates for a later preview.
101174
- The package does not validate that `SquadFolderPath` exists; consumers should validate their deployment paths.
102175
- `TraceEvents` can log sensitive operational details. Keep it off unless debugging.

‎src/Squad.Agents.AI/SquadAgent.cs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,21 @@ private SquadAgent(CopilotClient copilotClient, SquadAgentOptions options, ILogg
6565
options.AgentName, options.SquadFolderPath);
6666
}
6767

68+
// ── Extensibility seam ──────────────────────────────────────────────
69+
// CreateCopilotClient is the single factory method that translates
70+
// SquadAgentOptions into a live CopilotClient. Three extension points
71+
// feed into it:
72+
// 1. GitHubTokenProvider / GitHubToken — credential override.
73+
// 2. ConfigureCopilotClient — BYOK delegate for advanced SDK tuning.
74+
// 3. Connection-string binding (handled before this method by the
75+
// SquadAgentOptionsConfigurator).
76+
// Any future credential-store or model-property hooks should integrate
77+
// through SquadAgentOptions and be applied inside this method.
78+
// ────────────────────────────────────────────────────────────────────
6879
private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILoggerFactory? loggerFactory)
6980
{
81+
var logger = loggerFactory?.CreateLogger<SquadAgent>();
82+
7083
// Resolve token: provider takes precedence over direct property
7184
string? resolvedToken = null;
7285
if (options.GitHubTokenProvider is not null)
@@ -110,6 +123,60 @@ private static CopilotClient CreateCopilotClient(SquadAgentOptions options, ILog
110123
clientOptions.Logger = loggerFactory.CreateLogger<CopilotClient>();
111124
}
112125

126+
// ── BYOK / ConfigureCopilotClient delegate ─────────────────────
127+
// Allow consumers to customize the CopilotClientOptions after Squad
128+
// applies its standard values. After the delegate runs, snapshot and
129+
// restore routing-critical properties to prevent accidental or
130+
// malicious changes that would route the agent to a different CLI
131+
// process. (Picard Condition 1: hard routing gate.)
132+
if (options.ConfigureCopilotClient is not null)
133+
{
134+
// Snapshot routing properties before the delegate runs
135+
var snapshotCwd = clientOptions.Cwd;
136+
var snapshotCliPath = clientOptions.CliPath;
137+
var snapshotCliArgs = clientOptions.CliArgs;
138+
139+
options.ConfigureCopilotClient(clientOptions);
140+
141+
// Restore routing properties if changed (SC-3: post-delegate warning)
142+
bool restored = false;
143+
if (!string.Equals(clientOptions.Cwd, snapshotCwd, StringComparison.Ordinal))
144+
{
145+
logger?.LogWarning(
146+
"ConfigureCopilotClient delegate changed Cwd from '{Original}' to '{Changed}'; " +
147+
"restoring original value to preserve Squad routing.",
148+
snapshotCwd, clientOptions.Cwd);
149+
clientOptions.Cwd = snapshotCwd;
150+
restored = true;
151+
}
152+
153+
if (!string.Equals(clientOptions.CliPath, snapshotCliPath, StringComparison.Ordinal))
154+
{
155+
logger?.LogWarning(
156+
"ConfigureCopilotClient delegate changed CliPath from '{Original}' to '{Changed}'; " +
157+
"restoring original value to preserve Squad routing.",
158+
snapshotCliPath, clientOptions.CliPath);
159+
clientOptions.CliPath = snapshotCliPath;
160+
restored = true;
161+
}
162+
163+
if (!ReferenceEquals(clientOptions.CliArgs, snapshotCliArgs))
164+
{
165+
logger?.LogWarning(
166+
"ConfigureCopilotClient delegate replaced CliArgs; " +
167+
"restoring original value to preserve Squad routing.");
168+
clientOptions.CliArgs = snapshotCliArgs;
169+
restored = true;
170+
}
171+
172+
if (restored)
173+
{
174+
logger?.LogWarning(
175+
"One or more routing properties were restored after ConfigureCopilotClient delegate ran. " +
176+
"To set Cwd, CliPath, or CliArgs, configure them on SquadAgentOptions instead.");
177+
}
178+
}
179+
113180
return new CopilotClient(clientOptions);
114181
}
115182

‎src/Squad.Agents.AI/SquadAgentOptions.cs‎

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using System.Text.Json.Serialization;
2+
using GitHub.Copilot.SDK;
3+
14
namespace Squad.Agents.AI;
25

36
/// <summary>
@@ -37,6 +40,14 @@ public sealed class SquadAgentOptions
3740
/// <summary>
3841
/// Gets additional environment variables injected into the CLI process.
3942
/// </summary>
43+
/// <remarks>
44+
/// <para>
45+
/// This dictionary is excluded from JSON serialization to prevent credential leakage.
46+
/// Values for keys matching token/secret patterns are redacted in <see cref="ToString"/>.
47+
/// </para>
48+
/// <para>Avoid placing user-controlled values in this dictionary.</para>
49+
/// </remarks>
50+
[JsonIgnore]
4051
public IDictionary<string, string?> Environment { get; } = new Dictionary<string, string?>();
4152

4253
/// <summary>
@@ -46,7 +57,7 @@ public sealed class SquadAgentOptions
4657
/// The value is ignored by JSON serialization and redacted by <see cref="ToString"/>, but still lives in the options object.
4758
/// Prefer leaving this unset for local signed-in user authentication; use <see cref="GitHubTokenProvider"/> for production hosts that retrieve tokens dynamically.
4859
/// </remarks>
49-
[System.Text.Json.Serialization.JsonIgnore]
60+
[JsonIgnore]
5061
public string? GitHubToken { get; set; }
5162

5263
/// <summary>
@@ -55,6 +66,7 @@ public sealed class SquadAgentOptions
5566
/// <remarks>
5667
/// When set, this provider takes precedence over <see cref="GitHubToken"/>.
5768
/// </remarks>
69+
[JsonIgnore]
5870
public Func<CancellationToken, ValueTask<string?>>? GitHubTokenProvider { get; set; }
5971

6072
/// <summary>
@@ -75,13 +87,66 @@ public sealed class SquadAgentOptions
7587
/// </summary>
7688
public string? Instructions { get; set; }
7789

90+
/// <summary>
91+
/// Gets or sets a delegate that customizes the underlying <see cref="CopilotClientOptions"/> after Squad
92+
/// applies its standard values. Use this for advanced scenarios such as injecting custom environment
93+
/// variables, setting timeouts, or providing a custom logger to the Copilot SDK.
94+
/// </summary>
95+
/// <remarks>
96+
/// <para><b>Security note:</b> Do not override <c>Cwd</c>, <c>CliPath</c>, or <c>CliArgs</c> through this
97+
/// delegate — Squad enforces routing invariants and will restore its own values if they are changed.
98+
/// A warning is logged when this occurs.</para>
99+
/// <para>The delegate has full read/write access to <see cref="CopilotClientOptions"/>, including
100+
/// <c>GitHubToken</c>. If the delegate replaces the token, the replacement takes effect.
101+
/// Document this as the final authority on token value.</para>
102+
/// </remarks>
103+
/// <example>
104+
/// <code>
105+
/// options.ConfigureCopilotClient = clientOpts =>
106+
/// {
107+
/// clientOpts.Environment["MY_CUSTOM_VAR"] = "custom-value";
108+
/// };
109+
/// </code>
110+
/// </example>
111+
[JsonIgnore]
112+
public Action<CopilotClientOptions>? ConfigureCopilotClient { get; set; }
113+
114+
private static readonly string[] TokenPatterns = { "TOKEN", "KEY", "SECRET", "HMAC", "PASSWORD", "CREDENTIAL" };
115+
116+
private static bool IsTokenKey(string key)
117+
{
118+
var upper = key.ToUpperInvariant();
119+
foreach (var pattern in TokenPatterns)
120+
{
121+
if (upper.Contains(pattern))
122+
return true;
123+
}
124+
return false;
125+
}
126+
78127
/// <summary>
79128
/// Returns a diagnostic representation with sensitive values redacted.
80129
/// </summary>
130+
/// <remarks>
131+
/// <para><c>GitHubToken</c> is always shown as <c>[REDACTED]</c>.</para>
132+
/// <para><c>Environment</c> values whose keys match token/secret patterns
133+
/// (<c>*TOKEN*</c>, <c>*KEY*</c>, <c>*SECRET*</c>, <c>*HMAC*</c>, <c>*PASSWORD*</c>, <c>*CREDENTIAL*</c>)
134+
/// are shown as <c>[REDACTED]</c>; other environment entries show their value.</para>
135+
/// </remarks>
81136
/// <returns>A string containing non-secret option values.</returns>
82137
public override string ToString()
83138
{
139+
var envDisplay = "{}";
140+
if (Environment.Count > 0)
141+
{
142+
var entries = Environment.Select(kvp =>
143+
IsTokenKey(kvp.Key)
144+
? $"{kvp.Key}=[REDACTED]"
145+
: $"{kvp.Key}={kvp.Value}");
146+
envDisplay = $"{{ {string.Join(", ", entries)} }}";
147+
}
148+
84149
return $"SquadAgentOptions {{ SquadFolderPath = {SquadFolderPath}, AgentName = {AgentName}, " +
85-
$"GitHubToken = [REDACTED], TraceEvents = {TraceEvents} }}";
150+
$"GitHubToken = [REDACTED], Environment = {envDisplay}, TraceEvents = {TraceEvents} }}";
86151
}
87152
}

0 commit comments

Comments
 (0)