Skip to content

Commit ea4d4d0

Browse files
committed
Address release readiness review feedback
1 parent ed3a0a1 commit ea4d4d0

16 files changed

Lines changed: 310 additions & 27 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
param(
2+
[string]$Branch = "main"
3+
)
4+
5+
$ErrorActionPreference = "Stop"
6+
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path
7+
$repository = (& gh repo view --json nameWithOwner --jq .nameWithOwner).Trim()
8+
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repository)) {
9+
throw "Could not resolve the GitHub repository. Authenticate gh and run this script from the checkout."
10+
}
11+
12+
$branchProtectionPath = Join-Path $repositoryRoot ".github/branch-protection.json"
13+
$securitySettingsPath = Join-Path $repositoryRoot ".github/security-settings.json"
14+
15+
& gh api --method PUT "repos/$repository/branches/$Branch/protection" --input $branchProtectionPath --silent
16+
if ($LASTEXITCODE -ne 0) { throw "Could not apply branch protection to '$Branch'." }
17+
18+
& gh api --method PATCH "repos/$repository" --input $securitySettingsPath --silent
19+
if ($LASTEXITCODE -ne 0) { throw "Could not apply repository security settings." }
20+
21+
& gh api --method PUT "repos/$repository/vulnerability-alerts" --silent
22+
if ($LASTEXITCODE -ne 0) { throw "Could not enable vulnerability alerts." }
23+
24+
& gh api --method PUT "repos/$repository/automated-security-fixes" --silent
25+
if ($LASTEXITCODE -ne 0) { throw "Could not enable Dependabot security updates." }
26+
27+
Write-Host "Applied branch protection and repository security settings to $repository ($Branch)."

‎.github/scripts/Test-Packages.ps1‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ $scratchRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("sharpclaw-package-s
99
$packageOutput = Join-Path $scratchRoot "packages"
1010
$toolPath = Join-Path $scratchRoot "tool"
1111
$consumerPath = Join-Path $scratchRoot "consumer"
12+
$nugetConfigPath = Join-Path $scratchRoot "NuGet.Config"
1213

1314
try {
1415
New-Item -ItemType Directory -Path $packageOutput -Force | Out-Null
@@ -24,7 +25,16 @@ try {
2425
if ($LASTEXITCODE -ne 0) { throw "Could not create package smoke consumer." }
2526
& dotnet add (Join-Path $consumerPath "consumer.csproj") package SharpClaw.Code --version $PackageVersion --no-restore
2627
if ($LASTEXITCODE -ne 0) { throw "Could not add the aggregate SDK package." }
27-
& dotnet restore (Join-Path $consumerPath "consumer.csproj") --source $packageOutput --source https://api.nuget.org/v3/index.json
28+
29+
[System.IO.File]::WriteAllText(
30+
$nugetConfigPath,
31+
'<?xml version="1.0" encoding="utf-8"?><configuration><packageSources><clear /></packageSources></configuration>')
32+
& dotnet nuget add source $packageOutput --name sharpclaw-local --configfile $nugetConfigPath
33+
if ($LASTEXITCODE -ne 0) { throw "Could not configure the local package source." }
34+
& dotnet nuget add source "https://api.nuget.org/v3/index.json" --name nuget.org --configfile $nugetConfigPath
35+
if ($LASTEXITCODE -ne 0) { throw "Could not configure the NuGet.org package source." }
36+
37+
& dotnet restore (Join-Path $consumerPath "consumer.csproj") --configfile $nugetConfigPath
2838
if ($LASTEXITCODE -ne 0) { throw "Could not restore the aggregate SDK package." }
2939
& dotnet build (Join-Path $consumerPath "consumer.csproj") --configuration $Configuration --no-restore
3040
if ($LASTEXITCODE -ne 0) { throw "The aggregate SDK package failed to build in a clean consumer." }
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
param(
2+
[string]$Target = "SharpClawCode.sln"
3+
)
4+
5+
$ErrorActionPreference = "Stop"
6+
$reportJson = (& dotnet list $Target package --vulnerable --include-transitive --format json) -join [Environment]::NewLine
7+
if ($LASTEXITCODE -ne 0) { throw "NuGet vulnerability inspection failed." }
8+
9+
$report = $reportJson | ConvertFrom-Json
10+
$vulnerablePackages = @(
11+
foreach ($project in @($report.projects)) {
12+
foreach ($framework in @($project.frameworks)) {
13+
foreach ($package in @($framework.topLevelPackages) + @($framework.transitivePackages)) {
14+
if ($null -ne $package -and @($package.vulnerabilities).Count -gt 0) {
15+
[PSCustomObject]@{
16+
Project = $project.path
17+
Framework = $framework.framework
18+
Package = $package.id
19+
ResolvedVersion = $package.resolvedVersion
20+
Vulnerabilities = @($package.vulnerabilities)
21+
}
22+
}
23+
}
24+
}
25+
}
26+
)
27+
28+
if ($vulnerablePackages.Count -gt 0) {
29+
$vulnerablePackages | ConvertTo-Json -Depth 8 | Write-Error
30+
throw "NuGet reported $($vulnerablePackages.Count) vulnerable package occurrence(s)."
31+
}
32+
33+
Write-Host "NuGet vulnerability audit passed for $($report.projects.Count) projects."

‎.github/workflows/provider-smoke.yml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,17 @@ jobs:
3535
- run: dotnet build src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --configuration Release --warnaserror
3636
- name: Select credential reference
3737
shell: pwsh
38+
env:
39+
PROVIDER: ${{ inputs.provider }}
3840
run: |
39-
$provider = '${{ inputs.provider }}'
41+
$provider = $env:PROVIDER
4042
$variableName = if ($provider -eq 'anthropic') { 'ANTHROPIC_API_KEY' } else { 'OPENAI_API_KEY' }
4143
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($variableName))) {
4244
throw "The $variableName repository secret is required for this smoke test."
4345
}
4446
dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- auth set-key --provider $provider --env-var $variableName
4547
- name: Run real provider prompt
46-
run: dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- --output-format json --model '${{ inputs.provider }}/${{ inputs.model }}' prompt 'Reply with exactly SHARPCLAW_PROVIDER_OK.'
48+
env:
49+
PROVIDER: ${{ inputs.provider }}
50+
MODEL: ${{ inputs.model }}
51+
run: dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- --output-format json --model "$PROVIDER/$MODEL" prompt 'Reply with exactly SHARPCLAW_PROVIDER_OK.'

‎.github/workflows/release.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ jobs:
1616
- name: Validate semantic version tag
1717
id: version
1818
shell: pwsh
19+
env:
20+
REF_NAME: ${{ github.ref_name }}
1921
run: |
20-
$version = "${{ github.ref_name }}" -replace '^v', ''
22+
$version = $env:REF_NAME -replace '^v', ''
2123
if ($version -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') { throw "Tag is not a supported semantic version." }
2224
"value=$version" >> $env:GITHUB_OUTPUT
2325
- name: Setup .NET
@@ -27,7 +29,8 @@ jobs:
2729
- name: Restore
2830
run: dotnet restore SharpClawCode.sln
2931
- name: Audit dependencies
30-
run: dotnet list SharpClawCode.sln package --vulnerable --include-transitive
32+
shell: pwsh
33+
run: ./.github/scripts/Test-VulnerablePackages.ps1 -Target SharpClawCode.sln
3134
- name: Build
3235
run: dotnet build SharpClawCode.sln --no-restore --configuration Release --warnaserror
3336
- name: Test

‎SECURITY.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,13 @@ Until the first stable release, security fixes are applied to the latest preview
99
Use GitHub's private vulnerability reporting for this repository. Do not open a public issue or include live credentials, private prompts, session files, or exploit details in public logs. Include the affected version, impact, reproduction conditions, and any suggested mitigation. Maintainers will acknowledge a complete report within five business days and coordinate remediation and disclosure.
1010

1111
Local provider credentials are protected for the current operating-system user. On Windows this uses DPAPI; on macOS and Linux it uses an AES-GCM key stored with user-only file permissions under the user SharpClaw directory. This protects against accidental disclosure at rest, but it does not protect against another process already running as the same user.
12+
13+
## Repository security administration
14+
15+
Maintainers with repository administration access can idempotently apply the checked-in branch protection, secret scanning, push protection, vulnerability alerts, and Dependabot security-update settings with:
16+
17+
```powershell
18+
./.github/scripts/Set-RepositorySecurity.ps1 -Branch main
19+
```
20+
21+
The JSON files under `.github` are inputs to that script; committing them alone does not change GitHub settings.

‎src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
7878
ProviderExecutionException? lastCandidateFailure = null;
7979
foreach (var candidate in resolvedCandidates)
8080
{
81-
var candidateModel = ResolveCandidateModel(providerCatalogOptions.Value, candidate.ProviderName, requestedModel);
81+
var candidateModel = ResolveCandidateModel(
82+
providerCatalogOptions.Value,
83+
resolvedProviderName,
84+
candidate.ProviderName,
85+
requestedModel);
8286
try
8387
{
8488
var authStatus = string.Equals(candidate.ProviderName, resolvedProviderName, StringComparison.OrdinalIgnoreCase)
@@ -144,7 +148,11 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
144148

145149
var activeProviderIndex = 0;
146150
var activeProviderName = providerCandidates[0].ProviderName;
147-
var activeModel = ResolveCandidateModel(providerCatalogOptions.Value, activeProviderName, requestedModel);
151+
var activeModel = ResolveCandidateModel(
152+
providerCatalogOptions.Value,
153+
resolvedProviderName,
154+
activeProviderName,
155+
requestedModel);
148156

149157
// --- Build initial conversation messages ---
150158
// Do not add request.Instructions as a shared "system" chat message here.
@@ -185,7 +193,11 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
185193
for (var candidateIndex = activeProviderIndex; candidateIndex < providerCandidates.Count; candidateIndex++)
186194
{
187195
var provider = providerCandidates[candidateIndex];
188-
var candidateModel = ResolveCandidateModel(providerCatalogOptions.Value, provider.ProviderName, requestedModel);
196+
var candidateModel = ResolveCandidateModel(
197+
providerCatalogOptions.Value,
198+
resolvedProviderName,
199+
provider.ProviderName,
200+
requestedModel);
189201
var providerRequest = providerRequestPreflight.Prepare(new ProviderRequest(
190202
Id: $"provider-request-{Guid.NewGuid():N}",
191203
SessionId: request.Context.SessionId,
@@ -451,8 +463,14 @@ private static string CreateProviderFailedEventMessage(string providerName, Prov
451463
return $"Provider '{providerName}' stream failed: {detail}";
452464
}
453465

454-
private static string ResolveCandidateModel(ProviderCatalogOptions options, string providerName, string primaryModel)
455-
=> options.FallbackModels.TryGetValue(providerName, out var fallbackModel) && !string.IsNullOrWhiteSpace(fallbackModel)
466+
private static string ResolveCandidateModel(
467+
ProviderCatalogOptions options,
468+
string primaryProviderName,
469+
string candidateProviderName,
470+
string primaryModel)
471+
=> !string.Equals(candidateProviderName, primaryProviderName, StringComparison.OrdinalIgnoreCase)
472+
&& options.FallbackModels.TryGetValue(candidateProviderName, out var fallbackModel)
473+
&& !string.IsNullOrWhiteSpace(fallbackModel)
456474
? fallbackModel
457475
: primaryModel;
458476
}

‎src/SharpClaw.Code.Infrastructure/Services/PlatformSecretProtector.cs‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,33 +112,56 @@ private string UnprotectWithAes(string encodedPayload)
112112

113113
private byte[] GetOrCreateUnixKey()
114114
{
115+
if (OperatingSystem.IsWindows())
116+
{
117+
throw new PlatformNotSupportedException("AES key-file protection is available only on macOS and Linux.");
118+
}
119+
115120
var root = userProfilePaths.GetUserSharpClawRoot();
116-
Directory.CreateDirectory(root);
121+
var directoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
122+
Directory.CreateDirectory(root, directoryMode);
123+
File.SetUnixFileMode(root, directoryMode);
117124
var keyPath = Path.Combine(root, KeyFileName);
118125

119126
if (!File.Exists(keyPath))
120127
{
128+
var temporaryKeyPath = Path.Combine(root, $".{KeyFileName}.{Guid.NewGuid():N}.tmp");
121129
var generatedKey = RandomNumberGenerator.GetBytes(32);
122130
try
123131
{
124-
using var stream = new FileStream(keyPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
132+
using var stream = new FileStream(temporaryKeyPath, new FileStreamOptions
133+
{
134+
Mode = FileMode.CreateNew,
135+
Access = FileAccess.Write,
136+
Share = FileShare.None,
137+
Options = FileOptions.WriteThrough,
138+
UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite,
139+
});
125140
stream.Write(generatedKey);
141+
stream.Flush(flushToDisk: true);
142+
File.Move(temporaryKeyPath, keyPath, overwrite: false);
126143
}
127144
catch (IOException) when (File.Exists(keyPath))
128145
{
129146
// Another process created the same user-scoped key first.
130147
}
131148
finally
132149
{
150+
if (File.Exists(temporaryKeyPath))
151+
{
152+
File.Delete(temporaryKeyPath);
153+
}
154+
133155
CryptographicOperations.ZeroMemory(generatedKey);
134156
}
135157
}
136158

137-
if (!OperatingSystem.IsWindows())
159+
if ((File.GetAttributes(keyPath) & FileAttributes.ReparsePoint) != 0)
138160
{
139-
File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
161+
throw new CryptographicException($"The local secret-protection key at '{keyPath}' cannot be a symbolic link.");
140162
}
141163

164+
File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
142165
var key = File.ReadAllBytes(keyPath);
143166
return key.Length == 32
144167
? key

‎src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ private async IAsyncEnumerable<ProviderEvent> ExecuteWithResilienceAsync(
5050
[EnumeratorCancellation] CancellationToken callerCancellationToken)
5151
{
5252
Exception? lastException = null;
53+
var attemptsMade = 0;
5354

5455
for (var attempt = 0; attempt <= _options.MaxRetries; attempt++)
5556
{
57+
attemptsMade = attempt + 1;
5658
callerCancellationToken.ThrowIfCancellationRequested();
5759
using var timeoutCts = new CancellationTokenSource(_options.RequestTimeout);
5860
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(callerCancellationToken, timeoutCts.Token);
@@ -140,7 +142,7 @@ private async IAsyncEnumerable<ProviderEvent> ExecuteWithResilienceAsync(
140142

141143
lastException = attemptException;
142144
RecordFailureAndOpenCircuitIfNeeded();
143-
if (emittedEvent || attempt >= _options.MaxRetries)
145+
if (emittedEvent || attempt >= _options.MaxRetries || IsCircuitOpen())
144146
{
145147
break;
146148
}
@@ -160,7 +162,7 @@ private async IAsyncEnumerable<ProviderEvent> ExecuteWithResilienceAsync(
160162
ProviderName,
161163
request.Model,
162164
ProviderFailureKind.StreamFailed,
163-
$"Provider '{ProviderName}' failed while streaming after {_options.MaxRetries + 1} attempt(s).",
165+
$"Provider '{ProviderName}' failed while streaming after {attemptsMade} attempt(s).",
164166
lastException);
165167
}
166168

@@ -205,7 +207,7 @@ private static bool IsTransient(Exception exception)
205207
}
206208

207209
return exception is not ArgumentException
208-
&& exception is HttpRequestException or TaskCanceledException or TimeoutException or IOException;
210+
&& exception is HttpRequestException or OperationCanceledException or TimeoutException or IOException;
209211
}
210212

211213
private TimeSpan ComputeDelay(int attempt, Exception exception)
@@ -237,6 +239,14 @@ private void ResetCircuit()
237239
}
238240
}
239241

242+
private bool IsCircuitOpen()
243+
{
244+
lock (_lock)
245+
{
246+
return _circuitOpen;
247+
}
248+
}
249+
240250
private void RecordFailureAndOpenCircuitIfNeeded()
241251
{
242252
lock (_lock)

‎src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,16 @@ ON CONFLICT(session_id) DO UPDATE SET
5959
.ConfigureAwait(false);
6060
await using var command = connection.CreateCommand();
6161
command.CommandText = """
62-
SELECT payload_json
62+
SELECT session_id, payload_json
6363
FROM sessions
6464
ORDER BY updated_at_utc DESC;
6565
""";
6666
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
6767
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
6868
{
69-
var payload = reader.IsDBNull(0) ? null : reader.GetString(0);
70-
var session = Deserialize(payload, sessionId: null);
69+
var sessionId = reader.IsDBNull(0) ? null : reader.GetString(0);
70+
var payload = reader.IsDBNull(1) ? null : reader.GetString(1);
71+
var session = Deserialize(payload, sessionId);
7172
if (session is not null)
7273
{
7374
return session;
@@ -85,7 +86,7 @@ public async Task<IReadOnlyList<ConversationSession>> ListAllAsync(string worksp
8586
.ConfigureAwait(false);
8687
await using var command = connection.CreateCommand();
8788
command.CommandText = """
88-
SELECT payload_json
89+
SELECT session_id, payload_json
8990
FROM sessions
9091
ORDER BY updated_at_utc DESC;
9192
""";
@@ -94,8 +95,9 @@ FROM sessions
9495
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
9596
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
9697
{
97-
var payload = reader.IsDBNull(0) ? null : reader.GetString(0);
98-
var session = Deserialize(payload, sessionId: null);
98+
var sessionId = reader.IsDBNull(0) ? null : reader.GetString(0);
99+
var payload = reader.IsDBNull(1) ? null : reader.GetString(1);
100+
var session = Deserialize(payload, sessionId);
99101
if (session is not null)
100102
{
101103
sessions.Add(session);

0 commit comments

Comments
 (0)