From 7b4e3b3f6735b0ffc7626a9efab93acb364a60a3 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 20:05:18 +0000 Subject: [PATCH 1/5] test: pin adversarial shell policy boundaries --- .../evidence/netclaw-policy-fixtures.json | 112 +++++++++++++++- .../structure-shell-approval-policy/tasks.md | 12 +- .../Tools/ShellApprovalCaseCatalog.cs | 24 ++-- ...roval_cases_match_review_table.verified.md | 12 +- .../Tools/ShellPolicyEvidenceFixtureTests.cs | 120 +++++++++++++++++- .../Tools/ToolApprovalActorTests.cs | 87 +++++++++++++ .../Tools/ScopedShellSafeVerbPolicy.cs | 2 +- .../ShellApprovalEvidenceContractTests.cs | 18 ++- .../ShellPolicyEvidenceModels.cs | 36 ++++++ 9 files changed, 387 insertions(+), 36 deletions(-) diff --git a/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json b/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json index 1b31643ea..0cafe70e0 100644 --- a/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json +++ b/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json @@ -1,5 +1,5 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "fixtureDefaults": { "toolName": "shell_execute", "audience": "Personal", @@ -409,5 +409,115 @@ ], "expectedFinal": { "outcome": "RequiresApproval", "reason": "UncoveredCandidates", "approvalCandidates": ["gh pr close"], "isMessy": false, "agentCorrection": null } } + ], + "adversarialCases": [ + { + "id": "A01", + "category": "DynamicIdentity", + "command": "\"$tool\" --version", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": [], "isMessy": true, "optionKeys": ["approve_once", "deny"], "actorCheckCount": 0 } + }, + { + "id": "A02", + "category": "DenyOnlyWrapper", + "command": "bash -c 'rm -rf /'", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, + "expected": { "outcome": "Deny", "denyReason": "hard_deny_system_destructive", "approvalCandidates": null, "isMessy": null, "optionKeys": null, "actorCheckCount": 0 } + }, + { + "id": "A03", + "category": "FileWritingRedirect", + "command": "head README.md > report.txt", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [{ "tokens": ["head"], "proof": "ReviewedDiagnostic" }] }, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["head"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } + }, + { + "id": "A04", + "category": "ProtectedPath", + "command": "Get-Content C:\\protected\\config", + "environment": { "platform": "Windows", "executablePath": "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "commandArguments": ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], "grammar": "PowerShell", "pathStyle": "Windows", "powerShellDialect": "PowerShell7" }, + "initialWorkingDirectory": "C:\\work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [{ "tokens": ["Get-Content"], "proof": "ReviewedDiagnostic" }] }, + "expected": { "outcome": "Deny", "denyReason": "shell_references_protected_path", "approvalCandidates": null, "isMessy": null, "optionKeys": null, "actorCheckCount": 0 } + }, + { + "id": "A05", + "category": "PrefixCollision", + "command": "git statusx", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [{ "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "status"], "directory": null }], "safePhrases": [] }, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["git statusx"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } + }, + { + "id": "A06", + "category": "RuntimeIterator", + "command": "for f in $(list-files); do cat \"$f\"; done", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [{ "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cat"], "directory": null }], + "safePhrases": [{ "tokens": ["cat"], "proof": "ReviewedDiagnostic" }] + }, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": [], "isMessy": true, "optionKeys": ["approve_once", "deny"], "actorCheckCount": 0 } + }, + { + "id": "A07", + "category": "PowerShellProvider", + "command": "Get-Content Env:SECRET", + "environment": { "platform": "Windows", "executablePath": "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "commandArguments": ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], "grammar": "PowerShell", "pathStyle": "Windows", "powerShellDialect": "PowerShell7" }, + "initialWorkingDirectory": "C:\\work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [{ "tokens": ["Get-Content"], "proof": "ReviewedDiagnostic" }] }, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": [], "isMessy": true, "optionKeys": ["approve_once", "deny"], "actorCheckCount": 0 } + }, + { + "id": "A08", + "category": "UnsafeCatalogFindExec", + "command": "find . -exec rm {} +", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, + "useBundledSafeCatalog": true, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["find"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } + }, + { + "id": "A09", + "category": "UnsafeCatalogAwkSystem", + "command": "awk 'BEGIN { system(\"touch marker\") }'", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, + "useBundledSafeCatalog": true, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["awk"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } + }, + { + "id": "A10", + "category": "UnsafeCatalogRgPreprocessor", + "command": "rg --pre helper pattern .", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, + "useBundledSafeCatalog": true, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["rg"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } + }, + { + "id": "A11", + "category": "UnsafeCatalogSortOutput", + "command": "sort -o output input", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, + "useBundledSafeCatalog": true, + "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["sort"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } + } ] } diff --git a/openspec/changes/structure-shell-approval-policy/tasks.md b/openspec/changes/structure-shell-approval-policy/tasks.md index fa6621c2b..5ce24163a 100644 --- a/openspec/changes/structure-shell-approval-policy/tasks.md +++ b/openspec/changes/structure-shell-approval-policy/tasks.md @@ -10,7 +10,7 @@ candidates, phrases, scopes, grant and safe inputs, coverage, ordered trace, and outcome for D02, D03, D07, D08, D09, D10, D11, D14, D17, and D18; tests must load explicit authority defaults and fields, not branch on IDs. -- [ ] 1.5 Add adversarial dynamic identity, redirect, protected path, prefix +- [x] 1.5 Add adversarial dynamic identity, redirect, protected path, prefix collision, runtime loop, wrapper, provider, and unsafe-catalog cases. - [x] 1.6 Run the PII audit and manually inspect every command and fixture. @@ -28,7 +28,7 @@ `ToolApprovalAttempt`. - [x] 2.4 Route `DispatchingToolExecutor` through the coordinator without changing the original source, argument object, or tool history. -- [ ] 2.5 Preserve session-pipeline pending-request persistence, +- [x] 2.5 Preserve session-pipeline pending-request persistence, stale/duplicate response rejection and recovery; preserve exact-set one-time retry in `ToolApprovalAttempt` and actor-owned subagent scope inheritance. @@ -47,9 +47,9 @@ reviewed-safe phrase coverage for an interactive run. Deny with `ApprovalStoreUnavailable` rather than open a prompt when any candidate still depends on persistent state. -- [ ] 3.5 Let expected unresolved shell input offer only one-time approval and +- [x] 3.5 Let expected unresolved shell input offer only one-time approval and deny; never create a reusable candidate. -- [ ] 3.6 Keep legacy token scans deny-only and prove they cannot authorize, +- [x] 3.6 Keep legacy token scans deny-only and prove they cannot authorize, create persistence choices, or widen scope. - [x] 3.7 Apply reviewed-safe phrase coverage only when interactive approval is available. Prove that unattended calls need explicit one-time or stored-grant @@ -66,9 +66,9 @@ internal store failures deny and never salvage partial authority. - [x] 4.4 Use the same typed phrase comparison for one-time, session, persistent, global, and folder coverage. -- [ ] 4.5 Require real exact scope, normalization, containment, and symlink +- [x] 4.5 Require real exact scope, normalization, containment, and symlink checks for folder grants; global grants do not require cwd. -- [ ] 4.6 Preserve prompt display/spoof protections and store one clean entry per +- [x] 4.6 Preserve prompt display/spoof protections and store one clean entry per persistable candidate. - [x] 4.7 Update CLI list/add/revoke behavior and operator docs for schema 3, including manual rollback by restoring the preserved `.v2.bak` while the diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs index 330bb913b..f22a1e81d 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs @@ -323,10 +323,10 @@ public static class ShellApprovalCases Approvals.None, ExpectedApproval.Require(["git status"])), Case( - "safe-verb-null-device-redirect-allows", + "safe-verb-null-device-redirect-prompts", Bash("ls -la 2>/dev/null"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + ExpectedApproval.Require(["ls"])), Case( "mutating-verb-project-prompts", Bash("git push"), @@ -802,10 +802,10 @@ public static class ShellApprovalCases Approvals.None, ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), Case( - "powershell7-local-redirect-keeps-safe-command", + "powershell7-local-redirect-prompts-for-writer", PowerShell7(@"Get-Content .\input.txt > .\output.txt"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + ExpectedApproval.Require(["Get-Content"])), Case( "powershell7-protected-path-denies-before-approval", PowerShell7(@"Get-Content C:\protected\config\secret.txt"), @@ -992,20 +992,20 @@ public static class ShellApprovalCases Approvals.None, ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), Case( - "combined-output-project-redirect-safe-verb-allows", + "combined-output-project-redirect-safe-verb-prompts", Bash("git status &> result.log"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + ExpectedApproval.Require(["git status"])), Case( - "combined-output-append-project-redirect-safe-verb-allows", + "combined-output-append-project-redirect-safe-verb-prompts", Bash("git status &>> result.log"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + ExpectedApproval.Require(["git status"])), Case( - "numeric-source-project-redirect-safe-verb-allows", + "numeric-source-project-redirect-safe-verb-prompts", Bash("git status 3> result.log"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + ExpectedApproval.Require(["git status"])), Case( "fd-dup-redirect-mutating-no-grant-prompts-not-messy", Bash("git push origin dev 2>&1 | tail -2"), @@ -1299,10 +1299,10 @@ public static class ShellApprovalCases Approvals.PersistentHere(ApprovalDirectoryShape.Project, "printf"), ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:printf")), Case( - "workload-edit-search-pipeline-redirect-in-project-allows", + "workload-edit-search-pipeline-redirect-in-project-prompts-for-writer", Bash("grep -R \"error\" logs | head -20 > reports/errors.txt"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + ExpectedApproval.Require(["head"])), Case( "workload-edit-search-pipeline-redirect-external-prompts", Bash( diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md b/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md index 1b6e0e97c..50810ab7f 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md @@ -25,7 +25,7 @@ | safe-verb-traversal-external-path-prompts | Bash | Personal | Project | Interactive | cat safe/../../../../../../etc/netclaw.secret | none | RequiresApproval | approval required | cat | No | | safe-verb-bash-provider-looking-relative-path-allows | Bash | Personal | Project | Interactive | cat filesystem::/etc/netclaw.secret | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | | safe-verb-external-redirect-prompts | Bash | Personal | Project | Interactive | git status > /netclaw-approval-external/netclaw-approval-matrix.txt | none | RequiresApproval | approval required | git status | No | -| safe-verb-null-device-redirect-allows | Bash | Personal | Project | Interactive | ls -la 2>/dev/null | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| safe-verb-null-device-redirect-prompts | Bash | Personal | Project | Interactive | ls -la 2>/dev/null | none | RequiresApproval | approval required | ls | No | | mutating-verb-project-prompts | Bash | Personal | Project | Interactive | git push | none | RequiresApproval | approval required | git push | No | | all-safe-compound-allows | Bash | Personal | Project | Interactive | git status && git ls-tree HEAD | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | | four-safe-mixed-operator-clauses-allow | Bash | Personal | Project | Interactive | git status && git ls-tree HEAD \| head -20; pwd | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | @@ -113,7 +113,7 @@ | powershell7-callback-region-body-grant-does-not-cover-host | PowerShell7 | Personal | Project | Interactive | Get-ChildItem \| ForEach-Object { Remove-Item .\victim.txt } | persistent[project]:Remove-Item | RequiresApproval | approval required | ForEach-Object | No | | powershell7-unknown-region-grants-do-not-cover-incomplete-receiver | PowerShell7 | Personal | Project | Interactive | Invoke-Custom { Remove-Item .\victim.txt } | persistent[project]:Invoke-Custom, persistent[project]:Remove-Item | RequiresApproval | approval required | none | Yes | | powershell7-alias-resolves-before-safe-verb-check | PowerShell7 | Personal | Project | Interactive | gci | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| powershell7-local-redirect-keeps-safe-command | PowerShell7 | Personal | Project | Interactive | Get-Content .\input.txt > .\output.txt | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| powershell7-local-redirect-prompts-for-writer | PowerShell7 | Personal | Project | Interactive | Get-Content .\input.txt > .\output.txt | none | RequiresApproval | approval required | Get-Content | No | | powershell7-protected-path-denies-before-approval | PowerShell7 | Personal | Project | Interactive | Get-Content C:\protected\config\secret.txt | persistent[anywhere]:Get-Content | Denied | shell_references_protected_path | none | Not applicable | | powershell7-provider-drive-is-reviewed | PowerShell7 | Personal | Project | Interactive | Get-Content Env:\Path | none | RequiresApproval | approval required | none | Yes | | powershell7-environment-provider-value-stays-strict | PowerShell7 | Personal | Project | Interactive | Get-Content Env:SECRET | none | RequiresApproval | approval required | none | Yes | @@ -151,9 +151,9 @@ | fd-dup-redirect-safe-pipeline-allows | Bash | Personal | Project | Interactive | git ls-tree HEAD 2>&1 \| tail -20 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | | fd-close-redirect-safe-verb-allows | Bash | Personal | Project | Interactive | git status 2>&- | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | | fd-move-redirect-safe-verb-allows | Bash | Personal | Project | Interactive | git status 2>&1- | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| combined-output-project-redirect-safe-verb-allows | Bash | Personal | Project | Interactive | git status &> result.log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| combined-output-append-project-redirect-safe-verb-allows | Bash | Personal | Project | Interactive | git status &>> result.log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| numeric-source-project-redirect-safe-verb-allows | Bash | Personal | Project | Interactive | git status 3> result.log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| combined-output-project-redirect-safe-verb-prompts | Bash | Personal | Project | Interactive | git status &> result.log | none | RequiresApproval | approval required | git status | No | +| combined-output-append-project-redirect-safe-verb-prompts | Bash | Personal | Project | Interactive | git status &>> result.log | none | RequiresApproval | approval required | git status | No | +| numeric-source-project-redirect-safe-verb-prompts | Bash | Personal | Project | Interactive | git status 3> result.log | none | RequiresApproval | approval required | git status | No | | fd-dup-redirect-mutating-no-grant-prompts-not-messy | Bash | Personal | Project | Interactive | git push origin dev 2>&1 \| tail -2 | none | RequiresApproval | approval required | git push origin dev | No | | dynamic-fd-redirect-fails-closed | Bash | Personal | Project | Interactive | git status 2>&$FD | none | RequiresApproval | approval required | none | Yes | | background-list-prompts-for-mutating-tail | Bash | Personal | Project | Interactive | git status & git push | none | RequiresApproval | approval required | none | Yes | @@ -210,7 +210,7 @@ | workload-edit-remove-grant-allows | Bash | Personal | Project | Interactive | rm -- src/obsolete.txt | persistent[project]:rm | Allowed | StoredApproval | none | Not applicable | | workload-edit-printf-redirect-prompts | Bash | Personal | Project | Interactive | printf '%s\n' "text" > reports/output.txt | none | RequiresApproval | approval required | printf | No | | workload-edit-printf-redirect-grant-allows | Bash | Personal | Project | Interactive | printf '%s\n' "text" > reports/output.txt | persistent[project]:printf | Allowed | StoredApproval | none | Not applicable | -| workload-edit-search-pipeline-redirect-in-project-allows | Bash | Personal | Project | Interactive | grep -R "error" logs \| head -20 > reports/errors.txt | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| workload-edit-search-pipeline-redirect-in-project-prompts-for-writer | Bash | Personal | Project | Interactive | grep -R "error" logs \| head -20 > reports/errors.txt | none | RequiresApproval | approval required | head | No | | workload-edit-search-pipeline-redirect-external-prompts | Bash | Personal | External | Interactive | grep -R "error" logs \| head -20 > reports/errors.txt | none | RequiresApproval | approval required | grep, head | No | | workload-edit-search-pipeline-redirect-external-grant-allows | Bash | Personal | External | Interactive | grep -R "error" logs \| head -20 > reports/errors.txt | persistent[external]:grep, persistent[external]:head | Allowed | StoredApproval | none | Not applicable | | workload-search-loop-inherited-state-prompts | Bash | Personal | Project | Interactive | for f in src/*.cs; do grep -n "TODO" "$f"; done | persistent[project]:grep | RequiresApproval | approval required | none | Yes | diff --git a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs index 7b43368f5..61f6c51a1 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs @@ -49,7 +49,7 @@ public async Task Policy_fixtures_execute_through_the_coordinator() catalog.FixtureDefaults.ProjectDirectory, catalog.FixtureDefaults.Session.SessionDirectory, catalog.FixtureDefaults.Session.SessionId), - CreateSafeVerbs(policyCase)); + CreateSafeVerbs(policyCase.Available, invocation.CreateEnvironment())); var decision = await harness.EvaluateDecisionAsync(TestContext.Current.CancellationToken); TestContext.Current.TestOutputHelper?.WriteLine( $"{policyCase.EvidenceId}: outcome={decision.Outcome}; " @@ -76,6 +76,60 @@ public async Task Policy_fixtures_execute_through_the_coordinator() Assert.Equal(expectedRows, actualRows); } + [Fact] + public async Task Adversarial_policy_fixtures_fail_closed_through_the_coordinator() + { + var catalog = JsonSerializer.Deserialize( + File.ReadAllBytes(EvidencePath()), + ShellPolicyFixtureJsonContext.Default.PolicyFixtureCatalog) + ?? throw new InvalidDataException("The policy fixture catalog has no root object."); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse( + catalog.FixtureDefaults.ClockUtc, + CultureInfo.InvariantCulture)); + + foreach (var policyCase in catalog.AdversarialCases) + { + var invocation = CreateInvocation(catalog.FixtureDefaults, policyCase); + var approvals = CreateApprovals(policyCase.Available); + await using var harness = await ShellApprovalHarness.CreateAsync( + policyCase.Id, + invocation, + approvals, + fixture.ActorSystem, + TestContext.Current.CancellationToken, + timeProvider, + new ShellApprovalHarnessScope( + catalog.FixtureDefaults.ProjectDirectory, + catalog.FixtureDefaults.Session.SessionDirectory, + catalog.FixtureDefaults.Session.SessionId), + policyCase.UseBundledSafeCatalog + ? null + : CreateSafeVerbs(policyCase.Available, invocation.CreateEnvironment())); + + var decision = await harness.EvaluateDecisionAsync(TestContext.Current.CancellationToken); + TestContext.Current.TestOutputHelper?.WriteLine( + $"{policyCase.Id} ({policyCase.Category}): outcome={decision.Outcome}; " + + $"deny={decision.DenyReason}; " + + $"candidates={string.Join(", ", decision.ApprovalContext?.CandidateVerbs ?? [])}; " + + $"messy={decision.ApprovalContext?.IsMessy}; " + + $"checks={harness.ApprovalService.CheckCount}; " + + $"allow={decision.AllowReason}; " + + $"matches={string.Join(", ", decision.ApprovalMatches.Select(item => item.Pattern))}; " + + $"trace={string.Join(", ", decision.ShellPolicyTrace.Rows.Select(row => $"{row.Stage}/{row.Reason}"))}"); + + Assert.Equal(ParseOutcome(policyCase.Expected.Outcome), decision.Outcome); + Assert.Equal(policyCase.Expected.DenyReason, decision.DenyReason); + Assert.Equal( + policyCase.Expected.ApprovalCandidates, + decision.ApprovalContext?.CandidateVerbs); + Assert.Equal(policyCase.Expected.IsMessy, decision.ApprovalContext?.IsMessy); + Assert.Equal( + policyCase.Expected.OptionKeys, + decision.ApprovalContext?.Options.Select(option => option.Key.Value).ToList()); + Assert.Equal(policyCase.Expected.ActorCheckCount, harness.ApprovalService.CheckCount); + } + } + private static void AssertProjectedCandidates( PolicyFixtureCase policyCase, ShellApprovalInvocation invocation) @@ -190,23 +244,75 @@ private static ShellApprovalInvocation CreateInvocation( defaults.InteractiveApprovalCapability == "Available"); } + private static ShellApprovalInvocation CreateInvocation( + PolicyFixtureDefaults defaults, + PolicyAdversarialCase policyCase) + { + if (defaults.ToolName != "shell_execute" + || defaults.ApprovalMode != "Approval" + || defaults.PersistentStoreStatus != "Ready" + || defaults.InheritedWorkingDirectory is not null) + { + throw new InvalidDataException($"Unsupported fixture defaults: {policyCase.Id}."); + } + + var host = policyCase.Environment switch + { + { + Grammar: "Bash", + Platform: "Linux", + PathStyle: "Posix", + ExecutablePath: "/bin/bash", + PowerShellDialect: null + } when policyCase.Environment.CommandArguments.SequenceEqual(["-c"]) + => ShellApprovalHost.Bash, + { + Grammar: "PowerShell", + Platform: "Windows", + PathStyle: "Windows", + PowerShellDialect: "PowerShell7" + } => ShellApprovalHost.PowerShell7, + { + Grammar: "PowerShell", + Platform: "Windows", + PathStyle: "Windows", + PowerShellDialect: "WindowsPowerShell51" + } => ShellApprovalHost.WindowsPowerShell51, + _ => throw new InvalidDataException($"Unsupported fixture environment: {policyCase.Id}.") + }; + + return new ShellApprovalInvocation( + policyCase.Command, + ApprovalDirectoryShape.Project, + Enum.Parse(defaults.Audience), + defaults.InteractiveApprovalCapability == "Available", + host); + } + private static ApprovalState CreateApprovals(PolicyFixtureCase policyCase) - => new(policyCase.Available.PersistentGrants + => CreateApprovals(policyCase.Available); + + private static ApprovalState CreateApprovals(PolicyFixtureAuthority available) + => new(available.PersistentGrants .Select(CreatePersistentSeed) - .Concat(policyCase.Available.SessionGrants.Select(CreateSessionSeed)) + .Concat(available.SessionGrants.Select(CreateSessionSeed)) .ToList()); - private static SafeVerbList CreateSafeVerbs(PolicyFixtureCase policyCase) + private static SafeVerbList CreateSafeVerbs( + PolicyFixtureAuthority available, + ShellExecutionEnvironment environment) { - if (policyCase.Available.SafePhrases.Any(phrase => + if (available.SafePhrases.Any(phrase => phrase.Proof != "ReviewedDiagnostic")) { throw new InvalidDataException("The fixture has an unsupported safe-phrase proof."); } return SafeVerbList.FromVerbs( - ApprovalShell.Bash, - policyCase.Available.SafePhrases.Select(phrase => + environment.Grammar == ShellGrammar.Bash + ? ApprovalShell.Bash + : ApprovalShell.PowerShell, + available.SafePhrases.Select(phrase => string.Join(' ', phrase.Tokens))); } diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs index 491a6c1f5..ce5d4e2d1 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs @@ -726,6 +726,93 @@ [new ToolApprovalGrant(candidate, Directory: null)], } } + [Fact] + public async Task Persistent_structured_batch_stores_each_clean_candidate_atomically() + { + var ct = TestContext.Current.CancellationToken; + var tempFile = Path.GetTempFileName(); + try + { + var store = CreateStore(tempFile); + var actor = Sys.ActorOf(ToolApprovalActor.CreateProps(store)); + var service = CreateService(actor); + var first = NativeCandidate("git status"); + var second = NativeCandidate("head"); + + await service.RecordApprovalCandidatesAsync( + (ToolApprovalSessionId)"session-a", + TrustAudience.Personal, + new ToolName("shell_execute"), + [ + new ToolApprovalGrant(first, Directory: null), + new ToolApprovalGrant(second, Directory: null) + ], + persistent: true, + ct); + + var entries = store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Equal(2, entries.Count); + Assert.Equal( + ["git status", "head"], + entries.Select(static entry => entry.Verb)); + + var result = await ((IShellApprovalMatchService)service).MatchShellCandidatesAsync( + new ShellApprovalMatchRequest( + SessionId: null, + TrustAudience.Personal, + new ToolName("shell_execute"), + TestShellEnvironment.Current, + [ + new ShellGrantCandidate(new ShellPolicyCandidateId(0), first, RealDirectory: null), + new ShellGrantCandidate(new ShellPolicyCandidateId(1), second, RealDirectory: null) + ]), + ct); + + Assert.All(result.CandidateMatches, match => + Assert.Equal(ShellCoverageKind.PersistentGlobal, match.GrantCoverage)); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task Malformed_structured_batch_stores_no_partial_authority() + { + var ct = TestContext.Current.CancellationToken; + var tempFile = Path.GetTempFileName(); + try + { + var store = CreateStore(tempFile); + var actor = Sys.ActorOf(ToolApprovalActor.CreateProps(store)); + var service = CreateService(actor); + var malformed = new ApprovalCandidate("head", Directory: null) + { + Shell = NativeShell, + }; + + var exception = await Assert.ThrowsAsync(() => + service.RecordApprovalCandidatesAsync( + (ToolApprovalSessionId)"session-a", + TrustAudience.Personal, + new ToolName("shell_execute"), + [ + new ToolApprovalGrant(NativeCandidate("git status"), Directory: null), + new ToolApprovalGrant(malformed, Directory: null) + ], + persistent: true, + ct)); + + Assert.Contains("InvalidData", exception.Message, StringComparison.Ordinal); + Assert.Empty(store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); + } + finally + { + File.Delete(tempFile); + } + } + [Fact] public async Task Typed_shell_batch_preserves_ids_and_store_status() { diff --git a/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs b/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs index 3ce126db2..6700629c1 100644 --- a/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs +++ b/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs @@ -136,6 +136,7 @@ private bool IsReviewedDiagnostic( VerbTokens: { } } || sourceOccurrence is null + || ShellRedirectPolicyFacts.HasFileWritingRedirect(sourceOccurrence) || !_safeVerbs.TryMatchReviewedDiagnostic( shell, candidate.VerbTokens, @@ -171,7 +172,6 @@ internal bool ShortCircuitsCausalIntent( } || sourceOccurrence is null || string.IsNullOrWhiteSpace(intentDirectory) - || ShellRedirectPolicyFacts.HasFileWritingRedirect(sourceOccurrence) || !IsSafePath(intentDirectory, intentDirectory) || !IsReviewedDiagnostic( candidate, diff --git a/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs b/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs index 6b35e6727..2eb87a829 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs @@ -52,7 +52,7 @@ public void Policy_fixtures_load_exact_authority_and_trace_fields() var fixtures = DeserializeFixtures(File.ReadAllBytes(EvidencePath(PolicyFixturesFile))); var commands = matrix.Cases.ToDictionary(item => item.Id, item => item.Command); - Assert.Equal(1, fixtures.SchemaVersion); + Assert.Equal(2, fixtures.SchemaVersion); Assert.Equal("shell_execute", fixtures.FixtureDefaults.ToolName); Assert.Equal("Personal", fixtures.FixtureDefaults.Audience); Assert.Equal("Approval", fixtures.FixtureDefaults.ApprovalMode); @@ -64,6 +64,18 @@ public void Policy_fixtures_load_exact_authority_and_trace_fields() Assert.Equal("/work", fixtures.FixtureDefaults.ProjectDirectory); Assert.Null(fixtures.FixtureDefaults.InheritedWorkingDirectory); Assert.Equal(10, fixtures.Cases.Count); + Assert.Equal( + Enumerable.Range(1, 11).Select(number => $"A{number:00}"), + fixtures.AdversarialCases.Select(item => item.Id)); + Assert.Equal( + 11, + fixtures.AdversarialCases.Select(item => item.Category).Distinct().Count()); + Assert.All(fixtures.AdversarialCases, item => + { + Assert.False(string.IsNullOrWhiteSpace(item.Command)); + Assert.False(string.IsNullOrWhiteSpace(item.Expected.Outcome)); + Assert.True(item.Expected.ActorCheckCount >= 0); + }); foreach (var fixture in fixtures.Cases) { @@ -100,8 +112,8 @@ public void Policy_fixture_schema_rejects_unknown_members() { var json = File.ReadAllText(EvidencePath(PolicyFixturesFile)); var malformed = json.Replace( - "\"schemaVersion\": 1,", - "\"schemaVersion\": 1, \"unexpected\": true,", + "\"schemaVersion\": 2,", + "\"schemaVersion\": 2, \"unexpected\": true,", StringComparison.Ordinal); Assert.Throws(() => JsonSerializer.Deserialize( diff --git a/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs b/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs index 780fd601f..6cfa56ad1 100644 --- a/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs +++ b/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs @@ -14,6 +14,8 @@ internal sealed record PolicyFixtureCatalog public required PolicyFixtureDefaults FixtureDefaults { get; init; } public required List Cases { get; init; } + + public required List AdversarialCases { get; init; } } internal sealed record PolicyFixtureDefaults @@ -69,6 +71,40 @@ internal sealed record PolicyFixtureCase public required PolicyExpectedFinal ExpectedFinal { get; init; } } +internal sealed record PolicyAdversarialCase +{ + public required string Id { get; init; } + + public required string Category { get; init; } + + public required string Command { get; init; } + + public required PolicyFixtureEnvironment Environment { get; init; } + + public required string InitialWorkingDirectory { get; init; } + + public required PolicyFixtureAuthority Available { get; init; } + + public bool UseBundledSafeCatalog { get; init; } + + public required PolicyAdversarialExpected Expected { get; init; } +} + +internal sealed record PolicyAdversarialExpected +{ + public required string Outcome { get; init; } + + public string? DenyReason { get; init; } + + public List? ApprovalCandidates { get; init; } + + public bool? IsMessy { get; init; } + + public List? OptionKeys { get; init; } + + public required int ActorCheckCount { get; init; } +} + internal sealed record PolicyFixtureEnvironment { public required string Platform { get; init; } From 3c9035b524ac6aa0b0cc367047efdd5570aab570 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 20:19:10 +0000 Subject: [PATCH 2/5] test: bind adversarial policy fixture inputs --- .../Tools/ShellApprovalHarness.cs | 6 ++-- .../Tools/ShellPolicyEvidenceFixtureTests.cs | 31 +++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs index a18e2990d..4134c9a18 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs @@ -28,7 +28,8 @@ internal sealed record ObservedApproval( internal sealed record ShellApprovalHarnessScope( string ProjectDirectory, string SessionDirectory, - string InvocationSessionId); + string InvocationSessionId, + IReadOnlyList OneTimeApprovalKeys); internal sealed class ShellApprovalHarness : IAsyncDisposable { @@ -101,7 +102,7 @@ internal static async Task CreateAsync( var approvalProjectDirectory = scope?.ProjectDirectory ?? projectDirectory; var approvalSessionDirectory = scope?.SessionDirectory ?? sessionDirectory; var approvalExternalDirectory = externalDirectory; - if (environment.PathStyle == ShellPathStyle.Windows) + if (environment.PathStyle == ShellPathStyle.Windows && scope is null) { var windowsRoot = $"C:/netclaw-approval-matrix/{Guid.NewGuid():N}"; approvalProjectDirectory = $"{windowsRoot}/project"; @@ -207,6 +208,7 @@ await approvalService.RecordApprovalCandidatesAsync( ProjectDirectory = approvalProjectDirectory, InteractiveApproval = TestToolExecutionContext.InteractiveApproval(invocation.Interactive) }); + context.SetOneTimeApprovedPatterns(scope?.OneTimeApprovalKeys ?? []); return new ShellApprovalHarness( rootDirectory, diff --git a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs index 61f6c51a1..3ce679df3 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs @@ -48,7 +48,8 @@ public async Task Policy_fixtures_execute_through_the_coordinator() new ShellApprovalHarnessScope( catalog.FixtureDefaults.ProjectDirectory, catalog.FixtureDefaults.Session.SessionDirectory, - catalog.FixtureDefaults.Session.SessionId), + catalog.FixtureDefaults.Session.SessionId, + policyCase.Available.OneTimeApprovalKeys), CreateSafeVerbs(policyCase.Available, invocation.CreateEnvironment())); var decision = await harness.EvaluateDecisionAsync(TestContext.Current.CancellationToken); TestContext.Current.TestOutputHelper?.WriteLine( @@ -99,9 +100,10 @@ public async Task Adversarial_policy_fixtures_fail_closed_through_the_coordinato TestContext.Current.CancellationToken, timeProvider, new ShellApprovalHarnessScope( - catalog.FixtureDefaults.ProjectDirectory, - catalog.FixtureDefaults.Session.SessionDirectory, - catalog.FixtureDefaults.Session.SessionId), + policyCase.InitialWorkingDirectory, + policyCase.InitialWorkingDirectory, + catalog.FixtureDefaults.Session.SessionId, + policyCase.Available.OneTimeApprovalKeys), policyCase.UseBundledSafeCatalog ? null : CreateSafeVerbs(policyCase.Available, invocation.CreateEnvironment())); @@ -270,17 +272,34 @@ private static ShellApprovalInvocation CreateInvocation( Grammar: "PowerShell", Platform: "Windows", PathStyle: "Windows", + ExecutablePath: @"C:\Program Files\PowerShell\7\pwsh.exe", PowerShellDialect: "PowerShell7" - } => ShellApprovalHost.PowerShell7, + } when policyCase.Environment.CommandArguments.SequenceEqual( + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]) + => ShellApprovalHost.PowerShell7, { Grammar: "PowerShell", Platform: "Windows", PathStyle: "Windows", + ExecutablePath: @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", PowerShellDialect: "WindowsPowerShell51" - } => ShellApprovalHost.WindowsPowerShell51, + } when policyCase.Environment.CommandArguments.SequenceEqual( + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]) + => ShellApprovalHost.WindowsPowerShell51, _ => throw new InvalidDataException($"Unsupported fixture environment: {policyCase.Id}.") }; + var expectedWorkingDirectory = host == ShellApprovalHost.Bash + ? "/work" + : @"C:\work"; + if (!string.Equals( + policyCase.InitialWorkingDirectory, + expectedWorkingDirectory, + StringComparison.Ordinal)) + { + throw new InvalidDataException($"Unsupported fixture directory: {policyCase.Id}."); + } + return new ShellApprovalInvocation( policyCase.Command, ApprovalDirectoryShape.Project, From 40210215227f47fccf2a5e7030a8ed169842f749 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 20:25:03 +0000 Subject: [PATCH 3/5] test: prove exact fixture authority --- .../evidence/netclaw-policy-fixtures.json | 38 +++++++++++++++++++ .../Tools/ShellApprovalHarness.cs | 7 +++- .../Tools/ShellPolicyEvidenceFixtureTests.cs | 14 +++++-- .../ShellApprovalEvidenceContractTests.cs | 4 +- .../ShellPolicyEvidenceModels.cs | 4 ++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json b/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json index 0cafe70e0..06a1edb6f 100644 --- a/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json +++ b/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json @@ -417,6 +417,8 @@ "command": "\"$tool\" --version", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": [], "isMessy": true, "optionKeys": ["approve_once", "deny"], "actorCheckCount": 0 } }, @@ -426,6 +428,8 @@ "command": "bash -c 'rm -rf /'", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, "expected": { "outcome": "Deny", "denyReason": "hard_deny_system_destructive", "approvalCandidates": null, "isMessy": null, "optionKeys": null, "actorCheckCount": 0 } }, @@ -435,6 +439,8 @@ "command": "head README.md > report.txt", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [{ "tokens": ["head"], "proof": "ReviewedDiagnostic" }] }, "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["head"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } }, @@ -444,6 +450,8 @@ "command": "Get-Content C:\\protected\\config", "environment": { "platform": "Windows", "executablePath": "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "commandArguments": ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], "grammar": "PowerShell", "pathStyle": "Windows", "powerShellDialect": "PowerShell7" }, "initialWorkingDirectory": "C:\\work", + "projectDirectory": "C:\\work", + "sessionDirectory": "C:\\work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [{ "tokens": ["Get-Content"], "proof": "ReviewedDiagnostic" }] }, "expected": { "outcome": "Deny", "denyReason": "shell_references_protected_path", "approvalCandidates": null, "isMessy": null, "optionKeys": null, "actorCheckCount": 0 } }, @@ -453,6 +461,8 @@ "command": "git statusx", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [{ "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "status"], "directory": null }], "safePhrases": [] }, "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["git statusx"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } }, @@ -462,6 +472,8 @@ "command": "for f in $(list-files); do cat \"$f\"; done", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], @@ -476,6 +488,8 @@ "command": "Get-Content Env:SECRET", "environment": { "platform": "Windows", "executablePath": "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "commandArguments": ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], "grammar": "PowerShell", "pathStyle": "Windows", "powerShellDialect": "PowerShell7" }, "initialWorkingDirectory": "C:\\work", + "projectDirectory": "C:\\work", + "sessionDirectory": "C:\\work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [{ "tokens": ["Get-Content"], "proof": "ReviewedDiagnostic" }] }, "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": [], "isMessy": true, "optionKeys": ["approve_once", "deny"], "actorCheckCount": 0 } }, @@ -485,6 +499,8 @@ "command": "find . -exec rm {} +", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, "useBundledSafeCatalog": true, "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["find"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } @@ -495,6 +511,8 @@ "command": "awk 'BEGIN { system(\"touch marker\") }'", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, "useBundledSafeCatalog": true, "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["awk"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } @@ -505,6 +523,8 @@ "command": "rg --pre helper pattern .", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, "useBundledSafeCatalog": true, "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["rg"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } @@ -515,9 +535,27 @@ "command": "sort -o output input", "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", "available": { "oneTimeApprovalKeys": [], "sessionGrants": [], "persistentGrants": [], "safePhrases": [] }, "useBundledSafeCatalog": true, "expected": { "outcome": "RequiresApproval", "denyReason": null, "approvalCandidates": ["sort"], "isMessy": false, "optionKeys": ["approve_once", "approve_session", "approve_everywhere", "deny"], "actorCheckCount": 1 } + }, + { + "id": "A12", + "category": "ExactOneTimeAuthority", + "command": "git push", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "projectDirectory": "/work", + "sessionDirectory": "/work", + "available": { + "oneTimeApprovalKeys": ["git push", "\u0000candidate-v2:D404315B7AE7E1AB2E5DCDFCBFC1BC8A39770526922A4FCD7AB854C40B06F8BF"], + "sessionGrants": [], + "persistentGrants": [], + "safePhrases": [] + }, + "expected": { "outcome": "Allow", "denyReason": null, "approvalCandidates": null, "isMessy": null, "optionKeys": null, "actorCheckCount": 1 } } ] } diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs index 4134c9a18..6b049157a 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs @@ -208,7 +208,12 @@ await approvalService.RecordApprovalCandidatesAsync( ProjectDirectory = approvalProjectDirectory, InteractiveApproval = TestToolExecutionContext.InteractiveApproval(invocation.Interactive) }); - context.SetOneTimeApprovedPatterns(scope?.OneTimeApprovalKeys ?? []); + if (scope?.OneTimeApprovalKeys is { Count: > 0 } oneTimeApprovalKeys) + { + context.Approval.SeedOneTimeApproval( + ShellTool.ToolName, + oneTimeApprovalKeys); + } return new ShellApprovalHarness( rootDirectory, diff --git a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs index 3ce679df3..8927a243f 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvidenceFixtureTests.cs @@ -100,8 +100,8 @@ public async Task Adversarial_policy_fixtures_fail_closed_through_the_coordinato TestContext.Current.CancellationToken, timeProvider, new ShellApprovalHarnessScope( - policyCase.InitialWorkingDirectory, - policyCase.InitialWorkingDirectory, + policyCase.ProjectDirectory, + policyCase.SessionDirectory, catalog.FixtureDefaults.Session.SessionId, policyCase.Available.OneTimeApprovalKeys), policyCase.UseBundledSafeCatalog @@ -295,9 +295,17 @@ private static ShellApprovalInvocation CreateInvocation( if (!string.Equals( policyCase.InitialWorkingDirectory, expectedWorkingDirectory, + StringComparison.Ordinal) + || !string.Equals( + policyCase.ProjectDirectory, + expectedWorkingDirectory, + StringComparison.Ordinal) + || !string.Equals( + policyCase.SessionDirectory, + expectedWorkingDirectory, StringComparison.Ordinal)) { - throw new InvalidDataException($"Unsupported fixture directory: {policyCase.Id}."); + throw new InvalidDataException($"Unsupported fixture scope: {policyCase.Id}."); } return new ShellApprovalInvocation( diff --git a/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs b/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs index 2eb87a829..5543c09ec 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs @@ -65,10 +65,10 @@ public void Policy_fixtures_load_exact_authority_and_trace_fields() Assert.Null(fixtures.FixtureDefaults.InheritedWorkingDirectory); Assert.Equal(10, fixtures.Cases.Count); Assert.Equal( - Enumerable.Range(1, 11).Select(number => $"A{number:00}"), + Enumerable.Range(1, 12).Select(number => $"A{number:00}"), fixtures.AdversarialCases.Select(item => item.Id)); Assert.Equal( - 11, + 12, fixtures.AdversarialCases.Select(item => item.Category).Distinct().Count()); Assert.All(fixtures.AdversarialCases, item => { diff --git a/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs b/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs index 6cfa56ad1..979de6e5a 100644 --- a/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs +++ b/src/Netclaw.Security.Tests/ShellPolicyEvidenceModels.cs @@ -83,6 +83,10 @@ internal sealed record PolicyAdversarialCase public required string InitialWorkingDirectory { get; init; } + public required string ProjectDirectory { get; init; } + + public required string SessionDirectory { get; init; } + public required PolicyFixtureAuthority Available { get; init; } public bool UseBundledSafeCatalog { get; init; } From 3203975d9da3d7466ba84ca437ed486684b33d45 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 20:53:40 +0000 Subject: [PATCH 4/5] fix: use shell path style for approval scope depth --- .../Tools/ToolApprovalGateTests.cs | 55 +++++++++++- .../Tools/PlatformTemporaryScopePolicy.cs | 83 +++++++++++++++++++ .../Tools/ShellPolicyCoordinator.cs | 6 +- src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 30 ++----- 4 files changed, 148 insertions(+), 26 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index 4c42c4288..45c832e09 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -1087,10 +1087,63 @@ public void Narrow_shell_context_omits_reusable_options_for_incomplete_phrase_fa var narrowed = ToolAccessPolicy.NarrowShellApprovalContext( original, [candidate], - sessionDirectory: null); + sessionDirectory: null, + ShellPathStyle.Posix); Assert.Equal( [ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.Deny], narrowed.Options.Select(static option => option.Key.Value)); } + + [Theory] + [InlineData("/", ShellPathStyle.Posix, false)] + [InlineData("/etc", ShellPathStyle.Posix, false)] + [InlineData("/home/user", ShellPathStyle.Posix, true)] + [InlineData("/home//user", ShellPathStyle.Posix, false)] + [InlineData("/etc/..", ShellPathStyle.Posix, false)] + [InlineData("relative/repo", ShellPathStyle.Posix, false)] + [InlineData(@"C:\", ShellPathStyle.Windows, false)] + [InlineData(@"C:\Windows", ShellPathStyle.Windows, false)] + [InlineData(@"C:\Users\user", ShellPathStyle.Windows, true)] + [InlineData(@"C:\Users\\user", ShellPathStyle.Windows, false)] + [InlineData(@"\\server\share", ShellPathStyle.Windows, false)] + [InlineData(@"\\server\share\folder", ShellPathStyle.Windows, false)] + [InlineData(@"\\server\share\folder\repo", ShellPathStyle.Windows, true)] + [InlineData("\\\\ser\nver\\share\\folder\\repo", ShellPathStyle.Windows, false)] + [InlineData("\\\\server\\sha\nre\\folder\\repo", ShellPathStyle.Windows, false)] + [InlineData(@"\\server\\share\folder\repo", ShellPathStyle.Windows, false)] + [InlineData(@"\\server\..\folder\repo", ShellPathStyle.Windows, false)] + [InlineData(@"\\.\share\folder\repo", ShellPathStyle.Windows, false)] + [InlineData(@"\\?\C:\folder\repo", ShellPathStyle.Windows, false)] + [InlineData("\\\\server\\share\\folder\\repo\\", ShellPathStyle.Windows, false)] + [InlineData(@"C:\work", (ShellPathStyle)999, false)] + public void Narrow_shell_context_uses_root_relative_scope_depth( + string cwd, + ShellPathStyle pathStyle, + bool offersAlwaysHere) + { + var candidate = new ApprovalCandidate("git status", cwd) + { + Shell = ApprovalShell.Bash, + VerbTokens = ["git", "status"] + }; + var original = new ToolApprovalContext( + "shell_execute", + "git status", + ["git status"], + ["git status"], + [], + Cwd: cwd, + Candidates: [candidate]); + + var narrowed = ToolAccessPolicy.NarrowShellApprovalContext( + original, + [candidate], + sessionDirectory: null, + pathStyle); + + Assert.Equal( + offersAlwaysHere, + narrowed.Options.Any(option => option.Key.Value == ApprovalOptionKeys.ApproveAlways)); + } } diff --git a/src/Netclaw.Actors/Tools/PlatformTemporaryScopePolicy.cs b/src/Netclaw.Actors/Tools/PlatformTemporaryScopePolicy.cs index 5ffcd21df..cfdbfe78f 100644 --- a/src/Netclaw.Actors/Tools/PlatformTemporaryScopePolicy.cs +++ b/src/Netclaw.Actors/Tools/PlatformTemporaryScopePolicy.cs @@ -483,6 +483,89 @@ internal static bool IsWithinRoot( : StringComparison.Ordinal); } + internal static bool TryGetRootRelativeDepth( + string? path, + ShellPathStyle pathStyle, + out int depth) + { + depth = 0; + if (string.IsNullOrWhiteSpace(path) || path.Any(char.IsControl)) + return false; + + return pathStyle switch + { + ShellPathStyle.Posix => TryGetPosixDepth(path, out depth), + ShellPathStyle.Windows => TryGetWindowsDepth(path, out depth), + _ => false + }; + } + + private static bool TryGetPosixDepth(string path, out int depth) + { + if (path == "/") + { + depth = 0; + return true; + } + + if (path[0] != '/' + || path.EndsWith('/') + || path.Contains("//", StringComparison.Ordinal)) + { + depth = 0; + return false; + } + + return TryCountCanonicalSegments(path[1..], '/', out depth); + } + + private static bool TryGetWindowsDepth(string path, out int depth) + { + depth = 0; + if (path.Contains('/', StringComparison.Ordinal)) + return false; + + if (path.Length >= 3 + && char.IsAsciiLetter(path[0]) + && path[1] == ':' + && path[2] == '\\') + { + if (path.Length == 3) + return true; + + return TryCountCanonicalSegments(path[3..], '\\', out depth); + } + + if (!path.StartsWith("\\\\", StringComparison.Ordinal)) + return false; + + var components = path[2..].Split('\\', StringSplitOptions.None); + if (components.Length < 2 + || components[0] is "." or "?" + || components.Any(static component => + component.Length == 0 || component is "." or "..")) + { + return false; + } + + depth = components.Length - 2; + return true; + } + + private static bool TryCountCanonicalSegments(string path, char separator, out int depth) + { + var segments = path.Split(separator, StringSplitOptions.None); + if (segments.Any(static segment => + segment.Length == 0 || segment is "." or "..")) + { + depth = 0; + return false; + } + + depth = segments.Length; + return true; + } + private static string NormalizeSegments(string path, char separator, string root) { var segments = new List(); diff --git a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs index 4526c9ac4..bf5d9a83e 100644 --- a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs +++ b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs @@ -256,7 +256,8 @@ approvalService is not null : ToolAccessPolicy.NarrowShellApprovalContext( projection.ApprovalContext, uncovered.Select(static candidate => candidate.Candidate).ToArray(), - context.SessionDirectory); + context.SessionDirectory, + projection.Environment.PathStyle); if (projection.HasExactOneTimeApproval(toolCall.Name, remainingContext)) { foreach (var candidate in uncovered) @@ -293,7 +294,8 @@ approvalService is not null : ToolAccessPolicy.NarrowShellApprovalContext( projection.ApprovalContext, uncovered.Select(static candidate => candidate.Candidate).ToArray(), - context.SessionDirectory); + context.SessionDirectory, + projection.Environment.PathStyle); return CompleteWithTrace( ToolAuthorizationDecision.RequiresApproval(promptContext, approvalMatches), trace); diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 226536254..c06b0fdab 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -593,7 +593,7 @@ private ToolAccessDecision CheckApprovalGate( isMessy, hasReusablePhraseForEveryCandidate: !isShell || approvalCandidates.All(HasReusableShellPhrase), - isCwdShallow: IsCwdTooShallow(context.Approval.Cwd), + isCwdShallow: IsCwdTooShallow(context.Approval.Cwd, ShellEnvironment.PathStyle), allEffectiveDirsAreSessionScratch: AllCandidatesResolveToSessionScratch( approvalCandidates, context.Approval.Cwd, context.SessionDirectory), supportsDirectoryScope: matcher is ShellApprovalMatcher, @@ -622,7 +622,8 @@ private ToolAccessDecision CheckApprovalGate( internal static ToolApprovalContext NarrowShellApprovalContext( ToolApprovalContext context, IReadOnlyList unapprovedCandidates, - string? sessionDirectory) + string? sessionDirectory, + ShellPathStyle pathStyle) { var candidateVerbs = unapprovedCandidates .Select(static candidate => candidate.Verb) @@ -634,7 +635,7 @@ internal static ToolApprovalContext NarrowShellApprovalContext( isMessy: false, hasReusablePhraseForEveryCandidate: unapprovedCandidates.All(HasReusableShellPhrase), - isCwdShallow: IsCwdTooShallow(context.Cwd), + isCwdShallow: IsCwdTooShallow(context.Cwd, pathStyle), allEffectiveDirsAreSessionScratch: AllCandidatesResolveToSessionScratch( unapprovedCandidates, context.Cwd, sessionDirectory), supportsDirectoryScope: true, @@ -752,30 +753,13 @@ candidate.Shell is not null && /// than two non-empty segments under its root (e.g. /, /etc/, /// C:\) cannot be safely persisted as an ApprovalEntry directory. /// - private static bool IsCwdTooShallow(string? cwd) + private static bool IsCwdTooShallow(string? cwd, ShellPathStyle pathStyle) { if (string.IsNullOrWhiteSpace(cwd)) return false; - try - { - var segments = PathUtility.Normalize(cwd).Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries); - - // POSIX root → 0 segments after trim. /etc → 1 segment. /home/user → 2 segments. - // Windows: C:\ → ["C:"] → 1 segment but conventionally a root, so still shallow. - // C:\Users\foo → 3 segments. - // Require at least 2 distinct path segments for folder-scoped persistence. - return segments.Length < 2; - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) - { - // Treat unparseable cwds as shallow — fail closed on the - // persistent button rather than offering a grant whose target we - // could not normalize. - return true; - } + return !ShellPathRules.TryGetRootRelativeDepth(cwd, pathStyle, out var depth) + || depth < 2; } private static ToolApprovalMode GetMissingApprovalPolicyDefaultMode( From 74ea6e431d81bff6e4d26c015e255f291970d76b Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 21:36:41 +0000 Subject: [PATCH 5/5] test: use POSIX scope in Bash approval case --- .../Tools/ToolApprovalGateTests.cs | 49 ++++++++----------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index 45c832e09..6c0176190 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -990,36 +990,27 @@ public void Shell_multi_root_command_uses_fixed_labels() public void Shell_relative_path_command_extracts_verb_chain_without_directory_roots() { var policy = CreatePolicy(ToolApprovalMode.Approval); - var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - var logs = Path.Combine(root, "logs"); - Directory.CreateDirectory(logs); + const string root = "/netclaw-approval-test/workspace"; - try - { - var args = ToolInput.Create( - "Command", "grep timeout logs/app.log | wc -l", - "WorkingDirectory", root); - - var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); - - Assert.True(decision.NeedsApproval); - // Pipelines stay inside one approval unit, so the candidate is - // the verb chain of the unit's first command (path-aware - // "grep "). - Assert.Contains(decision.ApprovalContext!.CandidateVerbs, v => v.StartsWith("grep", StringComparison.Ordinal)); - // Button labels are fixed; Slack's 76-char and Discord's 80-char - // button caps make dynamic labels structurally unsafe. - Assert.Equal( - ApprovalOptionKeys.ApproveSessionLabel, - decision.ApprovalContext.Options.Single(o => o.Key.Value == ApprovalOptionKeys.ApproveSession).Label); - Assert.Equal( - ApprovalOptionKeys.ApproveAlwaysLabel, - decision.ApprovalContext.Options.Single(o => o.Key.Value == ApprovalOptionKeys.ApproveAlways).Label); - } - finally - { - Directory.Delete(root, recursive: true); - } + var args = ToolInput.Create( + "Command", "grep timeout logs/app.log | wc -l", + "WorkingDirectory", root); + + var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); + + Assert.True(decision.NeedsApproval); + // Pipelines stay inside one approval unit, so the candidate is + // the verb chain of the unit's first command (path-aware + // "grep "). + Assert.Contains(decision.ApprovalContext!.CandidateVerbs, v => v.StartsWith("grep", StringComparison.Ordinal)); + // Button labels are fixed; Slack's 76-char and Discord's 80-char + // button caps make dynamic labels structurally unsafe. + Assert.Equal( + ApprovalOptionKeys.ApproveSessionLabel, + decision.ApprovalContext.Options.Single(o => o.Key.Value == ApprovalOptionKeys.ApproveSession).Label); + Assert.Equal( + ApprovalOptionKeys.ApproveAlwaysLabel, + decision.ApprovalContext.Options.Single(o => o.Key.Value == ApprovalOptionKeys.ApproveAlways).Label); } [Fact]