diff --git a/src/Netclaw.Cli.Tests/Doctor/SecurityPolicyDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/SecurityPolicyDoctorCheckTests.cs index c8d17d1e..2622220a 100644 --- a/src/Netclaw.Cli.Tests/Doctor/SecurityPolicyDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/SecurityPolicyDoctorCheckTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -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, @@ -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] diff --git a/src/Netclaw.Cli.Tests/Doctor/ToolAudienceProfilesDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/ToolAudienceProfilesDoctorCheckTests.cs index a231d36c..380da7cd 100644 --- a/src/Netclaw.Cli.Tests/Doctor/ToolAudienceProfilesDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/ToolAudienceProfilesDoctorCheckTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -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( """ { @@ -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] @@ -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, @@ -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); } diff --git a/src/Netclaw.Cli/Doctor/SecurityPolicyDoctorCheck.cs b/src/Netclaw.Cli/Doctor/SecurityPolicyDoctorCheck.cs index c59688da..86bb932b 100644 --- a/src/Netclaw.Cli/Doctor/SecurityPolicyDoctorCheck.cs +++ b/src/Netclaw.Cli/Doctor/SecurityPolicyDoctorCheck.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -63,8 +63,12 @@ public Task 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."); } diff --git a/src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs b/src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs index 861eca11..1ca6646d 100644 --- a/src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs +++ b/src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -90,7 +90,12 @@ public Task 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)