Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions src/Netclaw.Cli.Tests/Doctor/SecurityPolicyDoctorCheckTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="SecurityPolicyDoctorCheckTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -57,8 +57,10 @@ public async Task NullPosture_StrictDisabled_IsError()
}

[Fact]
public async Task ExplicitPersonalPosture_HostAllowed_Warns()
public async Task ExplicitPersonalPosture_HostAllowed_Passes()
{
// When the user explicitly sets Personal + HostAllowed, doctor should
// respect that intentional choice and pass cleanly.
WriteConfig("""
{
"configVersion": 1,
Expand All @@ -73,8 +75,31 @@ public async Task ExplicitPersonalPosture_HostAllowed_Warns()
var check = new SecurityPolicyDoctorCheck(_paths);
var result = await check.RunAsync(TestContext.Current.CancellationToken);

Assert.Equal(DoctorSeverity.Warning, result.Severity);
Assert.Contains("full host access", result.Message);
Assert.Equal(DoctorSeverity.Pass, result.Severity);
Assert.Contains("Personal", result.Message);
}

[Fact]
public async Task ImplicitPersonalPosture_HostAllowed_Warns()
{
// When DeploymentPosture is missing and StrictDefaults is false,
// the fallback resolves to Personal with HostAllowed — this should
// warn because the user didn't explicitly choose this.
WriteConfig("""
{
"configVersion": 1,
"Security": {
"StrictDefaults": false
}
}
""");

var check = new SecurityPolicyDoctorCheck(_paths);
var result = await check.RunAsync(TestContext.Current.CancellationToken);

// StrictDefaults=false with no DeploymentPosture is an Error first
Assert.Equal(DoctorSeverity.Error, result.Severity);
Assert.Contains("silently assumes Personal posture", result.Message);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="ToolAudienceProfilesDoctorCheckTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -87,8 +87,11 @@ public async Task TeamFilesystemAll_IsError()
}

[Fact]
public async Task UnrestrictedPersonalProfile_Warns()
public async Task UnrestrictedPersonalProfile_Explicit_NoUnrestrictedWarning()
{
// When Personal profile is explicitly written, unrestricted access is intentional.
// Doctor should not warn about the unrestricted profile itself, but may still
// warn about missing shell_execute approval gate.
WriteConfig(
"""
{
Expand All @@ -111,9 +114,38 @@ public async Task UnrestrictedPersonalProfile_Warns()
var check = new ToolAudienceProfilesDoctorCheck(_paths);
var result = await check.RunAsync(TestContext.Current.CancellationToken);

Assert.Equal(DoctorSeverity.Warning, result.Severity);
// Should not warn about unrestricted profile when it's explicit
Assert.DoesNotContain("Personal profile allows all tools", result.Message);
// May still warn about missing shell approval gate (different message)
Assert.Contains("without an explicit shell_execute approval gate", result.Message);
}

[Fact]
public async Task UnrestrictedPersonalProfile_Implicit_Warns()
{
// When Personal profile is NOT in config (fallback defaults), unrestricted
// access should warn. AudienceProfiles must exist but Personal must be absent.
WriteConfig(
"""
{
"configVersion": 1,
"Tools": {
"ShellMode": "HostAllowed",
"AudienceProfiles": {
"Public": {
"ToolsMode": "AllowList"
}
}
}
}
""");

var check = new ToolAudienceProfilesDoctorCheck(_paths);
var result = await check.RunAsync(TestContext.Current.CancellationToken);

// Should warn about missing profiles and unrestricted fallback
Assert.Contains("Missing explicit profiles for", result.Message);
Assert.Contains("Personal profile allows all tools", result.Message);
Assert.Contains("host shell", result.Message);
}

[Fact]
Expand Down Expand Up @@ -191,8 +223,11 @@ public async Task McpServerWithToolGrants_NoSupplyChainWarning()
}

[Fact]
public async Task RecommendedProfiles_Pass()
public async Task RecommendedProfiles_WarnsAboutShellGateOnly()
{
// CreateProfiles() writes all three profiles explicitly, so Personal is
// explicit. The unrestricted warning is suppressed, but the shell
// approval gate warning still fires (no ApprovalPolicy on Personal).
var toolConfig = new ToolConfig
{
ShellMode = ShellExecutionMode.HostAllowed,
Expand All @@ -209,7 +244,9 @@ public async Task RecommendedProfiles_Pass()
var result = await check.RunAsync(TestContext.Current.CancellationToken);

Assert.Equal(DoctorSeverity.Warning, result.Severity);
Assert.Contains("Personal profile allows all tools", result.Message);
// Unrestricted warning suppressed for explicit Personal
Assert.DoesNotContain("Personal profile allows all tools", result.Message);
// Shell approval gate warning still fires
Assert.Contains("without an explicit shell_execute approval gate", result.Message);
}

Expand Down
8 changes: 6 additions & 2 deletions src/Netclaw.Cli/Doctor/SecurityPolicyDoctorCheck.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="SecurityPolicyDoctorCheck.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -63,8 +63,12 @@ public Task<DoctorCheckResult> RunAsync(CancellationToken cancellationToken = de
warnings.Add("DeploymentPosture not set; strict fallback resolved to Public.");
}

// Only warn about Personal + HostAllowed when the posture is implicit
// (resolved from fallback defaults). If the user explicitly set
// DeploymentPosture = Personal, they chose this intentionally.
if (effective.DeploymentPosture == DeploymentPosture.Personal
&& effective.ShellExecutionMode == ShellExecutionMode.HostAllowed)
&& effective.ShellExecutionMode == ShellExecutionMode.HostAllowed
&& !config.DeploymentPosture.HasValue)
{
warnings.Add("Personal posture with HostAllowed shell — full host access is enabled.");
}
Expand Down
9 changes: 7 additions & 2 deletions src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="ToolAudienceProfilesDoctorCheck.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -90,7 +90,12 @@ public Task<DoctorCheckResult> RunAsync(CancellationToken cancellationToken = de
warnings.Add($"Missing explicit profiles for {string.Join(", ", missingProfiles)}; fallback defaults are in effect.");
}

if (IsUnrestrictedPersonalProfile(toolConfig.AudienceProfiles.Personal))
// Only warn about unrestricted Personal when it's using fallback defaults.
// If the Personal profile was explicitly written (e.g., by `netclaw init`),
// the user made an intentional choice and this warning is noise.
var personalExplicit = !missingProfiles.Contains("personal");
if (IsUnrestrictedPersonalProfile(toolConfig.AudienceProfiles.Personal)
&& !personalExplicit)
{
warnings.Add("Personal profile allows all tools and unrestricted filesystem access.");
if (toolConfig.ShellMode == ShellExecutionMode.HostAllowed)
Expand Down
Loading