From 785b3c6a8813b221e45c59e7639d44aac7718954 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 09:57:49 +0000 Subject: [PATCH 1/4] feat(shell): activate native PowerShell on Windows --- IMPLEMENTATION_PLAN.md | 40 +- docs/runbooks/tool-approval-gates.md | 29 +- .../native-windows-powershell-host/tasks.md | 36 +- .../Jobs/BackgroundJobExecutionActorTests.cs | 43 +- .../Netclaw.Actors.Tests.csproj | 1 + .../Sessions/LlmSessionTestBase.cs | 4 +- .../Sessions/LlmSessionTestExtensions.cs | 2 + .../Pipelines/BackgroundRoutingTests.cs | 37 +- .../Sessions/SubAgentSpawnIntegrationTests.cs | 22 +- .../Sessions/WorkingContextSnapshotTests.cs | 97 +++- .../SubAgents/SubAgentSpawnerTests.cs | 11 +- .../TestShellEnvironment.cs | 62 +++ .../Tools/DispatchingToolExecutorTests.cs | 4 +- .../Tools/ShellApprovalCaseCatalog.cs | 226 +++++---- ...roval_cases_match_review_table.verified.md | 415 ++++++++-------- .../ShellApprovalDispositionMatrixTests.cs | 16 +- .../Tools/ShellApprovalHarness.cs | 43 +- .../Tools/ShellToolStreamingTests.cs | 65 ++- .../Tools/ShellToolTests.cs | 94 +++- ...olAccessPolicyRequiredDependenciesTests.cs | 55 ++- .../Hosting/NetclawAkkaHostingExtensions.cs | 23 +- .../Jobs/BackgroundJobExecutionActor.cs | 63 ++- .../Jobs/BackgroundJobManagerActor.cs | 21 +- .../Pipelines/SessionToolExecutionPipeline.cs | 8 +- .../Sessions/WorkingContextSnapshot.cs | 55 ++- .../Tools/DispatchingToolExecutor.cs | 21 +- src/Netclaw.Actors/Tools/ShellTool.cs | 285 ++++++----- src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 138 +++--- .../SafeVerbLoaderTests.cs | 19 +- src/Netclaw.Configuration/Resources/AGENTS.md | 25 + src/Netclaw.Configuration/SafeVerbList.cs | 20 +- .../SafeVerbs/safe-verbs.windows.json | 2 +- src/Netclaw.Daemon/Program.cs | 59 ++- .../ShellApprovalMatcherMultilineTests.cs | 13 +- .../ShellApprovalMatcherTests.cs | 160 ++++-- .../ShellCommandAnalysisTests.cs | 128 ++--- .../ShellCommandPolicyTests.cs | 74 ++- .../ShellSyntaxTreeIntegrationTests.cs | 21 +- .../ToolPathPolicyTests.cs | 97 ++-- src/Netclaw.Security/IToolApprovalMatcher.cs | 456 +++++++++++------- .../SecurityServiceExtensions.cs | 23 +- .../ShellApprovalSemantics.cs | 14 +- src/Netclaw.Security/ShellCommandAnalysis.cs | 216 +++------ src/Netclaw.Security/ShellCommandPolicy.cs | 280 +++++++++-- .../ShellExecutionEnvironment.cs | 8 + src/Netclaw.Security/ShellTokenizer.cs | 54 ++- src/Netclaw.Security/ToolPathPolicy.cs | 54 ++- 47 files changed, 2409 insertions(+), 1230 deletions(-) create mode 100644 src/Netclaw.Actors.Tests/TestShellEnvironment.cs diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 8ce3d8121..a2fd8f62d 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -145,13 +145,12 @@ Done when: command-resolution mutation and reserved execution forms into the strict 181-case review matrix. - [x] ShellSyntaxTree `0.3.0-alpha.2` introduced one temporary POSIX PowerShell - child wrapper. The current runtime keeps that transitional behavior until - activation task 3.1 removes it. The accepted target contract supersedes the - design: Bash treats `pwsh` as an external command, and only a native + child wrapper. Native-host activation removed that transitional consumer + behavior: Bash treats `pwsh` as an external command, and only a native PowerShell host uses `PwshParser`. -- [x] The current 204-case shell approval review table records the transitional - child-host behavior. Activation replaces those rows with native PowerShell - cases; they do not define the accepted host-language boundary. +- [x] The shell approval review table separates Bash, PowerShell 7, and Windows + PowerShell 5.1 rows. Cross-language payloads remain ordinary external-command + arguments; same-language static children use parser-returned occurrences. - [x] A constrained stdin grammar allows a complete literal heredoc or bounded here string only for argument-free `cat`. Unknown data, expanding heredocs, arguments, wrappers, interpreters, and stored grants stay strict. @@ -170,25 +169,34 @@ This work replaces `cmd.exe` with a native PowerShell host on Windows. Netclaw prefers a compatible PowerShell 7.6 host and falls back to Windows PowerShell 5.1. It keeps Bash and PowerShell as separate host languages. -The additive foundation now pins ShellSyntaxTree `0.3.0-alpha.5` and defines -the immutable environment, strict host probe, and process arguments. The -current runtime remains transitional until the activation tasks route every -executor and security consumer through that environment. +The additive foundation pins ShellSyntaxTree `0.3.0-alpha.5` and defines the +immutable environment, strict host probe, and process arguments. Runtime +activation now routes execution, policy, approval, background jobs, and model +context through the same resolved environment. Native Windows CI and final +OpenSpec delivery remain before this priority is complete. + +Local validation on 2026-08-10 passed restore, the zero-warning Release build, +the full solution test suite, changed-file format verification, headers, +Slopwatch, `git diff --check`, and strict OpenSpec validation. The shell-platform +behavioral evaluation was unavailable because the required +`NETCLAW_EVAL_PROVIDER_TYPE`, `NETCLAW_EVAL_PROVIDER_ENDPOINT`, and +`NETCLAW_EVAL_MODEL_ID` settings were absent. This result is blocked evidence, +not an evaluation pass. Done when: -- [ ] One immutable shell environment selects the absolute executable path, +- [x] One immutable shell environment selects the absolute executable path, grammar, path style, process arguments, and PowerShell dialect for the daemon lifetime. -- [ ] Windows selects `pwsh.exe` only for versions from 7.6.4 through 7.6.x. It +- [x] Windows selects `pwsh.exe` only for versions from 7.6.4 through 7.6.x. It falls back to `powershell.exe` 5.1 and fails clearly if neither host matches. -- [ ] Execution, parsing, hard deny, approval matching, prompt display, and +- [x] Execution, parsing, hard deny, approval matching, prompt display, and model context use the same selected environment. -- [ ] Bash treats `pwsh` as an external command. PowerShell treats `bash` as an +- [x] Bash treats `pwsh` as an external command. PowerShell treats `bash` as an external command. Only same-language child hosts can recurse. -- [ ] Unknown or incomplete facts cannot produce a stored approval candidate +- [x] Unknown or incomplete facts cannot produce a stored approval candidate or a safe-verb pass. Stored approval cannot bypass hard deny. -- [ ] Personal sessions state the platform, executable, grammar, and dialect, +- [x] Personal sessions state the platform, executable, grammar, and dialect, including sessions that have no project directory. - [ ] Native Windows tests cover PowerShell 7.6 and Windows PowerShell 5.1. The security review table covers direct, child, retry, and background paths. diff --git a/docs/runbooks/tool-approval-gates.md b/docs/runbooks/tool-approval-gates.md index bf465aea5..08cf2dd88 100644 --- a/docs/runbooks/tool-approval-gates.md +++ b/docs/runbooks/tool-approval-gates.md @@ -123,8 +123,11 @@ When the agent calls a tool in `Approval` mode: ### Command patterns -For `shell_execute`, patterns are verb-chain prefixes extracted by tokenizing -the command: +For `shell_execute`, patterns come from the parser for the daemon's selected +native shell environment. Linux and macOS use Bash. Windows uses a probed +native PowerShell host: compatible PowerShell 7.6 is preferred, with Windows +PowerShell 5.1 as the fallback. The exact executable, grammar, and dialect are +shown in Personal session working context. | Command | Pattern | |---------|---------| @@ -143,6 +146,21 @@ For **compound commands** (`&&`, `||`, `;`, `|`), each segment is checked independently. If any segment is unapproved, all unapproved patterns are batched into one prompt. +The selected host grammar is also the language boundary. Under Bash, +`pwsh -Command 'Get-Content ./a.txt'` is an ordinary external `pwsh` command; +the payload is not separately parsed as PowerShell. Under native PowerShell, +`bash -c 'cat ./a.txt'` is likewise an ordinary external `bash` command. +Same-language static child hosts can expose nested command occurrences when +ShellSyntaxTree proves them. + +PowerShell 7 and Windows PowerShell 5.1 are analyzed as distinct dialects. +In particular, `&&` and `||` are unresolved under 5.1 and cannot create a +persistent approval candidate or receive the read-only safe-verb shortcut. +Incomplete commands, dynamic command identities, and non-filesystem provider +drives also remain one-time-only. Netclaw does not claim knowledge of ambient +profiles, modules, inherited variables, executable lookup, or external script +contents. + For most **non-shell tools** (MCP tools, `file_read`, etc.), approval is at the tool-name level. @@ -157,9 +175,10 @@ path-scoped patterns (for example, Demonstrably read-only verbs auto-run with no prompt when invoked inside a trusted zone (`session_dir`, or `project_dir` for Personal/Team). The bundled safe-verb lists (`safe-verbs.linux.json`, `safe-verbs.windows.json`) cover file -readers (`ls`, `grep`, `cat`), system/info verbs (`date`, `whoami`, `uname`, -`uptime`), and read-only `git`/`gh` queries (`git status`, `git log`, -`gh pr view`, `gh run list`). Mutating verbs (`git push`, `git fetch`, `rm`), +readers (for example `ls`, `grep`, and `cat` on Bash; `Get-ChildItem`, +`Get-Content`, and `Select-String` on PowerShell), system/info verbs (`date`, +`whoami`, `uname`, `uptime`), and read-only `git`/`gh` queries (`git status`, +`git log`, `gh pr view`, `gh run list`). Mutating verbs (`git push`, `git fetch`, `rm`), command-prefixing verbs (`env`, `xargs`, `sudo`), network-writing verbs (`gh api`, `curl`), and environment/process-inspection verbs (`printenv`, `ps`) are never auto-allowed — the trusted-zone gate scopes verbs that act on diff --git a/openspec/changes/native-windows-powershell-host/tasks.md b/openspec/changes/native-windows-powershell-host/tasks.md index dd546eb0c..5de901031 100644 --- a/openspec/changes/native-windows-powershell-host/tasks.md +++ b/openspec/changes/native-windows-powershell-host/tasks.md @@ -18,47 +18,47 @@ fails when neither host matches. - [x] 2.3 Add version, priority, probe-failure, startup-failure, parser-options, and process-argument tests for Bash, PowerShell 7.6, and PowerShell 5.1. -- [ ] 2.4 Deliver the additive foundation as an adversarially reviewed PR with +- [x] 2.4 Deliver the additive foundation as an adversarially reviewed PR with green CI and auto-merge before activation work starts. ## 3. Atomic Runtime Activation -- [ ] 3.1 Remove Netclaw's POSIX `pwsh -Command` child-payload parser and pin +- [x] 3.1 Remove Netclaw's POSIX `pwsh -Command` child-payload parser and pin both cross-language non-delegation directions. -- [ ] 3.2 Route shell analysis and approval matching through the environment's +- [x] 3.2 Route shell analysis and approval matching through the environment's parser, working directory, PowerShell dialect, and unknown initial-state mode. -- [ ] 3.3 Route hard deny, protected paths, trust zones, safe verbs, approval +- [x] 3.3 Route hard deny, protected paths, trust zones, safe verbs, approval candidates, and approval display through the environment-bound analysis. -- [ ] 3.4 Add PowerShell hard-deny coverage for process termination, recursive +- [x] 3.4 Add PowerShell hard-deny coverage for process termination, recursive root removal, and `Start-Process -Verb RunAs` before approval evaluation. -- [ ] 3.5 Make buffered and streaming `ShellTool` execution use one shared +- [x] 3.5 Make buffered and streaming `ShellTool` execution use one shared process-start builder. It must use only the selected absolute host path and fixed non-interactive arguments. It must fail visibly and must not use a per-call fallback. -- [ ] 3.6 Register one environment instance for the parser, policy, matcher, +- [x] 3.6 Register one environment instance for the parser, policy, matcher, executor, context provider, direct calls, and background-job calls. ## 4. Model Context and Guidance -- [ ] 4.1 Add platform, executable, grammar, and dialect to the Personal +- [x] 4.1 Add platform, executable, grammar, and dialect to the Personal working-context tail, including sessions without a project directory. -- [ ] 4.2 Prove that parent and child runs receive the same shell identity and +- [x] 4.2 Prove that parent and child runs receive the same shell identity and that Team or Public sessions gain no shell capability. -- [ ] 4.3 Update the embedded operations guidance with native Bash and +- [x] 4.3 Update the embedded operations guidance with native Bash and PowerShell examples. State that ambient profiles, modules, and lookup remain outside parser proof. ## 5. Security and Approval Evidence -- [ ] 5.1 Replace `cmd.exe` host rows with reviewed PowerShell 7.6 and Windows +- [x] 5.1 Replace `cmd.exe` host rows with reviewed PowerShell 7.6 and Windows PowerShell 5.1 allow, prompt, deny, safe-verb, and stored-grant rows. -- [ ] 5.2 Add parser-boundary cases for ordinary Bash `pwsh` arguments, +- [x] 5.2 Add parser-boundary cases for ordinary Bash `pwsh` arguments, ordinary PowerShell `bash` arguments, and same-language child recursion. -- [ ] 5.3 Add incomplete, dynamic, unknown-dialect, 5.1 pipeline-chain, +- [x] 5.3 Add incomplete, dynamic, unknown-dialect, 5.1 pipeline-chain, protected-path, redirect, provider-drive, alias, and hard-deny cases. -- [ ] 5.4 Prove that a dialect change reparses before grant matching and that a +- [x] 5.4 Prove that a dialect change reparses before grant matching and that a stored approval cannot bypass a changed canonical candidate. -- [ ] 5.5 Prove buffered, streaming, direct, sub-agent, background, retry, and +- [x] 5.5 Prove buffered, streaming, direct, sub-agent, background, retry, and redrive paths use the same selected environment and approval decision. ## 6. Verification and Delivery @@ -66,15 +66,15 @@ - [ ] 6.1 Run focused security, actor, context, buffered-executor, streaming-executor, resolver, and approval matrix tests on Linux and native Windows. -- [ ] 6.2 Run restore, Release build, the full test suite, format verification, +- [x] 6.2 Run restore, Release build, the full test suite, format verification, header verification, Slopwatch, `git diff --check`, and strict OpenSpec validation. -- [ ] 6.3 Run the shell-platform behavioral evaluation when provider +- [x] 6.3 Run the shell-platform behavioral evaluation when provider credentials are available. Record an unavailable provider as blocked, not passed. - [ ] 6.4 Run an adversarial review for every implementation PR, enable auto-merge only after the review passes, and follow required CI to merge. -- [ ] 6.5 Update `IMPLEMENTATION_PLAN.md`, user guidance, and review-table +- [x] 6.5 Update `IMPLEMENTATION_PLAN.md`, user guidance, and review-table evidence with observed results. - [ ] 6.6 Run `openspec-verify-change`, sync the capability deltas, and archive this change only after all runtime and downstream acceptance gates pass. diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobExecutionActorTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobExecutionActorTests.cs index 47ffe1ca9..0e2693e01 100644 --- a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobExecutionActorTests.cs +++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobExecutionActorTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -9,6 +9,7 @@ using Netclaw.Actors.Channels; using Netclaw.Actors.Jobs; using Netclaw.Configuration; +using Netclaw.Security; using Netclaw.Tests.Utilities; using Xunit; using static Netclaw.Actors.Jobs.BackgroundJobProtocol; @@ -18,6 +19,7 @@ namespace Netclaw.Actors.Tests.Jobs; [Collection(BackgroundJobProcessCollection.Name)] public class BackgroundJobExecutionActorTests : TestKit { + private static readonly ShellExecutionEnvironment ShellEnvironment = TestShellEnvironment.Current; private readonly DisposableTempDir _dir = new(); private BackgroundJobDefinitionStore _store = null!; @@ -36,8 +38,7 @@ protected override async Task AfterAllAsync() await base.AfterAllAsync(); } - private static string LongRunningCommand => - OperatingSystem.IsWindows() ? "ping -n 300 127.0.0.1" : "sleep 300"; + private static string LongRunningCommand => TestShellEnvironment.LongRunningCommand; private BackgroundJobDefinition MakeDefinition(string command, int timeoutSeconds = 600) => new() { @@ -53,13 +54,41 @@ protected override async Task AfterAllAsync() TimeoutSeconds = timeoutSeconds }; - private IActorRef SpawnExecution(BackgroundJobDefinition definition, IActorRef probe) + private IActorRef SpawnExecution( + BackgroundJobDefinition definition, + IActorRef probe, + ShellExecutionEnvironment? environment = null) { var outputPath = _store.GetOutputLogPath(definition.Id); - var props = Props.Create(() => new BackgroundJobExecutionActor(definition, outputPath, TimeProvider.System)); + var props = Props.Create(() => new BackgroundJobExecutionActor( + definition, + outputPath, + TimeProvider.System, + environment ?? ShellEnvironment)); return Sys.ActorOf(ForwardingParent.Props(props, probe), $"exec-{definition.Id}"); } + [Fact] + public async Task Missing_selected_executable_reports_exact_host_without_fallback() + { + const string missingExecutable = @"C:\missing\pwsh.exe"; + var environment = ShellExecutionEnvironment.CreatePowerShell( + missingExecutable, + ShellSyntaxTree.PwshDialect.PowerShell7); + var definition = MakeDefinition("Get-ChildItem"); + var probe = CreateTestProbe("parent"); + SpawnExecution(definition, probe, environment); + + var completed = await probe.ExpectMsgAsync( + TimeSpan.FromSeconds(10), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(BackgroundJobStatus.Failed, completed.Status); + Assert.Contains(missingExecutable, completed.OutputTail); + Assert.DoesNotContain("powershell.exe", completed.OutputTail, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("cmd.exe", completed.OutputTail, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task SuccessfulCompletion_ReportsCompletedToParent() { @@ -118,8 +147,8 @@ public async Task RunningJob_OutputIsObservableOnDiskBeforeExit() { // The detached-process contract: a job that never exits (dev server) // must still have its output readable from the log while it runs. - var command = OperatingSystem.IsWindows() - ? "echo server-is-up && ping -n 300 127.0.0.1" + var command = ShellEnvironment.Grammar == ShellGrammar.PowerShell + ? "Write-Output server-is-up; Start-Sleep -Seconds 300" : "echo server-is-up && sleep 300"; var definition = MakeDefinition(command); var probe = CreateTestProbe("parent"); diff --git a/src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj b/src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj index 4e0ed8c52..a241fc2ae 100644 --- a/src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj +++ b/src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj @@ -30,6 +30,7 @@ + diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestBase.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestBase.cs index 89c4a39f4..22b0f95b0 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestBase.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestBase.cs @@ -14,6 +14,7 @@ using Netclaw.Actors.Reminders; using Netclaw.Actors.Tests.Hosting; using Netclaw.Configuration; +using Netclaw.Security; namespace Netclaw.Actors.Tests.Sessions; @@ -62,7 +63,8 @@ protected sealed override void ConfigureAkka(AkkaConfigurationBuilder builder, I if (VerifySerialization) builder.WithSerializationVerification(); - builder.WithNetclawActors(); + builder.WithNetclawActors( + provider.GetRequiredService()); } protected sealed override void ConfigureServices(HostBuilderContext context, IServiceCollection services) diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs index 643ad0210..807f55b46 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs @@ -22,6 +22,8 @@ internal static class LlmSessionTestExtensions public static IServiceCollection AddLlmSessionCompositeRecords(this IServiceCollection services) { services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton( + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux)); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(sp => new SessionServices( diff --git a/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs b/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs index 823b1c446..2189a6263 100644 --- a/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/Pipelines/BackgroundRoutingTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -242,6 +242,41 @@ await probe.ExpectMsgAsync( Assert.Equal("/tmp/project", received.WorkingDirectory); } + [Fact] + public async Task ExplicitBackground_PersistsResolvedProjectDirectoryWhenArgumentIsOmitted() + { + var executor = new EchoExecutor(); + var probe = CreateTestProbe("pipeline-probe-resolved-workingdir"); + var jobManagerProbe = CreateTestProbe("job-manager-resolved-workingdir"); + var fakeJobManager = Sys.ActorOf(Props.Create(() => new FakeJobManager(jobManagerProbe.Ref))); + + var toolCalls = new List + { + new("call-bg-resolved-dir", "shell_execute", new Dictionary + { + ["command"] = "dotnet test", + ["_background"] = true, + ["_rationale"] = "run tests in the active project" + }) + }; + + await new SessionToolPipelineTestFixture( + executor, toolCalls, new SessionId("test/background-resolved-dir"), probe.Ref) + .From(TestMessageSource()) + .InProject("/tmp/active-project") + .WithBackgroundJobs(fakeJobManager) + .ExecuteAsync(TestContext.Current.CancellationToken); + + await probe.ExpectMsgAsync( + TimeSpan.FromSeconds(5), + cancellationToken: TestContext.Current.CancellationToken); + + var received = await jobManagerProbe.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("/tmp/active-project", received.WorkingDirectory); + } + [Fact] public async Task ExplicitBackground_DeniedByAuthorization_DoesNotRouteToBackground() { diff --git a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs index 73c2eecd2..4cea1ec5e 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs @@ -265,8 +265,13 @@ await sessionManager.Ask(new SendUserMessage && m.Text.Contains(OperatingRulesMarker, StringComparison.Ordinal) && m.Text.Contains("You are a summarizer.", StringComparison.Ordinal) && m.Text.Contains("headless, non-interactive worker", StringComparison.Ordinal)); - Assert.Contains(subagentCall, m => - m.Role == Microsoft.Extensions.AI.ChatRole.User && string.Equals(m.Text, "Summarize src/README.md", StringComparison.Ordinal)); + var subagentTask = Assert.Single( + subagentCall, + static message => message.Role == Microsoft.Extensions.AI.ChatRole.User); + Assert.Contains("Context:\n[working-context]", subagentTask.Text, StringComparison.Ordinal); + Assert.Contains("platform: Linux", subagentTask.Text, StringComparison.Ordinal); + Assert.Contains("executable: /bin/bash", subagentTask.Text, StringComparison.Ordinal); + Assert.EndsWith("Task:\nSummarize src/README.md", subagentTask.Text, StringComparison.Ordinal); Assert.DoesNotContain(subagentCall, m => m.Role == Microsoft.Extensions.AI.ChatRole.System && (m.Text?.Contains("test assistant with subagent support", StringComparison.Ordinal) ?? false)); Assert.DoesNotContain(subagentCall, m => @@ -540,18 +545,19 @@ await sessionManager.Ask(new SendUserMessage Assert.Contains(subagentCall, m => m.Role == Microsoft.Extensions.AI.ChatRole.System && (m.Text?.Contains("You specialize in daemon health checks.", StringComparison.Ordinal) ?? false)); - Assert.Contains(subagentCall, m => - m.Role == Microsoft.Extensions.AI.ChatRole.User - && string.Equals(m.Text, "check daemon health", StringComparison.Ordinal)); + var routedTask = Assert.Single( + subagentCall, + static message => message.Role == Microsoft.Extensions.AI.ChatRole.User); + Assert.Contains("Context:\n[working-context]", routedTask.Text, StringComparison.Ordinal); + Assert.Contains("platform: Linux", routedTask.Text, StringComparison.Ordinal); + Assert.Contains("executable: /bin/bash", routedTask.Text, StringComparison.Ordinal); + Assert.EndsWith("Task:\ncheck daemon health", routedTask.Text, StringComparison.Ordinal); Assert.DoesNotContain(subagentCall, m => m.Role == Microsoft.Extensions.AI.ChatRole.System && (m.Text?.Contains(MainIdentityMarker, StringComparison.Ordinal) ?? false)); Assert.DoesNotContain(subagentCall, m => m.Role == Microsoft.Extensions.AI.ChatRole.System && (m.Text?.Contains(AgentsLayerMarker, StringComparison.Ordinal) ?? false)); - Assert.DoesNotContain(subagentCall, m => - m.Role == Microsoft.Extensions.AI.ChatRole.User - && (m.Text?.Contains("Context:", StringComparison.Ordinal) ?? false)); } // NOTE: routing the spawn lifecycle to session.log is no longer per-path-wired — the diff --git a/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs b/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs index d079e2f38..f2ecad3a0 100644 --- a/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -7,11 +7,106 @@ using Microsoft.Extensions.Time.Testing; using Netclaw.Actors.Sessions; using Netclaw.Configuration; +using Netclaw.Security; +using ShellSyntaxTree; namespace Netclaw.Actors.Tests.Sessions; public class WorkingContextSnapshotTests { + [Fact] + public async Task Personal_power_shell_context_renders_without_a_project_directory() + { + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7); + var provider = new WorkingContextSnapshotProvider( + new RecordingGitInspector(), + NullLogger.Instance, + environment); + + var snapshot = await provider.CreateAsync( + WorkingContext.Empty, + TrustAudience.Personal, + TestContext.Current.CancellationToken); + + Assert.Same(environment, snapshot.ShellEnvironment); + Assert.False(snapshot.IsEmpty); + var block = snapshot.ToContextBlock(); + Assert.Contains("platform: Windows", block); + Assert.Contains("executable: C:\\Program Files\\PowerShell\\7\\pwsh.exe", block); + Assert.Contains("grammar: PowerShell", block); + Assert.Contains("dialect: PowerShell7", block); + } + + [Fact] + public async Task Personal_windows_power_shell_fallback_names_its_dialect() + { + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + PwshDialect.WindowsPowerShell51); + var provider = new WorkingContextSnapshotProvider( + new RecordingGitInspector(), + NullLogger.Instance, + environment); + + var snapshot = await provider.CreateAsync( + WorkingContext.Empty, + TrustAudience.Personal, + TestContext.Current.CancellationToken); + + var block = snapshot.ToContextBlock(); + Assert.Contains("powershell.exe", block); + Assert.Contains("dialect: WindowsPowerShell51", block); + Assert.DoesNotContain("PowerShell7", block); + } + + [Theory] + [InlineData(ShellPlatform.Linux)] + [InlineData(ShellPlatform.MacOS)] + public async Task Personal_unix_context_names_bash_without_a_power_shell_dialect( + ShellPlatform platform) + { + var environment = ShellExecutionEnvironment.CreateBash(platform); + var provider = new WorkingContextSnapshotProvider( + new RecordingGitInspector(), + NullLogger.Instance, + environment); + + var snapshot = await provider.CreateAsync( + WorkingContext.Empty, + TrustAudience.Personal, + TestContext.Current.CancellationToken); + + var block = snapshot.ToContextBlock(); + Assert.Contains($"platform: {platform}", block); + Assert.Contains("executable: /bin/bash", block); + Assert.Contains("grammar: Bash", block); + Assert.DoesNotContain("dialect:", block); + } + + [Theory] + [InlineData(TrustAudience.Public)] + [InlineData(TrustAudience.Team)] + public async Task Non_personal_context_omits_shell_identity(TrustAudience audience) + { + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7); + var provider = new WorkingContextSnapshotProvider( + new RecordingGitInspector(), + NullLogger.Instance, + environment); + + var snapshot = await provider.CreateAsync( + WorkingContext.Empty, + audience, + TestContext.Current.CancellationToken); + + Assert.Null(snapshot.ShellEnvironment); + Assert.DoesNotContain("shell:", snapshot.ToContextBlock()); + } + [Fact] public void ParseStatus_reads_branch_divergence_and_dirty_counts() { diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs index e634f05f0..280287cbd 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs @@ -35,6 +35,9 @@ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IService [Fact] public async Task Spawn_async_propagates_parent_resolved_cwd_on_run_message() { + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + ShellSyntaxTree.PwshDialect.PowerShell7); var toolRegistry = new ToolRegistry(); toolRegistry.Register(new FakeNetclawTool("inspect_context", "ok")); @@ -48,13 +51,14 @@ public async Task Spawn_async_propagates_parent_resolved_cwd_on_run_message() TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([])), + new ShellCommandPolicy(environment), + new ToolPathPolicy(environment, [])), approvalService: null, new StaticSystemPromptProvider("You are a summarizer."), new WorkingContextSnapshotProvider( new GitWorkingContextInspector(TimeProvider.System), - NullLogger.Instance), + NullLogger.Instance, + environment), NullLogger.Instance); var childProbe = CreateTestProbe("subagent-child"); @@ -86,6 +90,7 @@ public async Task Spawn_async_propagates_parent_resolved_cwd_on_run_message() Assert.Equal("/tmp/netclaw/sessions/parent", bound.SessionDirectory); Assert.Equal("/home/user/repos/foo", run.Scope.Authority.ProjectDirectory); Assert.Equal("/home/user/repos/foo", run.Scope.Authority.InheritedCwd); + Assert.Same(environment, run.Scope.InitialWorkingSnapshot.ShellEnvironment); childProbe.Reply(new SubAgentResult { diff --git a/src/Netclaw.Actors.Tests/TestShellEnvironment.cs b/src/Netclaw.Actors.Tests/TestShellEnvironment.cs new file mode 100644 index 000000000..0e5be8822 --- /dev/null +++ b/src/Netclaw.Actors.Tests/TestShellEnvironment.cs @@ -0,0 +1,62 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Security; +using Netclaw.Daemon; +using ShellSyntaxTree; + +namespace Netclaw.Actors.Tests; + +internal static class TestShellEnvironment +{ + public static ShellExecutionEnvironment Current { get; } = CreateCurrent(); + + public static string PrintWorkingDirectoryCommand => + Current.Grammar == ShellGrammar.PowerShell + ? "(Get-Location).Path" + : "pwd"; + + public static string LongRunningCommand => + Current.Grammar == ShellGrammar.PowerShell + ? "Start-Sleep -Seconds 300" + : "sleep 300"; + + public static string StandardErrorCommand => + Current.Grammar == ShellGrammar.PowerShell + ? "[Console]::Error.WriteLine('error')" + : "echo error >&2"; + + public static string TwoOutputLinesCommand => + Current.Grammar == ShellGrammar.PowerShell + ? "Write-Output hello; Write-Output world" + : "echo hello && echo world"; + + public static ShellExecutionEnvironment CreateWindowsPowerShell51() + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "Windows PowerShell 5.1 tests require Windows."); + } + + var systemDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System); + var executablePath = Path.Combine( + systemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + return ShellExecutionEnvironment.CreatePowerShell( + executablePath, + PwshDialect.WindowsPowerShell51); + } + + private static ShellExecutionEnvironment CreateCurrent() + => ShellExecutionEnvironmentResolver + .CreateDefault(TimeProvider.System) + .ResolveAsync(ShellExecutionEnvironmentResolver.DetectCurrentPlatform()) + .GetAwaiter() + .GetResult() + .Environment; +} diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 716be2778..cc55f7ed9 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -483,7 +483,9 @@ public async Task Shell_parser_rejection_fails_closed_without_execution() var command = $"touch {markerPath} <(true)"; var arguments = ToolInput.Create("Command", command); Assert.False(ShellTokenizer.IsMessyCompoundCommand(command)); - Assert.Empty(ShellApprovalMatcher.Instance.ExtractCandidates(new ToolName("shell_execute"), arguments)); + var matcher = new ShellApprovalMatcher( + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux)); + Assert.Empty(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments)); var executor = CreateApprovalGatedShellExecutor(); var call = new FunctionCallContent( diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs index 5db51e7af..213b7a19b 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs @@ -6,6 +6,8 @@ using System.Collections.Frozen; using Netclaw.Actors.Tools; using Netclaw.Configuration; +using Netclaw.Security; +using ShellSyntaxTree; using Xunit; namespace Netclaw.Actors.Tests.Tools; @@ -55,11 +57,33 @@ internal enum ApprovalSessionShape Other } +internal enum ShellApprovalHost +{ + Bash, + PowerShell7, + WindowsPowerShell51 +} + internal sealed record ShellApprovalInvocation( string Command, ApprovalDirectoryShape WorkingDirectory = ApprovalDirectoryShape.Project, TrustAudience Audience = TrustAudience.Personal, - bool Interactive = true); + bool Interactive = true, + ShellApprovalHost Host = ShellApprovalHost.Bash) +{ + public ShellExecutionEnvironment CreateEnvironment() + => Host switch + { + ShellApprovalHost.Bash => ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux), + ShellApprovalHost.PowerShell7 => ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7), + ShellApprovalHost.WindowsPowerShell51 => ShellExecutionEnvironment.CreatePowerShell( + @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + PwshDialect.WindowsPowerShell51), + _ => throw new ArgumentOutOfRangeException(nameof(Host), Host, "Unknown shell approval host.") + }; +} internal sealed record ApprovalSeed( ApprovalSeedSource Source, @@ -494,110 +518,119 @@ public static class ShellApprovalCases Approvals.PersistentAnywhere("bash"), ExpectedApproval.Require(["git push"])), Case( - "pwsh-safe-child-still-requires-host-approval", - Bash("pwsh -NoProfile -NonInteractive -Command 'git status'"), + "bash-treats-pwsh-payload-as-ordinary-argument", + Bash("pwsh -NoProfile -Command 'Get-Content ./a.txt'"), Approvals.None, ExpectedApproval.Require(["pwsh"])), Case( - "pwsh-exported-function-risk-still-requires-host-approval", - Bash("pwsh -NoProfile -NonInteractive -Command 'git status'"), - Approvals.None, - ExpectedApproval.Require(["pwsh"])), + "bash-pwsh-grant-covers-authored-external-command", + Bash("pwsh -NoProfile -Command 'git push'"), + Approvals.PersistentAnywhere("pwsh"), + ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:pwsh")), Case( - "pwsh-bash-env-risk-still-requires-host-approval", - Bash("pwsh -NoProfile -NonInteractive -Command 'git status'"), - Approvals.PersistentAnywhere("git status"), + "bash-pwsh-payload-grant-does-not-cover-authored-command", + Bash("pwsh -NoProfile -Command 'git push'"), + Approvals.PersistentAnywhere("git push"), ExpectedApproval.Require(["pwsh"])), Case( - "pwsh-host-grant-composes-with-safe-child", - Bash("pwsh -NoProfile -NonInteractive -Command 'git status'"), - Approvals.PersistentAnywhere("pwsh"), - ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:pwsh")), + "bash-treats-windows-powershell-as-ordinary-command", + Bash("powershell.exe -NoProfile -Command 'Get-Content ./a.txt'"), + Approvals.None, + ExpectedApproval.Require(["powershell.exe"])), Case( - "pwsh-host-grant-does-not-cover-mutating-child", - Bash("pwsh -NoProfile -NonInteractive -Command 'git push'"), - Approvals.PersistentAnywhere("pwsh"), - ExpectedApproval.Require(["git push"], approvalMatches: ["persistent:pwsh"])), + "powershell7-safe-command-allows", + PowerShell7("Get-ChildItem -Path . -Filter *.cs"), + Approvals.None, + ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), Case( - "pwsh-child-grant-does-not-cover-host", - Bash("pwsh -NoProfile -NonInteractive -Command 'git push'"), - Approvals.PersistentAnywhere("git push"), - ExpectedApproval.Require(["pwsh"], approvalMatches: ["persistent:git push"])), + "powershell7-pipeline-prompts-for-unsafe-stage", + PowerShell7("Get-ChildItem | Remove-Item"), + Approvals.None, + ExpectedApproval.Require(["Remove-Item"])), Case( - "pwsh-host-and-child-grants-allow", - Bash("pwsh -NoProfile -NonInteractive -Command 'git push'"), - Approvals.PersistentAnywhere("pwsh", "git push"), - ExpectedApproval.Allow( - ToolAllowReason.StoredApproval, - 1, - "persistent:pwsh", - "persistent:git push")), + "powershell7-stored-grant-covers-unsafe-stage", + PowerShell7("Get-ChildItem | Remove-Item"), + Approvals.PersistentAnywhere("Remove-Item"), + ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:Remove-Item")), Case( - "pwsh-working-directory-option-fails-closed", - Bash("pwsh -NoProfile -NonInteractive -WorkingDirectory /etc -Command 'git status'"), - Approvals.PersistentAnywhere("pwsh", "git status"), - ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + "powershell7-stop-process-hard-deny", + PowerShell7("Stop-Process -Id 42"), + Approvals.PersistentAnywhere("Stop-Process"), + ExpectedApproval.Deny("hard_deny_self_destructive")), Case( - "pwsh-builtin-command-prefix-fails-closed", - Bash("builtin command pwsh -NoProfile -NonInteractive -Command 'git push'"), - Approvals.PersistentAnywhere("builtin command pwsh", "git push"), - ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + "powershell7-elevated-process-hard-deny", + PowerShell7("Start-Process pwsh -Verb RunAs"), + Approvals.PersistentAnywhere("Start-Process"), + ExpectedApproval.Deny("hard_deny_privilege_escalation")), Case( - "pwsh-absolute-env-prefix-fails-closed", - Bash("/usr/bin/env -i pwsh -NoProfile -NonInteractive -Command 'git push'"), - Approvals.PersistentAnywhere("/usr/bin/env", "git push"), - ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + "powershell7-elevated-process-abbreviated-quoted-hard-deny", + PowerShell7("Start-Process pwsh -Ve 'RunAs'"), + Approvals.PersistentAnywhere("Start-Process"), + ExpectedApproval.Deny("hard_deny_privilege_escalation")), Case( - "pwsh-xargs-prefix-fails-closed", - Bash("xargs -n1 pwsh -NoProfile -NonInteractive -Command 'git push'"), - Approvals.PersistentAnywhere("xargs", "git push"), - ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + "powershell7-recursive-root-removal-hard-deny", + PowerShell7(@"Remove-Item C:\ -Recurse -Confirm:$false"), + Approvals.PersistentAnywhere("Remove-Item"), + ExpectedApproval.Deny("hard_deny_system_destructive")), Case( - "windows-powershell-host-fails-closed", - Bash("powershell -NoProfile -NonInteractive -Command 'git status'"), - Approvals.PersistentAnywhere("powershell", "git status"), + "powershell7-dynamic-command-fails-closed", + PowerShell7("& $command"), + Approvals.PersistentAnywhere("Get-ChildItem"), ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "differently-cased-pwsh-host-fails-closed", - Bash("PWSH -NoProfile -NonInteractive -Command 'git status'"), - Approvals.PersistentAnywhere("PWSH", "git status"), - ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + "powershell7-treats-bash-payload-as-ordinary-argument", + PowerShell7("bash -lc 'Remove-Item victim.txt'"), + Approvals.None, + ExpectedApproval.Require(["bash"])), Case( - "pwsh-dynamic-child-fails-closed", - Bash("pwsh -NoProfile -NonInteractive -Command 'git $operation'"), - Approvals.PersistentAnywhere("pwsh", "git"), - ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + "powershell7-bash-grant-covers-authored-external-command", + PowerShell7("bash -lc 'Remove-Item victim.txt'"), + Approvals.PersistentAnywhere("bash"), + ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:bash")), Case( - "pwsh-command-resolution-mutation-fails-closed", - Bash("pwsh -NoProfile -NonInteractive -Command 'Set-Alias git Remove-Item; git victim.txt'"), - Approvals.PersistentAnywhere("pwsh", "Set-Alias", "git"), - ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + "powershell7-same-language-child-recurses-to-body", + PowerShell7("pwsh -NoProfile -Command 'Remove-Item victim.txt'"), + Approvals.None, + ExpectedApproval.Require(["Remove-Item"])), + Case( + "powershell7-alias-resolves-before-safe-verb-check", + PowerShell7("gci"), + Approvals.None, + ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), Case( - "pwsh-unknown-script-block-receiver-fails-closed", - Bash("pwsh -NoProfile -NonInteractive -Command 'Invoke-CustomAction { Remove-Item victim.txt }'"), - Approvals.PersistentAnywhere("pwsh", "Invoke-CustomAction", "Remove-Item"), + "powershell7-local-redirect-keeps-safe-command", + PowerShell7(@"Get-Content .\input.txt > .\output.txt"), + Approvals.None, + ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), + Case( + "powershell7-protected-path-denies-before-approval", + PowerShell7(@"Get-Content C:\protected\config\secret.txt"), + Approvals.PersistentAnywhere("Get-Content"), + ExpectedApproval.Deny("shell_references_protected_path")), + Case( + "powershell7-provider-drive-is-reviewed", + PowerShell7(@"Get-Content Env:\Path"), + Approvals.None, ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "pwsh-corpus-418-data-script-block-stays-strict", - Bash("pwsh -NoProfile -NonInteractive -Command 'Write-Output { Remove-Item target.txt }'"), - Approvals.PersistentAnywhere("pwsh", "Write-Output", "Remove-Item"), + "powershell7-incomplete-pipeline-fails-closed", + PowerShell7("Get-ChildItem |"), + Approvals.PersistentAnywhere("Get-ChildItem"), ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "pwsh-executable-script-block-prompts-for-body", - Bash("pwsh -NoProfile -NonInteractive -Command '& { git push }'"), - Approvals.PersistentAnywhere("pwsh"), - ExpectedApproval.Require( - ["git push"], - approvalMatches: ["persistent:pwsh"])), + "powershell51-safe-command-allows", + WindowsPowerShell51("Get-ChildItem"), + Approvals.None, + ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), Case( - "pwsh-executable-script-block-hard-deny-wins", - Bash("pwsh -NoProfile -NonInteractive -Command 'Invoke-Command { netclaw daemon stop }'"), - Approvals.PersistentAnywhere("pwsh", "Invoke-Command", "netclaw daemon stop"), - ExpectedApproval.Deny("hard_deny_self_destructive")), + "powershell51-pipeline-chain-fails-closed", + WindowsPowerShell51("Get-ChildItem && Get-Content .\\a.txt"), + Approvals.PersistentAnywhere("Get-ChildItem", "Get-Content"), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), Case( - "pwsh-bash-decoding-cannot-hide-hard-deny", - Bash("pwsh -NoProfile -NonInteractive -Command 'Write-Output '' ; netclaw daemon stop; #'''"), - Approvals.PersistentAnywhere("pwsh", "Write-Output", "netclaw daemon stop"), + "powershell51-stop-process-hard-deny", + WindowsPowerShell51("Stop-Process -Name netclaw"), + Approvals.PersistentAnywhere("Stop-Process"), ExpectedApproval.Deny("hard_deny_self_destructive")), Case( "env-nested-shell-prompts", @@ -1369,10 +1402,21 @@ public static class ShellApprovalCases All.ToFrozenDictionary(testCase => testCase.Id, StringComparer.Ordinal); public static IEnumerable> Rows => All.Select(testCase => + CreateRow(testCase)); + + public static IEnumerable> BashRows => All + .Where(testCase => testCase.Invocation.Host == ShellApprovalHost.Bash) + .Select(CreateRow); + + public static IEnumerable> PowerShellRows => All + .Where(testCase => testCase.Invocation.Host != ShellApprovalHost.Bash) + .Select(CreateRow); + + private static TheoryDataRow CreateRow(ShellApprovalCase testCase) => new TheoryDataRow(testCase.Id) .WithTestDisplayName($"shell approval :: {testCase.Id}") .WithTrait("Disposition", testCase.Expected.Outcome.ToString()) - .WithTrait("AllowReason", testCase.Expected.AllowReason?.ToString() ?? "NotAllowed")); + .WithTrait("AllowReason", testCase.Expected.AllowReason?.ToString() ?? "NotAllowed"); internal static ShellApprovalCase Get(string id) => CasesById[id]; @@ -1386,12 +1430,12 @@ internal static string RenderReviewTable() string.Empty, "`Personal.ApprovalPolicy.shell_execute`: `Approval`", string.Empty, - "| ID | Audience | Cwd | Interaction | Command | Approval state | Result | Reason | Candidates | Complex |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |" + "| ID | Host | Audience | Cwd | Interaction | Command | Approval state | Result | Reason | Candidates | Complex |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |" }; lines.AddRange(All.Select(testCase => - $"| {testCase.Id} | {testCase.Invocation.Audience} | {testCase.Invocation.WorkingDirectory} | " + + $"| {testCase.Id} | {testCase.Invocation.Host} | {testCase.Invocation.Audience} | {testCase.Invocation.WorkingDirectory} | " + $"{(testCase.Invocation.Interactive ? "Interactive" : "Non-interactive")} | " + $"{Escape(testCase.Invocation.Command)} | " + $"{Escape(testCase.Approvals.Display)} | {testCase.Expected.Outcome} | " + @@ -1415,6 +1459,20 @@ private static ShellApprovalInvocation Bash( bool interactive = true) => new(command, workingDirectory, audience, interactive); + private static ShellApprovalInvocation PowerShell7( + string command, + ApprovalDirectoryShape workingDirectory = ApprovalDirectoryShape.Project, + TrustAudience audience = TrustAudience.Personal, + bool interactive = true) + => new(command, workingDirectory, audience, interactive, ShellApprovalHost.PowerShell7); + + private static ShellApprovalInvocation WindowsPowerShell51( + string command, + ApprovalDirectoryShape workingDirectory = ApprovalDirectoryShape.Project, + TrustAudience audience = TrustAudience.Personal, + bool interactive = true) + => new(command, workingDirectory, audience, interactive, ShellApprovalHost.WindowsPowerShell51); + private static string TemporaryFile(string fileName) => $"/netclaw-approval-external/{fileName}"; 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 67515d9f6..bff7c50ba 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 @@ -4,209 +4,212 @@ `Personal.ApprovalPolicy.shell_execute`: `Approval` -| ID | Audience | Cwd | Interaction | Command | Approval state | Result | Reason | Candidates | Complex | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| mutating-command-prompts | Personal | Project | Interactive | git push origin dev | none | RequiresApproval | approval required | git push origin dev | No | -| team-audience-denied | Team | Project | Interactive | git push | none | Denied | tool_not_allowed_for_audience_profile | none | Not applicable | -| public-audience-denied | Public | Project | Interactive | git push | none | Denied | tool_not_allowed_for_audience_profile | none | Not applicable | -| hard-deny-blocks | Personal | Project | Interactive | netclaw daemon stop | none | Denied | hard_deny_self_destructive | none | Not applicable | -| hard-deny-beats-stored-grant | Personal | Project | Interactive | netclaw daemon stop | persistent[anywhere]:netclaw daemon stop | Denied | hard_deny_self_destructive | none | Not applicable | -| compound-hard-deny-denies | Personal | Project | Interactive | git status && netclaw daemon stop | none | Denied | hard_deny_self_destructive | none | Not applicable | -| safe-verb-project-allows | Personal | Project | Interactive | git status | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| safe-verb-context-project-fallback-allows | Personal | None | Interactive | cat src/readme.txt | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| safe-verb-context-project-traversal-prompts | Personal | None | Interactive | cat ../secret.txt | none | RequiresApproval | approval required | cat | No | -| safe-verb-session-allows | Personal | Session | Interactive | git status | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| safe-verb-external-prompts | Personal | External | Interactive | git status | none | RequiresApproval | approval required | git status | No | -| safe-verb-external-path-prompts | Personal | Project | Interactive | cat /etc/passwd | none | RequiresApproval | approval required | cat | No | -| safe-verb-quoted-external-path-prompts | Personal | Project | Interactive | cat "/etc/netclaw.secret" | none | RequiresApproval | approval required | cat | No | -| safe-verb-traversal-external-path-prompts | Personal | Project | Interactive | cat safe/../../../../../../etc/netclaw.secret | none | RequiresApproval | approval required | cat | No | -| safe-verb-bash-provider-looking-relative-path-allows | Personal | Project | Interactive | cat filesystem::/etc/netclaw.secret | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| safe-verb-external-redirect-prompts | Personal | Project | Interactive | git status > /netclaw-approval-external/netclaw-approval-matrix.txt | none | RequiresApproval | approval required | git status | No | -| mutating-verb-project-prompts | Personal | Project | Interactive | git push | none | RequiresApproval | approval required | git push | No | -| all-safe-compound-allows | Personal | Project | Interactive | git status && git log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| four-safe-mixed-operator-clauses-allow | Personal | Project | Interactive | git status && git log \| head -20; pwd | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| mixed-safe-unsafe-compound-prompts | Personal | Project | Interactive | git status && git push | none | RequiresApproval | approval required | git push | No | -| safe-pipe-unsafe-tail-prompts | Personal | Project | Interactive | git status \| git push | none | RequiresApproval | approval required | git push | No | -| safe-pipeline-allows | Personal | Project | Interactive | git log \| head -20 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| native-project-path-operand-allows-safe-verb | Personal | Project | Interactive | git diff install-skills.sh | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| native-external-path-operand-prompts | Personal | Project | Interactive | git diff /etc/passwd | none | RequiresApproval | approval required | git diff | No | -| native-project-path-operand-reuses-grant | Personal | Project | Interactive | kubectl apply deployment.yaml | persistent[project]:kubectl apply | Allowed | StoredApproval | none | Not applicable | -| native-external-path-operand-does-not-reuse-project-grant | Personal | Project | Interactive | kubectl apply /etc/deployment.yaml | persistent[project]:kubectl apply | RequiresApproval | approval required | kubectl apply | No | -| native-output-option-outside-scope-prompts | Personal | Project | Interactive | curl -D /etc/netclaw.headers https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | curl | No | -| native-command-valued-option-fails-closed | Personal | Project | Interactive | tar --info-script=./helper.sh archive.tar | persistent[project]:tar | RequiresApproval | approval required | none | Yes | -| native-project-file-reference-reuses-grant | Personal | Project | Interactive | curl --data=@request.json https://example.invalid/api | persistent[project]:curl | Allowed | StoredApproval | none | Not applicable | -| native-external-file-reference-prompts | Personal | Project | Interactive | curl --data=@/etc/passwd https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | curl | No | -| native-later-external-path-prompts | Personal | Project | Interactive | curl -D ./headers.txt --data=@/etc/passwd https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | curl | No | -| native-earlier-external-path-prompts | Personal | Project | Interactive | curl -D /etc/netclaw.headers --data=@request.json https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | curl | No | -| native-two-project-paths-reuse-grant | Personal | Project | Interactive | curl -D ./headers.txt --data=@request.json https://example.invalid/api | persistent[project]:curl | Allowed | StoredApproval | none | Not applicable | -| native-option-and-redirect-scopes-all-checked | Personal | Project | Interactive | curl --data=@/etc/passwd https://example.invalid/api > ./response.json | persistent[project]:curl | RequiresApproval | approval required | curl | No | -| native-dynamic-file-reference-fails-closed | Personal | Project | Interactive | curl --data=@$REQUEST_FILE https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | none | Yes | -| local-glob-allows-safe-verb | Personal | Project | Interactive | ls *.txt | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| local-glob-reuses-project-grant | Personal | Project | Interactive | rm *.tmp | persistent[project]:rm | Allowed | StoredApproval | none | Not applicable | -| external-glob-does-not-reuse-project-grant | Personal | Project | Interactive | rm /netclaw-approval-external/netclaw-ext-glob/*.bak | persistent[project]:rm | RequiresApproval | approval required | rm | No | -| glob-traversal-fails-closed | Personal | Project | Interactive | cat */../../secret.txt | persistent[anywhere]:cat | RequiresApproval | approval required | none | Yes | -| glob-intermediate-symlink-scope-fails-closed | Personal | Project | Interactive | cat artifacts/*/secret.txt | persistent[anywhere]:cat | RequiresApproval | approval required | none | Yes | -| directory-listing-glob-in-project-auto-allows | Personal | Project | Interactive | ls -d subdirs/*/ | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| directory-listing-glob-external-offers-persistent-grant | Personal | External | Interactive | ls -d subdirs/*/ | none | RequiresApproval | approval required | ls | No | -| directory-listing-glob-pipeline-offers-persistent-grant | Personal | External | Interactive | ls -d subdirs/*/ \| xargs -n1 basename | none | RequiresApproval | approval required | ls, xargs | No | -| native-global-option-identity-gap-currently-prompts | Personal | Project | Interactive | git --no-pager status | persistent[project]:git status | RequiresApproval | approval required | git | No | -| semicolon-sequence-prompts | Personal | Project | Interactive | git status; git push | none | RequiresApproval | approval required | git push | No | -| newline-sequence-prompts | Personal | Project | Interactive | git status\ngit push | none | RequiresApproval | approval required | git push | No | -| or-chain-prompts | Personal | Project | Interactive | git status \|\| git push | none | RequiresApproval | approval required | git push | No | -| three-step-release-prompts | Personal | Project | Interactive | git add . && git commit -m fix && git push origin dev | none | RequiresApproval | approval required | git add, git commit, git push origin dev | No | -| hard-deny-pipeline-tail-blocks | Personal | Project | Interactive | echo safe \| netclaw daemon stop | none | Denied | hard_deny_self_destructive | none | Not applicable | -| hard-deny-nested-shell-blocks | Personal | Project | Interactive | bash -lc "netclaw daemon stop" | none | Denied | hard_deny_self_destructive | none | Not applicable | -| hard-deny-sudo-nested-shell-blocks | Personal | Project | Interactive | sudo bash -lc "git status" | none | Denied | hard_deny_privilege_escalation | none | Not applicable | -| hard-deny-dash-shell-blocks | Personal | Project | Interactive | /bin/dash -c "netclaw daemon stop" | persistent[anywhere]:/bin/dash | Denied | hard_deny_self_destructive | none | Not applicable | -| nested-shell-prompts-for-inner-command | Personal | Project | Interactive | bash -lc "git push" | none | RequiresApproval | approval required | git push | No | -| nested-shell-inner-grant-allows | Personal | Project | Interactive | bash -lc "git push" | persistent[anywhere]:git push | Allowed | StoredApproval | none | Not applicable | -| nested-shell-wrapper-grant-does-not-cover-inner-command | Personal | Project | Interactive | bash -lc "git push" | persistent[anywhere]:bash | RequiresApproval | approval required | git push | No | -| pwsh-safe-child-still-requires-host-approval | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'git status' | none | RequiresApproval | approval required | pwsh | No | -| pwsh-exported-function-risk-still-requires-host-approval | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'git status' | none | RequiresApproval | approval required | pwsh | No | -| pwsh-bash-env-risk-still-requires-host-approval | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'git status' | persistent[anywhere]:git status | RequiresApproval | approval required | pwsh | No | -| pwsh-host-grant-composes-with-safe-child | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'git status' | persistent[anywhere]:pwsh | Allowed | StoredApproval | none | Not applicable | -| pwsh-host-grant-does-not-cover-mutating-child | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'git push' | persistent[anywhere]:pwsh | RequiresApproval | approval required | git push | No | -| pwsh-child-grant-does-not-cover-host | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'git push' | persistent[anywhere]:git push | RequiresApproval | approval required | pwsh | No | -| pwsh-host-and-child-grants-allow | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'git push' | persistent[anywhere]:pwsh, persistent[anywhere]:git push | Allowed | StoredApproval | none | Not applicable | -| pwsh-working-directory-option-fails-closed | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -WorkingDirectory /etc -Command 'git status' | persistent[anywhere]:pwsh, persistent[anywhere]:git status | RequiresApproval | approval required | none | Yes | -| pwsh-builtin-command-prefix-fails-closed | Personal | Project | Interactive | builtin command pwsh -NoProfile -NonInteractive -Command 'git push' | persistent[anywhere]:builtin command pwsh, persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | -| pwsh-absolute-env-prefix-fails-closed | Personal | Project | Interactive | /usr/bin/env -i pwsh -NoProfile -NonInteractive -Command 'git push' | persistent[anywhere]:/usr/bin/env, persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | -| pwsh-xargs-prefix-fails-closed | Personal | Project | Interactive | xargs -n1 pwsh -NoProfile -NonInteractive -Command 'git push' | persistent[anywhere]:xargs, persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | -| windows-powershell-host-fails-closed | Personal | Project | Interactive | powershell -NoProfile -NonInteractive -Command 'git status' | persistent[anywhere]:powershell, persistent[anywhere]:git status | RequiresApproval | approval required | none | Yes | -| differently-cased-pwsh-host-fails-closed | Personal | Project | Interactive | PWSH -NoProfile -NonInteractive -Command 'git status' | persistent[anywhere]:PWSH, persistent[anywhere]:git status | RequiresApproval | approval required | none | Yes | -| pwsh-dynamic-child-fails-closed | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'git $operation' | persistent[anywhere]:pwsh, persistent[anywhere]:git | RequiresApproval | approval required | none | Yes | -| pwsh-command-resolution-mutation-fails-closed | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'Set-Alias git Remove-Item; git victim.txt' | persistent[anywhere]:pwsh, persistent[anywhere]:Set-Alias, persistent[anywhere]:git | RequiresApproval | approval required | none | Yes | -| pwsh-unknown-script-block-receiver-fails-closed | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'Invoke-CustomAction { Remove-Item victim.txt }' | persistent[anywhere]:pwsh, persistent[anywhere]:Invoke-CustomAction, persistent[anywhere]:Remove-Item | RequiresApproval | approval required | none | Yes | -| pwsh-corpus-418-data-script-block-stays-strict | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'Write-Output { Remove-Item target.txt }' | persistent[anywhere]:pwsh, persistent[anywhere]:Write-Output, persistent[anywhere]:Remove-Item | RequiresApproval | approval required | none | Yes | -| pwsh-executable-script-block-prompts-for-body | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command '& { git push }' | persistent[anywhere]:pwsh | RequiresApproval | approval required | git push | No | -| pwsh-executable-script-block-hard-deny-wins | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'Invoke-Command { netclaw daemon stop }' | persistent[anywhere]:pwsh, persistent[anywhere]:Invoke-Command, persistent[anywhere]:netclaw daemon stop | Denied | hard_deny_self_destructive | none | Not applicable | -| pwsh-bash-decoding-cannot-hide-hard-deny | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'Write-Output '' ; netclaw daemon stop; #''' | persistent[anywhere]:pwsh, persistent[anywhere]:Write-Output, persistent[anywhere]:netclaw daemon stop | Denied | hard_deny_self_destructive | none | Not applicable | -| env-nested-shell-prompts | Personal | Project | Interactive | env bash -lc "git push" | none | RequiresApproval | approval required | env bash, git push | No | -| nohup-nested-shell-prompts | Personal | Project | Interactive | nohup bash -lc "git push" | none | RequiresApproval | approval required | nohup bash, git push | No | -| timeout-nested-shell-prompts | Personal | Project | Interactive | timeout 5 bash -lc "git push" | none | RequiresApproval | approval required | timeout, git push | No | -| subshell-prompts | Personal | Project | Interactive | (git status && git push) | none | RequiresApproval | approval required | git push | No | -| command-substitution-fails-closed | Personal | Project | Interactive | echo $(git push) | none | RequiresApproval | approval required | none | Yes | -| dynamic-path-fails-closed | Personal | Project | Interactive | cat "$FILE" | none | RequiresApproval | approval required | none | Yes | -| dynamic-redirect-fails-closed | Personal | Project | Interactive | git status > "$OUTPUT" | none | RequiresApproval | approval required | none | Yes | -| fd-dup-redirect-safe-verb-allows | Personal | Project | Interactive | git status 2>&1 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| fd-dup-redirect-safe-pipeline-allows | Personal | Project | Interactive | git log --oneline -5 2>&1 \| tail -20 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| fd-close-redirect-safe-verb-allows | Personal | Project | Interactive | git status 2>&- | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| fd-move-redirect-safe-verb-allows | Personal | Project | Interactive | git status 2>&1- | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| combined-output-project-redirect-safe-verb-allows | Personal | Project | Interactive | git status &> result.log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| combined-output-append-project-redirect-safe-verb-allows | Personal | Project | Interactive | git status &>> result.log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| numeric-source-project-redirect-safe-verb-allows | Personal | Project | Interactive | git status 3> result.log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| fd-dup-redirect-mutating-no-grant-prompts-not-messy | 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 | Personal | Project | Interactive | git status 2>&$FD | none | RequiresApproval | approval required | none | Yes | -| background-list-prompts-for-mutating-tail | Personal | Project | Interactive | git status & git push | none | RequiresApproval | approval required | none | Yes | -| unbalanced-quote-fails-closed | Personal | Project | Interactive | git push "unterminated | none | RequiresApproval | approval required | none | Yes | -| multiline-argument-prompts | Personal | Project | Interactive | gh issue comment 123 --body "first line\nsecond line" | none | RequiresApproval | approval required | gh issue comment | No | -| approved-pipeline-head-does-not-cover-tail | Personal | Project | Interactive | git push \| curl https://example.com | persistent[anywhere]:git push | RequiresApproval | approval required | curl | No | -| all-pipeline-clauses-approved | Personal | Project | Interactive | git push \| curl https://example.com | persistent[anywhere]:git push, persistent[anywhere]:curl | Allowed | StoredApproval | none | Not applicable | -| input-redirect-outside-zone-prompts | Personal | Project | Interactive | cat < /netclaw-approval-external/netclaw-approval-input.txt | none | RequiresApproval | approval required | cat | No | -| error-redirect-outside-zone-prompts | Personal | Project | Interactive | git status 2> /netclaw-approval-external/netclaw-approval-errors.txt | none | RequiresApproval | approval required | git status | No | -| cd-current-then-safe-allows | Personal | Project | Interactive | cd . && git status | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| cd-parent-then-safe-prompts | Personal | Project | Interactive | cd .. && git status | none | RequiresApproval | approval required | cd, git status | No | -| multiple-cd-then-safe-prompts | Personal | Project | Interactive | cd . && cd .. && git status | none | RequiresApproval | approval required | cd, git status | No | -| side-effect-before-mutation-prompts | Personal | Project | Interactive | echo ready && git push | none | RequiresApproval | approval required | git push | No | -| literal-heredoc-cat-allows | Personal | Project | Interactive | cat <<'EOF'\nhello\nEOF | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| expanding-heredoc-cat-prompts | Personal | Project | Interactive | cat < reports/output.txt | none | RequiresApproval | approval required | printf | No | -| workload-edit-printf-redirect-grant-allows | 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 | Personal | Project | Interactive | grep -R "error" logs \| head -20 > reports/errors.txt | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| workload-edit-search-pipeline-redirect-external-prompts | 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 | 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-currently-complex | Personal | Project | Interactive | for f in src/*.cs; do grep -n "TODO" "$f"; done | persistent[project]:grep | RequiresApproval | approval required | none | Yes | -| workload-edit-loop-currently-complex | Personal | Project | Interactive | for f in src/a.txt src/b.txt; do sed -i 's/old/new/' "$f"; done | persistent[project]:sed | RequiresApproval | approval required | none | Yes | -| workload-search-dynamic-root-remains-complex | Personal | Project | Interactive | grep -R "error" "$SEARCH_ROOT" | persistent[anywhere]:grep | RequiresApproval | approval required | none | Yes | -| workload-search-substitution-pipeline-redirect-remains-complex | Personal | Project | Interactive | pattern=$(printf '%s' error); grep -R "$pattern" src \| head -20 > reports/errors.txt | persistent[project]:grep, persistent[project]:head, persistent[project]:printf | RequiresApproval | approval required | none | Yes | -| workload-search-loop-substitution-pipeline-redirect-remains-complex | Personal | Project | Interactive | for f in logs/*.log; do grep -n "$(printf '%s' error)" "$f" \| head -20 > "reports/$f.txt"; done | persistent[project]:grep, persistent[project]:head, persistent[project]:printf | RequiresApproval | approval required | none | Yes | -| echo-allows-without-grant | Personal | Project | Interactive | echo hello | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | -| printf-allows-without-grant | Personal | Project | Interactive | printf hello | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | -| echo-redirect-prompts | Personal | Project | Interactive | echo hello > result.txt | none | RequiresApproval | approval required | echo | No | -| echo-control-word-argument-allows | Personal | Project | Interactive | echo done | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | -| control-flow-fails-closed | Personal | Project | Interactive | for f in *.txt; do cat "$f"; done | persistent[anywhere]:cat | RequiresApproval | approval required | none | Yes | -| process-substitution-fails-closed | Personal | Project | Interactive | cat <(git push) | persistent[anywhere]:cat, persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | -| arithmetic-expansion-fails-closed | Personal | Project | Interactive | echo $((1 + 2)) | none | RequiresApproval | approval required | none | Yes | -| function-definition-fails-closed | Personal | Project | Interactive | deploy() { git push; }; deploy | persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | -| unknown-state-named-parameter-fails-closed | Personal | Project | Interactive | printf '%s' "$value" | persistent[anywhere]:printf | RequiresApproval | approval required | none | Yes | -| nameref-deferred-execution-fails-closed | Personal | Project | Interactive | declare -a values; declare -n current='values[$(printf marker >&2)0]'; cat < /netclaw-approval-external/netclaw-approval-matrix.txt | none | RequiresApproval | approval required | git status | 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 log | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| four-safe-mixed-operator-clauses-allow | Bash | Personal | Project | Interactive | git status && git log \| head -20; pwd | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| mixed-safe-unsafe-compound-prompts | Bash | Personal | Project | Interactive | git status && git push | none | RequiresApproval | approval required | git push | No | +| safe-pipe-unsafe-tail-prompts | Bash | Personal | Project | Interactive | git status \| git push | none | RequiresApproval | approval required | git push | No | +| safe-pipeline-allows | Bash | Personal | Project | Interactive | git log \| head -20 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| native-project-path-operand-allows-safe-verb | Bash | Personal | Project | Interactive | git diff install-skills.sh | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| native-external-path-operand-prompts | Bash | Personal | Project | Interactive | git diff /etc/passwd | none | RequiresApproval | approval required | git diff | No | +| native-project-path-operand-reuses-grant | Bash | Personal | Project | Interactive | kubectl apply deployment.yaml | persistent[project]:kubectl apply | Allowed | StoredApproval | none | Not applicable | +| native-external-path-operand-does-not-reuse-project-grant | Bash | Personal | Project | Interactive | kubectl apply /etc/deployment.yaml | persistent[project]:kubectl apply | RequiresApproval | approval required | kubectl apply | No | +| native-output-option-outside-scope-prompts | Bash | Personal | Project | Interactive | curl -D /etc/netclaw.headers https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | curl | No | +| native-command-valued-option-fails-closed | Bash | Personal | Project | Interactive | tar --info-script=./helper.sh archive.tar | persistent[project]:tar | RequiresApproval | approval required | none | Yes | +| native-project-file-reference-reuses-grant | Bash | Personal | Project | Interactive | curl --data=@request.json https://example.invalid/api | persistent[project]:curl | Allowed | StoredApproval | none | Not applicable | +| native-external-file-reference-prompts | Bash | Personal | Project | Interactive | curl --data=@/etc/passwd https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | curl | No | +| native-later-external-path-prompts | Bash | Personal | Project | Interactive | curl -D ./headers.txt --data=@/etc/passwd https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | curl | No | +| native-earlier-external-path-prompts | Bash | Personal | Project | Interactive | curl -D /etc/netclaw.headers --data=@request.json https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | curl | No | +| native-two-project-paths-reuse-grant | Bash | Personal | Project | Interactive | curl -D ./headers.txt --data=@request.json https://example.invalid/api | persistent[project]:curl | Allowed | StoredApproval | none | Not applicable | +| native-option-and-redirect-scopes-all-checked | Bash | Personal | Project | Interactive | curl --data=@/etc/passwd https://example.invalid/api > ./response.json | persistent[project]:curl | RequiresApproval | approval required | curl | No | +| native-dynamic-file-reference-fails-closed | Bash | Personal | Project | Interactive | curl --data=@$REQUEST_FILE https://example.invalid/api | persistent[project]:curl | RequiresApproval | approval required | none | Yes | +| local-glob-allows-safe-verb | Bash | Personal | Project | Interactive | ls *.txt | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| local-glob-reuses-project-grant | Bash | Personal | Project | Interactive | rm *.tmp | persistent[project]:rm | Allowed | StoredApproval | none | Not applicable | +| external-glob-does-not-reuse-project-grant | Bash | Personal | Project | Interactive | rm /netclaw-approval-external/netclaw-ext-glob/*.bak | persistent[project]:rm | RequiresApproval | approval required | rm | No | +| glob-traversal-fails-closed | Bash | Personal | Project | Interactive | cat */../../secret.txt | persistent[anywhere]:cat | RequiresApproval | approval required | none | Yes | +| glob-intermediate-symlink-scope-fails-closed | Bash | Personal | Project | Interactive | cat artifacts/*/secret.txt | persistent[anywhere]:cat | RequiresApproval | approval required | none | Yes | +| directory-listing-glob-in-project-auto-allows | Bash | Personal | Project | Interactive | ls -d subdirs/*/ | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| directory-listing-glob-external-offers-persistent-grant | Bash | Personal | External | Interactive | ls -d subdirs/*/ | none | RequiresApproval | approval required | ls | No | +| directory-listing-glob-pipeline-offers-persistent-grant | Bash | Personal | External | Interactive | ls -d subdirs/*/ \| xargs -n1 basename | none | RequiresApproval | approval required | ls, xargs | No | +| native-global-option-identity-gap-currently-prompts | Bash | Personal | Project | Interactive | git --no-pager status | persistent[project]:git status | RequiresApproval | approval required | git | No | +| semicolon-sequence-prompts | Bash | Personal | Project | Interactive | git status; git push | none | RequiresApproval | approval required | git push | No | +| newline-sequence-prompts | Bash | Personal | Project | Interactive | git status\ngit push | none | RequiresApproval | approval required | git push | No | +| or-chain-prompts | Bash | Personal | Project | Interactive | git status \|\| git push | none | RequiresApproval | approval required | git push | No | +| three-step-release-prompts | Bash | Personal | Project | Interactive | git add . && git commit -m fix && git push origin dev | none | RequiresApproval | approval required | git add, git commit, git push origin dev | No | +| hard-deny-pipeline-tail-blocks | Bash | Personal | Project | Interactive | echo safe \| netclaw daemon stop | none | Denied | hard_deny_self_destructive | none | Not applicable | +| hard-deny-nested-shell-blocks | Bash | Personal | Project | Interactive | bash -lc "netclaw daemon stop" | none | Denied | hard_deny_self_destructive | none | Not applicable | +| hard-deny-sudo-nested-shell-blocks | Bash | Personal | Project | Interactive | sudo bash -lc "git status" | none | Denied | hard_deny_privilege_escalation | none | Not applicable | +| hard-deny-dash-shell-blocks | Bash | Personal | Project | Interactive | /bin/dash -c "netclaw daemon stop" | persistent[anywhere]:/bin/dash | Denied | hard_deny_self_destructive | none | Not applicable | +| nested-shell-prompts-for-inner-command | Bash | Personal | Project | Interactive | bash -lc "git push" | none | RequiresApproval | approval required | git push | No | +| nested-shell-inner-grant-allows | Bash | Personal | Project | Interactive | bash -lc "git push" | persistent[anywhere]:git push | Allowed | StoredApproval | none | Not applicable | +| nested-shell-wrapper-grant-does-not-cover-inner-command | Bash | Personal | Project | Interactive | bash -lc "git push" | persistent[anywhere]:bash | RequiresApproval | approval required | git push | No | +| bash-treats-pwsh-payload-as-ordinary-argument | Bash | Personal | Project | Interactive | pwsh -NoProfile -Command 'Get-Content ./a.txt' | none | RequiresApproval | approval required | pwsh | No | +| bash-pwsh-grant-covers-authored-external-command | Bash | Personal | Project | Interactive | pwsh -NoProfile -Command 'git push' | persistent[anywhere]:pwsh | Allowed | StoredApproval | none | Not applicable | +| bash-pwsh-payload-grant-does-not-cover-authored-command | Bash | Personal | Project | Interactive | pwsh -NoProfile -Command 'git push' | persistent[anywhere]:git push | RequiresApproval | approval required | pwsh | No | +| bash-treats-windows-powershell-as-ordinary-command | Bash | Personal | Project | Interactive | powershell.exe -NoProfile -Command 'Get-Content ./a.txt' | none | RequiresApproval | approval required | powershell.exe | No | +| powershell7-safe-command-allows | PowerShell7 | Personal | Project | Interactive | Get-ChildItem -Path . -Filter *.cs | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| powershell7-pipeline-prompts-for-unsafe-stage | PowerShell7 | Personal | Project | Interactive | Get-ChildItem \| Remove-Item | none | RequiresApproval | approval required | Remove-Item | No | +| powershell7-stored-grant-covers-unsafe-stage | PowerShell7 | Personal | Project | Interactive | Get-ChildItem \| Remove-Item | persistent[anywhere]:Remove-Item | Allowed | StoredApproval | none | Not applicable | +| powershell7-stop-process-hard-deny | PowerShell7 | Personal | Project | Interactive | Stop-Process -Id 42 | persistent[anywhere]:Stop-Process | Denied | hard_deny_self_destructive | none | Not applicable | +| powershell7-elevated-process-hard-deny | PowerShell7 | Personal | Project | Interactive | Start-Process pwsh -Verb RunAs | persistent[anywhere]:Start-Process | Denied | hard_deny_privilege_escalation | none | Not applicable | +| powershell7-elevated-process-abbreviated-quoted-hard-deny | PowerShell7 | Personal | Project | Interactive | Start-Process pwsh -Ve 'RunAs' | persistent[anywhere]:Start-Process | Denied | hard_deny_privilege_escalation | none | Not applicable | +| powershell7-recursive-root-removal-hard-deny | PowerShell7 | Personal | Project | Interactive | Remove-Item C:\ -Recurse -Confirm:$false | persistent[anywhere]:Remove-Item | Denied | hard_deny_system_destructive | none | Not applicable | +| powershell7-dynamic-command-fails-closed | PowerShell7 | Personal | Project | Interactive | & $command | persistent[anywhere]:Get-ChildItem | RequiresApproval | approval required | none | Yes | +| powershell7-treats-bash-payload-as-ordinary-argument | PowerShell7 | Personal | Project | Interactive | bash -lc 'Remove-Item victim.txt' | none | RequiresApproval | approval required | bash | No | +| powershell7-bash-grant-covers-authored-external-command | PowerShell7 | Personal | Project | Interactive | bash -lc 'Remove-Item victim.txt' | persistent[anywhere]:bash | Allowed | StoredApproval | none | Not applicable | +| powershell7-same-language-child-recurses-to-body | PowerShell7 | Personal | Project | Interactive | pwsh -NoProfile -Command 'Remove-Item victim.txt' | none | RequiresApproval | approval required | Remove-Item | No | +| 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-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-incomplete-pipeline-fails-closed | PowerShell7 | Personal | Project | Interactive | Get-ChildItem \| | persistent[anywhere]:Get-ChildItem | RequiresApproval | approval required | none | Yes | +| powershell51-safe-command-allows | WindowsPowerShell51 | Personal | Project | Interactive | Get-ChildItem | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| powershell51-pipeline-chain-fails-closed | WindowsPowerShell51 | Personal | Project | Interactive | Get-ChildItem && Get-Content .\a.txt | persistent[anywhere]:Get-ChildItem, persistent[anywhere]:Get-Content | RequiresApproval | approval required | none | Yes | +| powershell51-stop-process-hard-deny | WindowsPowerShell51 | Personal | Project | Interactive | Stop-Process -Name netclaw | persistent[anywhere]:Stop-Process | Denied | hard_deny_self_destructive | none | Not applicable | +| env-nested-shell-prompts | Bash | Personal | Project | Interactive | env bash -lc "git push" | none | RequiresApproval | approval required | env bash, git push | No | +| nohup-nested-shell-prompts | Bash | Personal | Project | Interactive | nohup bash -lc "git push" | none | RequiresApproval | approval required | nohup bash, git push | No | +| timeout-nested-shell-prompts | Bash | Personal | Project | Interactive | timeout 5 bash -lc "git push" | none | RequiresApproval | approval required | timeout, git push | No | +| subshell-prompts | Bash | Personal | Project | Interactive | (git status && git push) | none | RequiresApproval | approval required | git push | No | +| command-substitution-fails-closed | Bash | Personal | Project | Interactive | echo $(git push) | none | RequiresApproval | approval required | none | Yes | +| dynamic-path-fails-closed | Bash | Personal | Project | Interactive | cat "$FILE" | none | RequiresApproval | approval required | none | Yes | +| dynamic-redirect-fails-closed | Bash | Personal | Project | Interactive | git status > "$OUTPUT" | none | RequiresApproval | approval required | none | Yes | +| fd-dup-redirect-safe-verb-allows | Bash | Personal | Project | Interactive | git status 2>&1 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| fd-dup-redirect-safe-pipeline-allows | Bash | Personal | Project | Interactive | git log --oneline -5 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 | +| 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 | +| unbalanced-quote-fails-closed | Bash | Personal | Project | Interactive | git push "unterminated | none | RequiresApproval | approval required | none | Yes | +| multiline-argument-prompts | Bash | Personal | Project | Interactive | gh issue comment 123 --body "first line\nsecond line" | none | RequiresApproval | approval required | gh issue comment | No | +| approved-pipeline-head-does-not-cover-tail | Bash | Personal | Project | Interactive | git push \| curl https://example.com | persistent[anywhere]:git push | RequiresApproval | approval required | curl | No | +| all-pipeline-clauses-approved | Bash | Personal | Project | Interactive | git push \| curl https://example.com | persistent[anywhere]:git push, persistent[anywhere]:curl | Allowed | StoredApproval | none | Not applicable | +| input-redirect-outside-zone-prompts | Bash | Personal | Project | Interactive | cat < /netclaw-approval-external/netclaw-approval-input.txt | none | RequiresApproval | approval required | cat | No | +| error-redirect-outside-zone-prompts | Bash | Personal | Project | Interactive | git status 2> /netclaw-approval-external/netclaw-approval-errors.txt | none | RequiresApproval | approval required | git status | No | +| cd-current-then-safe-allows | Bash | Personal | Project | Interactive | cd . && git status | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| cd-parent-then-safe-prompts | Bash | Personal | Project | Interactive | cd .. && git status | none | RequiresApproval | approval required | cd, git status | No | +| multiple-cd-then-safe-prompts | Bash | Personal | Project | Interactive | cd . && cd .. && git status | none | RequiresApproval | approval required | cd, git status | No | +| side-effect-before-mutation-prompts | Bash | Personal | Project | Interactive | echo ready && git push | none | RequiresApproval | approval required | git push | No | +| literal-heredoc-cat-allows | Bash | Personal | Project | Interactive | cat <<'EOF'\nhello\nEOF | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| expanding-heredoc-cat-prompts | Bash | Personal | Project | Interactive | cat < 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-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-currently-complex | Bash | Personal | Project | Interactive | for f in src/*.cs; do grep -n "TODO" "$f"; done | persistent[project]:grep | RequiresApproval | approval required | none | Yes | +| workload-edit-loop-currently-complex | Bash | Personal | Project | Interactive | for f in src/a.txt src/b.txt; do sed -i 's/old/new/' "$f"; done | persistent[project]:sed | RequiresApproval | approval required | none | Yes | +| workload-search-dynamic-root-remains-complex | Bash | Personal | Project | Interactive | grep -R "error" "$SEARCH_ROOT" | persistent[anywhere]:grep | RequiresApproval | approval required | none | Yes | +| workload-search-substitution-pipeline-redirect-remains-complex | Bash | Personal | Project | Interactive | pattern=$(printf '%s' error); grep -R "$pattern" src \| head -20 > reports/errors.txt | persistent[project]:grep, persistent[project]:head, persistent[project]:printf | RequiresApproval | approval required | none | Yes | +| workload-search-loop-substitution-pipeline-redirect-remains-complex | Bash | Personal | Project | Interactive | for f in logs/*.log; do grep -n "$(printf '%s' error)" "$f" \| head -20 > "reports/$f.txt"; done | persistent[project]:grep, persistent[project]:head, persistent[project]:printf | RequiresApproval | approval required | none | Yes | +| echo-allows-without-grant | Bash | Personal | Project | Interactive | echo hello | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | +| printf-allows-without-grant | Bash | Personal | Project | Interactive | printf hello | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | +| echo-redirect-prompts | Bash | Personal | Project | Interactive | echo hello > result.txt | none | RequiresApproval | approval required | echo | No | +| echo-control-word-argument-allows | Bash | Personal | Project | Interactive | echo done | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | +| control-flow-fails-closed | Bash | Personal | Project | Interactive | for f in *.txt; do cat "$f"; done | persistent[anywhere]:cat | RequiresApproval | approval required | none | Yes | +| process-substitution-fails-closed | Bash | Personal | Project | Interactive | cat <(git push) | persistent[anywhere]:cat, persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | +| arithmetic-expansion-fails-closed | Bash | Personal | Project | Interactive | echo $((1 + 2)) | none | RequiresApproval | approval required | none | Yes | +| function-definition-fails-closed | Bash | Personal | Project | Interactive | deploy() { git push; }; deploy | persistent[anywhere]:git push | RequiresApproval | approval required | none | Yes | +| unknown-state-named-parameter-fails-closed | Bash | Personal | Project | Interactive | printf '%s' "$value" | persistent[anywhere]:printf | RequiresApproval | approval required | none | Yes | +| nameref-deferred-execution-fails-closed | Bash | Personal | Project | Interactive | declare -a values; declare -n current='values[$(printf marker >&2)0]'; cat < !OperatingSystem.IsWindows(); - [SlopwatchSuppress("SW001", "This theory defines Bash authorization behavior. The Windows shell parser does not implement this contract.")] - [Theory(SkipUnless = nameof(IsPosix), Skip = "The first matrix defines Bash authorization behavior.")] - [MemberData(nameof(ShellApprovalCases.Rows), MemberType = typeof(ShellApprovalCases))] - public async Task Shell_approval_contract(string caseId) + [SlopwatchSuppress("SW001", "These rows require a POSIX filesystem in addition to the explicitly selected Bash grammar.")] + [Theory(SkipUnless = nameof(IsPosix), Skip = "Bash matrix rows require POSIX filesystem semantics.")] + [MemberData(nameof(ShellApprovalCases.BashRows), MemberType = typeof(ShellApprovalCases))] + public Task Bash_approval_contract(string caseId) + => AssertApprovalContract(caseId); + + [Theory] + [MemberData(nameof(ShellApprovalCases.PowerShellRows), MemberType = typeof(ShellApprovalCases))] + public Task Power_shell_approval_contract(string caseId) + => AssertApprovalContract(caseId); + + private async Task AssertApprovalContract(string caseId) { var testCase = ShellApprovalCases.Get(caseId); await using var harness = await ShellApprovalHarness.CreateAsync( diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs index fbe54a23c..87ed9ba6d 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs @@ -76,6 +76,18 @@ public static async Task CreateAsync( Directory.CreateDirectory(sessionDirectory); Directory.CreateDirectory(externalDirectory); + var environment = testCase.Invocation.CreateEnvironment(); + var approvalProjectDirectory = projectDirectory; + var approvalSessionDirectory = sessionDirectory; + var approvalExternalDirectory = externalDirectory; + if (environment.PathStyle == ShellPathStyle.Windows) + { + var windowsRoot = $"C:/netclaw-approval-matrix/{Guid.NewGuid():N}"; + approvalProjectDirectory = $"{windowsRoot}/project"; + approvalSessionDirectory = $"{windowsRoot}/session"; + approvalExternalDirectory = $"{windowsRoot}/external"; + } + var store = new ToolApprovalStore(Path.Combine(rootDirectory, "tool-approvals.json")); var approvalActor = CreateApprovalActor(actorSystem, store); var approvalService = CreateApprovalService(approvalActor); @@ -91,7 +103,11 @@ await approvalService.RecordApprovalAsync( new ToolName(ShellTool.ToolName), [seed.Pattern], persistent: true, - ResolveDirectory(seed.Directory, projectDirectory, sessionDirectory, externalDirectory), + ResolveDirectory( + seed.Directory, + approvalProjectDirectory, + approvalSessionDirectory, + approvalExternalDirectory), ct); } @@ -116,12 +132,17 @@ await approvalService.RecordApprovalAsync( var countingApprovalService = new CountingApprovalService(approvalService); var config = CreateConfig(); + var commandPolicy = new ShellCommandPolicy(environment); + var deniedPaths = environment.Platform == ShellPlatform.Windows + ? new[] { @"C:\protected\config" } + : []; + var pathPolicy = new ToolPathPolicy(environment, deniedPaths); var registry = new ToolRegistry(); registry.WithFirstPartyTools( config, new NetclawPaths(), - new ToolPathPolicy([]), - new ShellCommandPolicy()); + pathPolicy, + commandPolicy); var policy = new ToolAccessPolicy( config, @@ -130,19 +151,19 @@ await approvalService.RecordApprovalAsync( TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - shellCommandPolicy: new ShellCommandPolicy(), - toolPathPolicy: new ToolPathPolicy([]), + shellCommandPolicy: commandPolicy, + toolPathPolicy: pathPolicy, shellTrustZonePolicy: new ShellTrustZonePolicy( config, new NetclawPaths(rootDirectory, Path.Combine(rootDirectory, "workspaces"))), - safeVerbs: SafeVerbLoader.Load()); + safeVerbs: SafeVerbLoader.Load(environment.Platform == ShellPlatform.Windows)); var executor = new DispatchingToolExecutor(registry, policy, countingApprovalService); var workingDirectory = ResolveDirectory( testCase.Invocation.WorkingDirectory, - projectDirectory, - sessionDirectory, - externalDirectory); + approvalProjectDirectory, + approvalSessionDirectory, + approvalExternalDirectory); var arguments = workingDirectory is null ? ToolInput.Create("Command", testCase.Invocation.Command) : ToolInput.Create( @@ -151,11 +172,11 @@ await approvalService.RecordApprovalAsync( var toolCall = new FunctionCallContent(testCase.Id, ShellTool.ToolName, arguments); var context = TestToolExecutionContext.CreateBound( InvocationSessionId, - sessionDirectory, + approvalSessionDirectory, new TestToolExecutionContextOptions { Audience = testCase.Invocation.Audience, - ProjectDirectory = projectDirectory, + ProjectDirectory = approvalProjectDirectory, InteractiveApproval = TestToolExecutionContext.InteractiveApproval(testCase.Invocation.Interactive) }); diff --git a/src/Netclaw.Actors.Tests/Tools/ShellToolStreamingTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellToolStreamingTests.cs index 64294e1e5..bd77f3837 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellToolStreamingTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellToolStreamingTests.cs @@ -14,7 +14,41 @@ namespace Netclaw.Actors.Tests.Tools; public class ShellToolStreamingTests { - private readonly ShellTool _tool = new(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy()); + private static readonly ShellExecutionEnvironment ShellEnvironment = TestShellEnvironment.Current; + private readonly ShellTool _tool = CreateTool(); + + private static ShellTool CreateTool(ToolConfig? config = null) + { + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + return new ShellTool( + config ?? new ToolConfig(), + new ToolPathPolicy(ShellEnvironment, []), + commandPolicy); + } + + [Fact] + public async Task Missing_selected_executable_streams_failure_without_fallback() + { + const string missingExecutable = @"C:\missing\pwsh.exe"; + var environment = ShellExecutionEnvironment.CreatePowerShell( + missingExecutable, + ShellSyntaxTree.PwshDialect.PowerShell7); + var tool = new ShellTool( + new ToolConfig(), + new ToolPathPolicy(environment, []), + new ShellCommandPolicy(environment)); + + var (activities, completion) = await CollectStreamAsync( + tool, + ToolInput.Create("Command", "Get-ChildItem"), + ct: TestContext.Current.CancellationToken); + + Assert.Empty(activities); + Assert.NotNull(completion); + Assert.Contains(missingExecutable, completion.Result); + Assert.DoesNotContain("powershell.exe", completion.Result, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("cmd.exe", completion.Result, StringComparison.OrdinalIgnoreCase); + } private static async Task<(List Activities, ToolCompletedUpdate? Completion)> CollectStreamAsync(ShellTool tool, IDictionary args, @@ -59,8 +93,7 @@ public async Task Echo_emits_activity_with_output_chunk_then_completion() [Fact] public async Task Stderr_emits_activity_items_with_stderr_phase() { - var cmd = OperatingSystem.IsWindows() ? "echo error 1>&2" : "echo error >&2"; - var args = ToolInput.Create("Command", cmd); + var args = ToolInput.Create("Command", TestShellEnvironment.StandardErrorCommand); var (activities, completion) = await CollectStreamAsync(_tool, args, ct: TestContext.Current.CancellationToken); Assert.NotNull(completion); @@ -75,10 +108,9 @@ public async Task Stderr_emits_activity_items_with_stderr_phase() public async Task Chatty_command_emits_multiple_activities() { // Produce enough output that pipe reads span multiple coalesce - // windows without relying on sleep-based timing or bash syntax - // (ShellTool uses cmd.exe on Windows). - var cmd = OperatingSystem.IsWindows() - ? "for /L %i in (1,1,200) do @echo line %i" + // windows without relying on sleep-based timing. + var cmd = ShellEnvironment.Grammar == ShellGrammar.PowerShell + ? "1..200 | ForEach-Object { \"line $_\" }" : "for i in $(seq 1 200); do echo \"line $i\"; done"; var args = ToolInput.Create("Command", cmd); var (activities, completion) = await CollectStreamAsync(_tool, args, ct: TestContext.Current.CancellationToken); @@ -94,7 +126,7 @@ public async Task Chatty_command_emits_multiple_activities() public async Task Cancellation_kills_process_and_returns_timeout() { using var cts = new CancellationTokenSource(); - var cmd = OperatingSystem.IsWindows() ? "ping -n 100 127.0.0.1" : "sleep 100"; + var cmd = TestShellEnvironment.LongRunningCommand; var args = ToolInput.Create("Command", cmd); // Cancel after a short delay @@ -109,10 +141,10 @@ public async Task Cancellation_kills_process_and_returns_timeout() [Fact] public async Task Output_clamping_preserved_in_completion_result() { - var tool = new ShellTool(new ToolConfig { MaxOutputChars = 100 }, new ToolPathPolicy([]), new ShellCommandPolicy()); + var tool = CreateTool(new ToolConfig { MaxOutputChars = 100 }); // Generate output much larger than the 100-char budget - var cmd = OperatingSystem.IsWindows() - ? "for /L %i in (1,1,10000) do @echo %i" + var cmd = ShellEnvironment.Grammar == ShellGrammar.PowerShell + ? "1..10000" : "seq 1 10000"; var args = ToolInput.Create("Command", cmd); var (_, completion) = await CollectStreamAsync(tool, args, ct: TestContext.Current.CancellationToken); @@ -136,9 +168,12 @@ public async Task Empty_command_yields_immediate_error_completion() [Fact] public async Task Hard_deny_yields_immediate_error_completion() { - var policy = new ShellCommandPolicy(["kill"], []); - var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), policy); - var args = ToolInput.Create("Command", "kill -9 1"); + var policy = new ShellCommandPolicy(ShellEnvironment, ["kill"], []); + var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy(ShellEnvironment, []), policy); + var command = ShellEnvironment.Grammar == ShellGrammar.PowerShell + ? "Stop-Process -Id 1" + : "kill -9 1"; + var args = ToolInput.Create("Command", command); var (activities, completion) = await CollectStreamAsync(tool, args, ct: TestContext.Current.CancellationToken); Assert.Empty(activities); @@ -149,7 +184,7 @@ public async Task Hard_deny_yields_immediate_error_completion() [Fact] public async Task Streaming_result_matches_non_streaming_format() { - var args = ToolInput.Create("Command", "echo hello && echo world"); + var args = ToolInput.Create("Command", TestShellEnvironment.TwoOutputLinesCommand); var nonStreaming = await _tool.ExecuteAsync(args, TestToolExecutionContext.CreateUnbound(), TestContext.Current.CancellationToken); var (_, completion) = await CollectStreamAsync(_tool, args, ct: TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs index 941d24990..2007e402c 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs @@ -14,7 +14,55 @@ namespace Netclaw.Actors.Tests.Tools; public class ShellToolTests { - private readonly ShellTool _tool = new(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy()); + private static readonly ShellExecutionEnvironment ShellEnvironment = TestShellEnvironment.Current; + private readonly ShellTool _tool = CreateTool(); + + public static bool IsWindows => OperatingSystem.IsWindows(); + + private static ShellTool CreateTool(ToolConfig? config = null) + { + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + return new ShellTool( + config ?? new ToolConfig(), + new ToolPathPolicy(ShellEnvironment, []), + commandPolicy); + } + + [Fact] + public void Constructor_rejects_policies_from_different_shell_environments() + { + var first = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + var second = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + + var exception = Assert.Throws(() => new ShellTool( + new ToolConfig(), + new ToolPathPolicy(first, []), + new ShellCommandPolicy(second))); + + Assert.Contains("same shell environment", exception.Message); + } + + [Fact] + public async Task Missing_selected_executable_fails_without_fallback() + { + const string missingExecutable = @"C:\missing\pwsh.exe"; + var environment = ShellExecutionEnvironment.CreatePowerShell( + missingExecutable, + ShellSyntaxTree.PwshDialect.PowerShell7); + var tool = new ShellTool( + new ToolConfig(), + new ToolPathPolicy(environment, []), + new ShellCommandPolicy(environment)); + + var result = await tool.ExecuteAsync( + ToolInput.Create("Command", "Get-ChildItem"), + TestToolExecutionContext.CreateUnbound(), + TestContext.Current.CancellationToken); + + Assert.Contains(missingExecutable, result); + Assert.DoesNotContain("powershell.exe", result, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("cmd.exe", result, StringComparison.OrdinalIgnoreCase); + } [Fact] public async Task Execute_echo_returns_output() @@ -26,10 +74,29 @@ public async Task Execute_echo_returns_output() Assert.Contains("Exit code: 0", result); } + [SlopwatchSuppress("SW001", "This native fallback test requires Windows PowerShell 5.1.")] + [Fact(SkipUnless = nameof(IsWindows), Skip = "Native Windows PowerShell 5.1 execution requires Windows.")] + public async Task Windows_power_shell_51_executes_through_the_selected_host() + { + var environment = TestShellEnvironment.CreateWindowsPowerShell51(); + var tool = new ShellTool( + new ToolConfig(), + new ToolPathPolicy(environment, []), + new ShellCommandPolicy(environment)); + + var result = await tool.ExecuteAsync( + ToolInput.Create("Command", "Write-Output windows-powershell-51"), + TestToolExecutionContext.CreateUnbound(), + TestContext.Current.CancellationToken); + + Assert.Contains("windows-powershell-51", result); + Assert.Contains("Exit code: 0", result); + } + [Fact] public async Task Execute_captures_stderr() { - var args = ToolInput.Create("Command", "echo error >&2"); + var args = ToolInput.Create("Command", TestShellEnvironment.StandardErrorCommand); var result = await _tool.ExecuteAsync(args, TestToolExecutionContext.CreateUnbound(), CancellationToken.None); Assert.Contains("error", result); @@ -48,8 +115,8 @@ public async Task Execute_returns_nonzero_exit_code() [Fact] public async Task Timeout_kills_long_running_process() { - var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy()); - var args = ToolInput.Create("Command", "sleep 100"); + var tool = CreateTool(); + var args = ToolInput.Create("Command", TestShellEnvironment.LongRunningCommand); var context = TestToolExecutionContext.CreateBound("test/thread", Path.GetTempPath(), new TestToolExecutionContextOptions { Audience = TrustAudience.Personal, @@ -71,9 +138,9 @@ public async Task Caller_cancellation_kills_child_process_tree_and_returns_grace // exception. On Unix the command also spawns a background child that // inherits stdout/stderr; if the tree kill regresses, that child keeps // the pipe write-ends open and the test never completes. - var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy()); - var command = OperatingSystem.IsWindows() - ? "ping 127.0.0.1 -n 120 > nul" + var tool = CreateTool(); + var command = ShellEnvironment.Grammar == ShellGrammar.PowerShell + ? "ping.exe 127.0.0.1 -n 120 | Out-Null" : "sleep 120 & wait"; var args = ToolInput.Create("Command", command); var context = TestToolExecutionContext.CreateBound("test/thread", Path.GetTempPath(), new TestToolExecutionContextOptions @@ -93,9 +160,9 @@ public async Task ShellTool_returns_raw_combined_output_without_spilling() { // ShellTool only returns its (bounded) raw output now — redaction and the // inline-budget bound + spill happen centrally in DispatchingToolExecutor - // (covered by DispatchingToolExecutorTests). `echo` is a builtin on both - // bash and cmd.exe; a long literal is deterministic on stdout. - var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy()); + // (covered by DispatchingToolExecutorTests). `echo` is available in both + // canonical host grammars; a long literal is deterministic on stdout. + var tool = CreateTool(); var args = ToolInput.Create("Command", $"echo {new string('x', 200)}"); var result = await tool.ExecuteAsync(args, TestToolExecutionContext.CreateUnbound(), CancellationToken.None); @@ -109,8 +176,7 @@ public async Task ShellTool_returns_raw_combined_output_without_spilling() public async Task Working_directory_is_respected() { var tmpDir = Path.GetTempPath(); - // Use platform-appropriate command to print working directory - var command = OperatingSystem.IsWindows() ? "cd" : "pwd"; + var command = TestShellEnvironment.PrintWorkingDirectoryCommand; var args = ToolInput.Create("Command", command, "WorkingDirectory", tmpDir); var result = await _tool.ExecuteAsync(args, TestToolExecutionContext.CreateUnbound(), CancellationToken.None); @@ -144,7 +210,7 @@ public async Task Cwd_falls_back_to_project_directory_when_no_explicit_arg() { var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, new TestToolExecutionContextOptions { Audience = TrustAudience.Personal, ProjectDirectory = projectDir }); - var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); + var args = ToolInput.Create("Command", TestShellEnvironment.PrintWorkingDirectoryCommand); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -168,7 +234,7 @@ public async Task Cwd_falls_back_to_session_directory_when_project_directory_nul { var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, TrustAudience.Personal); // ProjectDirectory not set - var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); + var args = ToolInput.Create("Command", TestShellEnvironment.PrintWorkingDirectoryCommand); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); diff --git a/src/Netclaw.Actors.Tests/Tools/ToolAccessPolicyRequiredDependenciesTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolAccessPolicyRequiredDependenciesTests.cs index bb0ab24ce..df2618884 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolAccessPolicyRequiredDependenciesTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolAccessPolicyRequiredDependenciesTests.cs @@ -8,6 +8,7 @@ using Netclaw.Security; using Netclaw.Tests.Utilities; using Netclaw.Tools; +using ShellSyntaxTree; using Xunit; namespace Netclaw.Actors.Tests.Tools; @@ -85,32 +86,52 @@ public void Protected_path_control_is_enforced_and_scoped() Assert.NotEqual("shell_references_protected_path", otherDecision.DenyReason); } - [SlopwatchSuppress("SW001", "This regression verifies Bash decoding before PowerShell child path policy.")] - [Fact(SkipUnless = nameof(IsPosix), Skip = "The PowerShell child wrapper requires the POSIX Bash host.")] - public void Protected_path_control_checks_decoded_power_shell_child_path() + [Fact] + public void Protected_path_control_checks_native_power_shell_path() { - var deniedRoot = Path.Combine( - Path.GetTempPath(), - "netclaw-protected-root", - "config"); + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7); + const string deniedRoot = @"C:\protected\config"; + var commandPolicy = new ShellCommandPolicy(environment); + var pathPolicy = new ToolPathPolicy(environment, [deniedRoot]); var policy = new ToolAccessPolicy( ShellConfig(), Defaults(), - new ShellCommandPolicy(), - new ToolPathPolicy([deniedRoot])); - var decodedPath = Path.Combine(deniedRoot, "secret.txt"); - var authoredPath = decodedPath.Replace("config", "con\"fig", StringComparison.Ordinal); - var command = - $"pwsh -NoProfile -NonInteractive -Command 'Get-Content {authoredPath}\"'"; - - Assert.DoesNotContain(deniedRoot, command, StringComparison.Ordinal); + commandPolicy, + pathPolicy); + var shellTool = new ShellTool(ShellConfig(), pathPolicy, commandPolicy); var decision = policy.AuthorizeInvocation( - ShellTool(), + shellTool, PersonalContext(), - ToolInput.Create("Command", command)); + ToolInput.Create("Command", @"Get-Content C:\protected\config\secret.txt")); Assert.False(decision.Allowed); Assert.Equal("shell_references_protected_path", decision.DenyReason); } + + [Fact] + public void Shell_authorization_captures_one_analysis_for_execution() + { + var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + var commandPolicy = new ShellCommandPolicy(environment); + var pathPolicy = new ToolPathPolicy(environment, []); + var policy = new ToolAccessPolicy( + ShellConfig(), + Defaults(), + commandPolicy, + pathPolicy); + var shellTool = new ShellTool(ShellConfig(), pathPolicy, commandPolicy); + var context = PersonalContext(); + var arguments = ToolInput.Create("Command", "git status"); + + _ = policy.AuthorizeInvocation(shellTool, context, arguments); + + Assert.True(policy.TryTakeAuthorizedShellAnalysis(context, out var analysis)); + Assert.NotNull(analysis); + Assert.Equal("git status", analysis.Source); + Assert.Equal(context.ResolveShellCwd(null), analysis.WorkingDirectory); + Assert.False(policy.TryTakeAuthorizedShellAnalysis(context, out _)); + } } diff --git a/src/Netclaw.Actors/Hosting/NetclawAkkaHostingExtensions.cs b/src/Netclaw.Actors/Hosting/NetclawAkkaHostingExtensions.cs index ca4e51ce5..b56882213 100644 --- a/src/Netclaw.Actors/Hosting/NetclawAkkaHostingExtensions.cs +++ b/src/Netclaw.Actors/Hosting/NetclawAkkaHostingExtensions.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -17,6 +17,7 @@ using Netclaw.Actors.Serialization; using Netclaw.Actors.Sessions; using Netclaw.Actors.Tools; +using Netclaw.Security; namespace Netclaw.Actors.Hosting; @@ -134,11 +135,18 @@ public static AkkaConfigurationBuilder WithToolApprovalActor( public static AkkaConfigurationBuilder WithBackgroundJobManager( this AkkaConfigurationBuilder builder) + => builder.WithBackgroundJobManager( + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux)); + + public static AkkaConfigurationBuilder WithBackgroundJobManager( + this AkkaConfigurationBuilder builder, + ShellExecutionEnvironment environment) { + ArgumentNullException.ThrowIfNull(environment); return builder.StartActors((system, registry, resolver) => { var actor = system.ActorOf( - resolver.Props(), + resolver.Props(environment), "background-job-manager"); registry.Register(actor); }); @@ -180,13 +188,22 @@ public static AkkaConfigurationBuilder WithSessionLogDispatcher( public static AkkaConfigurationBuilder WithNetclawActors( this AkkaConfigurationBuilder builder, ReminderStorageOptions? reminderStorageOptions = null) + => builder.WithNetclawActors( + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux), + reminderStorageOptions); + + public static AkkaConfigurationBuilder WithNetclawActors( + this AkkaConfigurationBuilder builder, + ShellExecutionEnvironment environment, + ReminderStorageOptions? reminderStorageOptions = null) { + ArgumentNullException.ThrowIfNull(environment); return builder .WithModelCapabilityCache() .WithSessionManager() .WithToolApprovalActor() .WithReminderManager(reminderStorageOptions) - .WithBackgroundJobManager(); + .WithBackgroundJobManager(environment); } /// diff --git a/src/Netclaw.Actors/Jobs/BackgroundJobExecutionActor.cs b/src/Netclaw.Actors/Jobs/BackgroundJobExecutionActor.cs index e23992be7..850346367 100644 --- a/src/Netclaw.Actors/Jobs/BackgroundJobExecutionActor.cs +++ b/src/Netclaw.Actors/Jobs/BackgroundJobExecutionActor.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -20,6 +20,7 @@ public sealed class BackgroundJobExecutionActor : ReceiveActor private readonly BackgroundJobDefinition _definition; private readonly string _outputLogPath; private readonly TimeProvider _timeProvider; + private readonly ShellExecutionEnvironment _environment; private readonly ILoggingAdapter _log; private Process? _process; private ICancelable? _timeoutHandle; @@ -28,10 +29,24 @@ public BackgroundJobExecutionActor( BackgroundJobDefinition definition, string outputLogPath, TimeProvider timeProvider) + : this( + definition, + outputLogPath, + timeProvider, + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux)) + { + } + + public BackgroundJobExecutionActor( + BackgroundJobDefinition definition, + string outputLogPath, + TimeProvider timeProvider, + ShellExecutionEnvironment environment) { _definition = definition; _outputLogPath = outputLogPath; _timeProvider = timeProvider; + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); _log = Context.GetLogger(); Receive(_ => HandleCancel()); @@ -77,27 +92,7 @@ protected override void PostStop() private void SpawnProcess() { - var isWindows = OperatingSystem.IsWindows(); - var psi = new ProcessStartInfo - { - FileName = isWindows ? "cmd.exe" : "/bin/bash", - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - if (isWindows) - { - psi.ArgumentList.Add("/c"); - psi.ArgumentList.Add(_definition.Command); - } - else - { - psi.ArgumentList.Add("-c"); - psi.ArgumentList.Add(_definition.Command); - } + var psi = _environment.CreateProcessStartInfo(_definition.Command); if (!string.IsNullOrWhiteSpace(_definition.WorkingDirectory)) { @@ -114,9 +109,7 @@ private void SpawnProcess() return; } - var mkdirHint = isWindows - ? $"mkdir \"{_definition.WorkingDirectory}\"" - : $"mkdir -p \"{_definition.WorkingDirectory}\""; + var mkdirHint = CreateDirectoryHint(_definition.WorkingDirectory); ReportCompletion(BackgroundJobStatus.Failed, -1, $"Working directory '{_definition.WorkingDirectory}' does not exist. " + $"Create it first, e.g.: {mkdirHint}"); @@ -126,7 +119,20 @@ private void SpawnProcess() psi.WorkingDirectory = _definition.WorkingDirectory; } - _process = Process.Start(psi); + try + { + _process = Process.Start(psi); + } + catch (Exception ex) + { + ReportCompletion( + BackgroundJobStatus.Failed, + -1, + $"Failed to start shell '{_environment.ExecutableName}' " + + $"at '{_environment.ExecutablePath}': {ex.Message}"); + return; + } + if (_process is null) { ReportCompletion(BackgroundJobStatus.Failed, -1, "Process.Start returned null"); @@ -197,6 +203,11 @@ private static async Task PumpToLogAsync(StreamReader reader, JobOutputLog outpu } } + private string CreateDirectoryHint(string path) + => _environment.PathStyle == ShellPathStyle.Windows + ? $"New-Item -ItemType Directory -Force -Path '{path.Replace("'", "''", StringComparison.Ordinal)}'" + : $"mkdir -p -- '{path.Replace("'", "'\\''", StringComparison.Ordinal)}'"; + private void HandleProcessExited(ProcessExited msg) { _timeoutHandle?.Cancel(); diff --git a/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs b/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs index 6a199fd9b..62b9f3e14 100644 --- a/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs +++ b/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs @@ -55,6 +55,7 @@ public sealed class BackgroundJobManagerActor : ReceiveActor, IWithTimers private readonly BackgroundJobDefinitionStore _store; private readonly TimeProvider _timeProvider; + private readonly ShellExecutionEnvironment _environment; private readonly IOperationalNotificationSink _notificationSink; private readonly ILoggingAdapter _log; @@ -77,9 +78,23 @@ public BackgroundJobManagerActor( BackgroundJobDefinitionStore store, TimeProvider timeProvider, IOperationalNotificationSink? notificationSink = null) + : this( + store, + timeProvider, + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux), + notificationSink) + { + } + + public BackgroundJobManagerActor( + BackgroundJobDefinitionStore store, + TimeProvider timeProvider, + ShellExecutionEnvironment environment, + IOperationalNotificationSink? notificationSink = null) { _store = store; _timeProvider = timeProvider; + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); _notificationSink = notificationSink ?? NullNotificationSink.Instance; _log = Context.GetLogger(); @@ -502,7 +517,11 @@ private void SpawnExecution(BackgroundJobDefinition definition) var outputLogPath = _store.GetOutputLogPath(running.Id); var props = DependencyResolver.For(Context.System) - .Props(running, outputLogPath, _timeProvider); + .Props( + running, + outputLogPath, + _timeProvider, + _environment); Context.ActorOf(props, $"job-{running.Id}"); } diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index 934a011ce..2ced61bee 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -501,7 +501,7 @@ private async Task ExecuteSingleToolAsync( await _executor.AuthorizeAsync(tc, context, batch.CancellationToken); sw.Stop(); return await RouteToBackgroundJobAsync( - tc, batch, + tc, batch, context, meta, backgroundJobs.Manager, // Honor the agent's requested timeout; when absent, no // kill timer is armed — a background job is a detached @@ -591,7 +591,7 @@ private async Task ExecuteSingleToolAsync( await _executor.AuthorizeAsync(tc, context, batch.CancellationToken); sw.Stop(); return await RouteToBackgroundJobAsync( - tc, batch, + tc, batch, context, meta, backgroundJobs.Manager, // Honor the agent's requested timeout; when absent, no // kill timer is armed — a background job is a detached @@ -821,12 +821,14 @@ internal static SubAgentFindingReviewResult ReviewSubAgentFinding( private async Task RouteToBackgroundJobAsync( FunctionCallContent tc, SessionToolBatch batch, + ToolExecutionContext context, ToolCallMeta meta, IActorRef backgroundJobManager, int timeoutSeconds) { var command = ToolArgumentHelper.GetString(tc.Arguments, "Command"); - var workingDirectory = ToolArgumentHelper.GetString(tc.Arguments, "WorkingDirectory"); + var workingDirectory = context.ResolveShellCwd( + ToolArgumentHelper.GetString(tc.Arguments, "WorkingDirectory")); if (string.IsNullOrWhiteSpace(command)) { diff --git a/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs b/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs index 98ce5f15a..368ed631f 100644 --- a/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs +++ b/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs @@ -8,6 +8,7 @@ using System.Text; using Microsoft.Extensions.Logging; using Netclaw.Configuration; +using Netclaw.Security; namespace Netclaw.Actors.Sessions; @@ -48,8 +49,11 @@ public sealed record WorkingContextSnapshot { public required WorkingContext WorkingContext { get; init; } public required GitWorkingContextInspection Git { get; init; } + public ShellExecutionEnvironment? ShellEnvironment { get; init; } - public bool IsEmpty => WorkingContext.IsEmpty && Git is GitWorkingContextInspection.Skipped or GitWorkingContextInspection.NotRepository; + public bool IsEmpty => ShellEnvironment is null + && WorkingContext.IsEmpty + && Git is GitWorkingContextInspection.Skipped or GitWorkingContextInspection.NotRepository; public string ToContextBlock() { @@ -57,6 +61,16 @@ public string ToContextBlock() return string.Empty; var sb = new StringBuilder("[working-context]"); + if (ShellEnvironment is { } shell) + { + sb.Append("\nshell:") + .Append("\n platform: ").Append(shell.Platform) + .Append("\n executable: ").Append(shell.ExecutablePath) + .Append("\n grammar: ").Append(shell.Grammar); + if (shell.PowerShellDialect is { } dialect) + sb.Append("\n dialect: ").Append(dialect); + } + if (WorkingContext.ProjectDirectory is not null) sb.Append("\nproject_dir: ").Append(WorkingContext.ProjectDirectory); @@ -153,11 +167,32 @@ Task CreateAsync( CancellationToken cancellationToken); } -public sealed class WorkingContextSnapshotProvider( - IGitWorkingContextInspector gitInspector, - ILogger logger) - : IWorkingContextSnapshotProvider +public sealed class WorkingContextSnapshotProvider : IWorkingContextSnapshotProvider { + private readonly IGitWorkingContextInspector _gitInspector; + private readonly ILogger _logger; + private readonly ShellExecutionEnvironment _environment; + + public WorkingContextSnapshotProvider( + IGitWorkingContextInspector gitInspector, + ILogger logger) + : this( + gitInspector, + logger, + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux)) + { + } + + public WorkingContextSnapshotProvider( + IGitWorkingContextInspector gitInspector, + ILogger logger, + ShellExecutionEnvironment environment) + { + _gitInspector = gitInspector; + _logger = logger; + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + } + public async Task CreateAsync( WorkingContext context, TrustAudience audience, @@ -170,27 +205,29 @@ public async Task CreateAsync( return new WorkingContextSnapshot { WorkingContext = audience == TrustAudience.Public ? WorkingContext.Empty : context, - Git = new GitWorkingContextInspection.Skipped() + Git = new GitWorkingContextInspection.Skipped(), + ShellEnvironment = audience == TrustAudience.Personal ? _environment : null }; } var inspection = Directory.Exists(context.ProjectDirectory) - ? await gitInspector.InspectAsync(context.ProjectDirectory, cancellationToken).ConfigureAwait(false) + ? await _gitInspector.InspectAsync(context.ProjectDirectory, cancellationToken).ConfigureAwait(false) : new GitWorkingContextInspection.Unavailable("project directory does not exist"); switch (inspection) { case GitWorkingContextInspection.ExecutableNotFound: - logger.LogWarning("Git working-context inspection failed: git executable not found"); + _logger.LogWarning("Git working-context inspection failed: git executable not found"); break; case GitWorkingContextInspection.Unavailable unavailable: - logger.LogWarning("Git working-context inspection failed: {Reason}", unavailable.Reason); + _logger.LogWarning("Git working-context inspection failed: {Reason}", unavailable.Reason); break; } return new WorkingContextSnapshot { WorkingContext = context, + ShellEnvironment = audience == TrustAudience.Personal ? _environment : null, Git = inspection switch { GitWorkingContextInspection.Unavailable failure => diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index e35bdd4e9..3627198e3 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -157,7 +157,15 @@ public async Task ExecuteAsync(FunctionCallContent toolCall, ToolExecuti var sw = Stopwatch.StartNew(); try { - var result = await tool.ExecuteAsync(toolCall.Arguments, context.Invocation, ct); + var result = tool is ShellTool shellTool + && _policy.TryTakeAuthorizedShellAnalysis(context, out var shellAnalysis) + && shellAnalysis is not null + ? await shellTool.ExecuteAuthorizedAsync( + toolCall.Arguments, + context.Invocation, + shellAnalysis, + ct) + : await tool.ExecuteAsync(toolCall.Arguments, context.Invocation, ct); var redacted = SecretOutputRedactor.Redact(result); @@ -232,8 +240,17 @@ public async IAsyncEnumerable ExecuteStreamAsync( // before the first item is produced; the tool-execution pipeline handles // those exactly as it does for the non-streaming path. var tool = await GetAuthorizedToolAsync(toolCall, context, ct); + var updates = tool is ShellTool shellTool + && _policy.TryTakeAuthorizedShellAnalysis(context, out var shellAnalysis) + && shellAnalysis is not null + ? shellTool.ExecuteAuthorizedStreamAsync( + toolCall.Arguments, + context.Invocation, + shellAnalysis, + ct) + : tool.ExecuteStreamAsync(toolCall.Arguments, context.Invocation, ct); var sw = Stopwatch.StartNew(); - await foreach (var update in tool.ExecuteStreamAsync(toolCall.Arguments, context.Invocation, ct)) + await foreach (var update in updates) { switch (update) { diff --git a/src/Netclaw.Actors/Tools/ShellTool.cs b/src/Netclaw.Actors/Tools/ShellTool.cs index 4e1a8f023..696b31142 100644 --- a/src/Netclaw.Actors/Tools/ShellTool.cs +++ b/src/Netclaw.Actors/Tools/ShellTool.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -16,7 +16,7 @@ namespace Netclaw.Actors.Tools; /// -/// Executes shell commands via /bin/bash (Linux) or cmd.exe (Windows). +/// Executes commands through the daemon's resolved native shell environment. /// Captures stdout+stderr, enforces timeout, closes stdin immediately. /// [NetclawTool(ToolName, @@ -38,6 +38,7 @@ public sealed partial class ShellTool : NetclawTool private readonly ToolConfig _config; private readonly ToolPathPolicy _pathPolicy; private readonly ShellCommandPolicy _commandPolicy; + private readonly ShellExecutionEnvironment _environment; public record Params( [property: Description("The shell command to execute")] string Command, @@ -48,41 +49,55 @@ public ShellTool(ToolConfig config, ToolPathPolicy pathPolicy, ShellCommandPolic _config = config; _pathPolicy = pathPolicy; _commandPolicy = commandPolicy; + if (!ReferenceEquals(pathPolicy.Environment, commandPolicy.Environment)) + { + throw new ArgumentException( + "Shell command and path policies must use the same shell environment.", + nameof(commandPolicy)); + } + + _environment = commandPolicy.Environment; } - protected override async Task ExecuteAsync(Params args, ToolInvocationContext context, CancellationToken ct) + protected override Task ExecuteAsync( + Params args, + ToolInvocationContext context, + CancellationToken ct) + => ExecuteCoreAsync(args, context, authorizedAnalysis: null, ct); + + internal async Task ExecuteAuthorizedAsync( + IDictionary? arguments, + ToolInvocationContext context, + ShellCommandAnalysis analysis, + CancellationToken ct) + { + if (!TryParse(arguments, out var error, out var args)) + return error; + + return await ExecuteCoreAsync(args, context, analysis, ct); + } + + private async Task ExecuteCoreAsync( + Params args, + ToolInvocationContext context, + ShellCommandAnalysis? authorizedAnalysis, + CancellationToken ct) { if (string.IsNullOrWhiteSpace(args.Command)) return "Error: 'command' parameter is required."; - var commandDecision = _commandPolicy.Evaluate(args.Command); + // Resolve once before parsing or execution. The same cwd and parse + // facts feed both security policies and the launched process. + var resolvedCwd = context.ResolveShellCwd(args.WorkingDirectory); + var analysis = ResolveAnalysis(args.Command, resolvedCwd, authorizedAnalysis); + var commandDecision = _commandPolicy.Evaluate(analysis); if (!commandDecision.Allowed) return $"Error: Command blocked by hard deny policy: {commandDecision.DenyReason}"; - if (_pathPolicy.CommandReferencesDeniedPath(args.Command, args.WorkingDirectory)) + if (_pathPolicy.CommandReferencesDeniedPath(analysis)) return "Error: Command references a protected file path. Access denied by security policy."; - var isWindows = OperatingSystem.IsWindows(); - var psi = new ProcessStartInfo - { - FileName = isWindows ? "cmd.exe" : "/bin/bash", - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - if (isWindows) - { - psi.ArgumentList.Add("/c"); - psi.ArgumentList.Add(args.Command); - } - else - { - psi.ArgumentList.Add("-c"); - psi.ArgumentList.Add(args.Command); - } + var psi = _environment.CreateProcessStartInfo(args.Command); // Resolve working directory in priority order: explicit arg → // WorkingContext.ProjectDirectory (declared via set_working_directory) @@ -94,43 +109,12 @@ protected override async Task ExecuteAsync(Params args, ToolInvocationCo // membership. The matcher reads context.Cwd against the same // resolution chain so the gate evaluates folder-scoped ApprovalEntry // records against the directory the spawned process will run in. - var resolvedCwd = context.ResolveShellCwd(args.WorkingDirectory); - if (!string.IsNullOrWhiteSpace(resolvedCwd)) - { - if (IsResolvedSessionDirectory(resolvedCwd, context.SessionDirectory)) - { - try - { - Directory.CreateDirectory(resolvedCwd); - } - catch (Exception ex) when (ex is ArgumentException - or IOException - or NotSupportedException - or UnauthorizedAccessException - or System.Security.SecurityException) - { - return $"Error preparing session working directory: {ex.Message}"; - } - } - else if (!Directory.Exists(resolvedCwd)) - { - // ProcessStartInfo.WorkingDirectory must point at an existing directory or - // Process.Start throws an opaque, platform-specific error. Only the session - // scratch dir is auto-created (above); every other resolved cwd — explicit - // arg, project dir, inherited cwd — must already exist. Fail loudly with the - // remedy so the agent creates it instead of retry-looping on a cryptic error. - // Any approval for this cwd is existence-agnostic, so it still matches once - // the agent runs the mkdir. - if (File.Exists(resolvedCwd)) - return $"Error: Working directory '{resolvedCwd}' is a file, not a directory."; - - var mkdirHint = isWindows ? $"mkdir \"{resolvedCwd}\"" : $"mkdir -p \"{resolvedCwd}\""; - return $"Error: Working directory '{resolvedCwd}' does not exist. " - + $"Create it first, e.g.: {mkdirHint}"; - } - - psi.WorkingDirectory = resolvedCwd; - } + var workingDirectoryError = PrepareWorkingDirectory( + psi, + resolvedCwd, + context.SessionDirectory); + if (workingDirectoryError is not null) + return workingDirectoryError; var effectiveTimeout = context.ExecutionTimeout.Value; @@ -144,12 +128,11 @@ or UnauthorizedAccessException } catch (Exception ex) { - return $"Error starting process: {ex.Message}"; + return FormatStartError(ex); } // Start the timeout countdown only after the shell process exists, so - // process-spawn overhead (heavier on Windows: cmd.exe plus the child it - // execs) is not charged against the command's execution budget. + // process-spawn overhead is not charged against the command's execution budget. timeoutCts.CancelAfter(effectiveTimeout); using (process) @@ -245,10 +228,28 @@ or UnauthorizedAccessException /// carries the same bounded head+tail result /// as the non-streaming path. /// - public override async IAsyncEnumerable ExecuteStreamAsync( + public override IAsyncEnumerable ExecuteStreamAsync( + IDictionary? arguments, + ToolInvocationContext context, + CancellationToken ct = default) + => ExecuteStreamWithAnalysisAsync( + arguments, + context, + authorizedAnalysis: null, + ct); + + internal IAsyncEnumerable ExecuteAuthorizedStreamAsync( IDictionary? arguments, ToolInvocationContext context, - [EnumeratorCancellation] CancellationToken ct = default) + ShellCommandAnalysis analysis, + CancellationToken ct) + => ExecuteStreamWithAnalysisAsync(arguments, context, analysis, ct); + + private async IAsyncEnumerable ExecuteStreamWithAnalysisAsync( + IDictionary? arguments, + ToolInvocationContext context, + ShellCommandAnalysis? authorizedAnalysis, + [EnumeratorCancellation] CancellationToken ct) { // All items (activities + completion) are produced by the non-iterator // helper and written into a channel. The iterator just relays them. @@ -257,7 +258,12 @@ public override async IAsyncEnumerable ExecuteStreamAsync( // internally and writes the error completion before completing the channel. var channel = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true }); - _ = ExecuteStreamCoreAsync(arguments, context, channel.Writer, ct); + _ = ExecuteStreamCoreAsync( + arguments, + context, + authorizedAnalysis, + channel.Writer, + ct); await foreach (var update in channel.Reader.ReadAllAsync(CancellationToken.None)) yield return update; @@ -266,6 +272,7 @@ public override async IAsyncEnumerable ExecuteStreamAsync( private async Task ExecuteStreamCoreAsync( IDictionary? arguments, ToolInvocationContext context, + ShellCommandAnalysis? authorizedAnalysis, ChannelWriter output, CancellationToken ct) { @@ -283,7 +290,9 @@ private async Task ExecuteStreamCoreAsync( return; } - var commandDecision = _commandPolicy.Evaluate(args.Command); + var resolvedCwd = context.ResolveShellCwd(args.WorkingDirectory); + var analysis = ResolveAnalysis(args.Command, resolvedCwd, authorizedAnalysis); + var commandDecision = _commandPolicy.Evaluate(analysis); if (!commandDecision.Allowed) { output.TryWrite(new ToolCompletedUpdate( @@ -291,72 +300,22 @@ private async Task ExecuteStreamCoreAsync( return; } - if (_pathPolicy.CommandReferencesDeniedPath(args.Command, args.WorkingDirectory)) + if (_pathPolicy.CommandReferencesDeniedPath(analysis)) { output.TryWrite(new ToolCompletedUpdate( "Error: Command references a protected file path. Access denied by security policy.")); return; } - var isWindows = OperatingSystem.IsWindows(); - var psi = new ProcessStartInfo - { - FileName = isWindows ? "cmd.exe" : "/bin/bash", - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - if (isWindows) - { - psi.ArgumentList.Add("/c"); - psi.ArgumentList.Add(args.Command); - } - else + var psi = _environment.CreateProcessStartInfo(args.Command); + var workingDirectoryError = PrepareWorkingDirectory( + psi, + resolvedCwd, + context.SessionDirectory); + if (workingDirectoryError is not null) { - psi.ArgumentList.Add("-c"); - psi.ArgumentList.Add(args.Command); - } - - var resolvedCwd = context.ResolveShellCwd(args.WorkingDirectory); - if (!string.IsNullOrWhiteSpace(resolvedCwd)) - { - if (IsResolvedSessionDirectory(resolvedCwd, context.SessionDirectory)) - { - try - { - Directory.CreateDirectory(resolvedCwd); - } - catch (Exception ex) when (ex is ArgumentException - or IOException - or NotSupportedException - or UnauthorizedAccessException - or System.Security.SecurityException) - { - output.TryWrite(new ToolCompletedUpdate( - $"Error preparing session working directory: {ex.Message}")); - return; - } - } - else if (!Directory.Exists(resolvedCwd)) - { - if (File.Exists(resolvedCwd)) - { - output.TryWrite(new ToolCompletedUpdate( - $"Error: Working directory '{resolvedCwd}' is a file, not a directory.")); - return; - } - - var mkdirHint = isWindows ? $"mkdir \"{resolvedCwd}\"" : $"mkdir -p \"{resolvedCwd}\""; - output.TryWrite(new ToolCompletedUpdate( - $"Error: Working directory '{resolvedCwd}' does not exist. " - + $"Create it first, e.g.: {mkdirHint}")); - return; - } - - psi.WorkingDirectory = resolvedCwd; + output.TryWrite(new ToolCompletedUpdate(workingDirectoryError)); + return; } Process process; @@ -366,7 +325,7 @@ or UnauthorizedAccessException } catch (Exception ex) { - output.TryWrite(new ToolCompletedUpdate($"Error starting process: {ex.Message}")); + output.TryWrite(new ToolCompletedUpdate(FormatStartError(ex))); return; } @@ -459,6 +418,27 @@ or UnauthorizedAccessException } } + private ShellCommandAnalysis ResolveAnalysis( + string command, + string? resolvedCwd, + ShellCommandAnalysis? authorizedAnalysis) + { + if (authorizedAnalysis is null) + return _commandPolicy.Analyze(command, resolvedCwd); + + if (!string.Equals(authorizedAnalysis.Source, command, StringComparison.Ordinal) + || !string.Equals( + authorizedAnalysis.WorkingDirectory, + resolvedCwd, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The authorized shell analysis does not match the executed command."); + } + + return authorizedAnalysis; + } + private static readonly TimeSpan CoalesceInterval = TimeSpan.FromMilliseconds(500); private static async Task DrainPipeToChannelAsync( @@ -516,6 +496,51 @@ private static async Task KillAndDrainAsync(Process process, Task drainStdout, T } } + private string? PrepareWorkingDirectory( + ProcessStartInfo startInfo, + string? resolvedCwd, + string? sessionDirectory) + { + if (string.IsNullOrWhiteSpace(resolvedCwd)) + return null; + + if (IsResolvedSessionDirectory(resolvedCwd, sessionDirectory)) + { + try + { + Directory.CreateDirectory(resolvedCwd); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + return $"Error preparing session working directory: {ex.Message}"; + } + } + else if (!Directory.Exists(resolvedCwd)) + { + if (File.Exists(resolvedCwd)) + return $"Error: Working directory '{resolvedCwd}' is a file, not a directory."; + + return $"Error: Working directory '{resolvedCwd}' does not exist. " + + $"Create it first, e.g.: {CreateDirectoryHint(resolvedCwd)}"; + } + + startInfo.WorkingDirectory = resolvedCwd; + return null; + } + + private string CreateDirectoryHint(string path) + => _environment.PathStyle == ShellPathStyle.Windows + ? $"New-Item -ItemType Directory -Force -Path '{path.Replace("'", "''", StringComparison.Ordinal)}'" + : $"mkdir -p -- '{path.Replace("'", "'\\''", StringComparison.Ordinal)}'"; + + private string FormatStartError(Exception exception) + => $"Error starting shell '{_environment.ExecutableName}' " + + $"at '{_environment.ExecutablePath}': {exception.Message}"; + // Retained for compatibility with tests/benchmark that call it directly; the // main execution path no longer uses this — output is bounded at read time by // BoundedOutputReader. diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 9375866bd..9f585f364 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Microsoft.Extensions.AI; +using System.Runtime.CompilerServices; using Netclaw.Actors.Channels; using Netclaw.Actors.Jobs; using Netclaw.Actors.Protocol; @@ -20,10 +21,13 @@ public sealed class ToolAccessPolicy private readonly ToolAudienceProfileResolver _profileResolver; private readonly ShellCommandPolicy _shellCommandPolicy; private readonly ToolPathPolicy _toolPathPolicy; + private readonly ShellApprovalMatcher _shellApprovalMatcher; private readonly IShellTrustZonePolicy? _shellTrustZonePolicy; private readonly IToolApprovalMatcher _fileApprovalMatcher; private readonly FeatureGates _featureGates; private readonly ScopedShellSafeVerbPolicy? _safeVerbPolicy; + private readonly ConditionalWeakTable + _authorizedShellAnalyses = new(); public ToolAccessPolicy( ToolConfig toolConfig, @@ -44,6 +48,14 @@ public ToolAccessPolicy( _profileResolver = new ToolAudienceProfileResolver(toolConfig); _shellCommandPolicy = shellCommandPolicy; _toolPathPolicy = toolPathPolicy; + if (!ReferenceEquals(shellCommandPolicy.Environment, toolPathPolicy.Environment)) + { + throw new ArgumentException( + "Shell command and path policies must use the same shell environment.", + nameof(toolPathPolicy)); + } + + _shellApprovalMatcher = new ShellApprovalMatcher(shellCommandPolicy.Environment); _shellTrustZonePolicy = shellTrustZonePolicy; _fileApprovalMatcher = fileApprovalMatcher ?? DefaultApprovalMatcher.Instance; _featureGates = featureGates ?? FeatureGates.AllEnabled; @@ -107,6 +119,8 @@ public ToolAccessDecision AuthorizeInvocation( ToolExecutionContext context, IDictionary? arguments) { + _authorizedShellAnalyses.Remove(context); + if (tool is McpToolAdapter mcp) { if (!_profileResolver.IsMcpServerAllowed(new McpServerName(mcp.ServerName), context.Invocation)) @@ -148,21 +162,33 @@ public ToolAccessDecision AuthorizeInvocation( return ToolAccessDecision.Allow(ToolAllowReason.BackgroundJobLifecycle); var shellCommand = ExtractShellCommand(arguments); + var workingDirectory = context.ResolveShellCwd(ExtractWorkingDirectory(arguments)); + ShellCommandAnalysis? shellAnalysis = null; if (shellCommand is not null) { - var hardDenyDecision = _shellCommandPolicy.Evaluate(shellCommand); + shellAnalysis = _shellCommandPolicy.Analyze(shellCommand, workingDirectory); + var hardDenyDecision = _shellCommandPolicy.Evaluate(shellAnalysis); if (!hardDenyDecision.Allowed) return ToolAccessDecision.Deny( $"hard_deny_{hardDenyDecision.DenyCategory?.ToWireName() ?? "unknown"}"); + + if (_toolPathPolicy.CommandReferencesDeniedPath(shellAnalysis)) + return ToolAccessDecision.Deny("shell_references_protected_path"); } - // All shell policy checks must use the directory that ShellTool uses. + // All shell policy checks use the directory that ShellTool executes. // The explicit tool argument can be absent while the context supplies // an active project, session, or inherited directory. - var workingDirectory = context.ResolveShellCwd(ExtractWorkingDirectory(arguments)); - if (shellCommand is not null - && _toolPathPolicy.CommandReferencesDeniedPath(shellCommand, workingDirectory)) - return ToolAccessDecision.Deny("shell_references_protected_path"); + var analysisArguments = WithResolvedShellWorkingDirectory(arguments, workingDirectory); + var shellApproval = shellAnalysis is null + ? null + : _shellApprovalMatcher.AnalyzeInvocation( + toolName, + analysisArguments, + shellAnalysis); + + if (shellAnalysis is not null) + _authorizedShellAnalyses.Add(context, shellAnalysis); // Non-interactive channels: sandbox shell commands to trust zone paths. // Even if the verb-chain is pre-approved, path arguments must fall within @@ -172,18 +198,37 @@ public ToolAccessDecision AuthorizeInvocation( { if (_shellTrustZonePolicy is null) { - if (ShellCommandHasTrustZoneSensitiveInputs(shellCommand, workingDirectory)) + if (ShellCommandHasTrustZoneSensitiveInputs(shellApproval, workingDirectory)) return ToolAccessDecision.Deny("shell_trust_zone_policy_not_configured"); } else { - var trustZoneDeny = EnforceShellTrustZones(shellCommand, workingDirectory, context); + var trustZoneDeny = EnforceShellTrustZones( + shellApproval!, + workingDirectory, + context); if (trustZoneDeny is not null) return trustZoneDeny; } } - return CheckApprovalGate(toolName, context, arguments, ShellApprovalMatcher.Instance); + return CheckApprovalGate( + toolName, + context, + arguments, + _shellApprovalMatcher, + shellApproval); + } + + internal bool TryTakeAuthorizedShellAnalysis( + ToolExecutionContext context, + out ShellCommandAnalysis? analysis) + { + if (!_authorizedShellAnalyses.TryGetValue(context, out analysis)) + return false; + + _authorizedShellAnalyses.Remove(context); + return true; } /// @@ -195,10 +240,13 @@ public ToolAccessDecision AuthorizeInvocation( /// null if all paths are within bounds. /// private ToolAccessDecision? EnforceShellTrustZones( - string shellCommand, + ShellApprovalAnalysis approval, string? workingDirectory, ToolExecutionContext context) { + if (approval.IsMessy) + return ToolAccessDecision.Deny("shell_unresolved_trust_zone_input"); + if (!string.IsNullOrWhiteSpace(workingDirectory)) { var expandedWorkingDirectory = PathUtility.ExpandAndNormalize(workingDirectory, workingDirectory: null); @@ -209,55 +257,26 @@ public ToolAccessDecision AuthorizeInvocation( return ToolAccessDecision.Deny("shell_working_directory_outside_trust_zone"); } - var pathTokens = ExtractShellPathTokens(shellCommand); - if (pathTokens.Count == 0) - return null; - - foreach (var pathToken in pathTokens) + foreach (var directory in approval.Candidates + .Select(static candidate => candidate.Directory) + .Where(static directory => !string.IsNullOrWhiteSpace(directory)) + .Distinct(StringComparer.Ordinal)) { - var expanded = ShellTokenizer.NormalizePathToken(pathToken, workingDirectory); - if (expanded is null) - continue; - - if (!_shellTrustZonePolicy!.IsShellWritePathAuthorized(expanded, context.Invocation)) + if (!_shellTrustZonePolicy!.IsShellWritePathAuthorized(directory!, context.Invocation)) return ToolAccessDecision.Deny("shell_path_outside_trust_zone"); } return null; } - private static IReadOnlyList ExtractShellPathTokens(string shellCommand) - { - var pathTokens = new List(); - foreach (var segment in ShellTokenizer.GetAllCommandSegments(shellCommand)) - { - foreach (var token in ShellTokenizer.Tokenize(segment)) - { - var trimmed = TrimShellTokenPunctuation(token); - if (ShellTokenizer.LooksLikePath(trimmed)) - pathTokens.Add(trimmed); - } - } - - return pathTokens; - } - - private static bool ShellCommandHasTrustZoneSensitiveInputs(string shellCommand, string? workingDirectory) - => !string.IsNullOrWhiteSpace(workingDirectory) || ShellCommandHasPathArguments(shellCommand); - - private static string TrimShellTokenPunctuation(string token) - => token.Trim().TrimStart(';', '|', '&').TrimEnd(';', '|', '&'); - - private static bool ShellCommandHasPathArguments(string shellCommand) - { - foreach (var token in ExtractShellPathTokens(shellCommand)) - { - if (!string.IsNullOrWhiteSpace(token)) - return true; - } - - return false; - } + private static bool ShellCommandHasTrustZoneSensitiveInputs( + ShellApprovalAnalysis? approval, + string? workingDirectory) + => !string.IsNullOrWhiteSpace(workingDirectory) + || approval is null + || approval.IsMessy + || approval.Candidates.Any(static candidate => + !string.IsNullOrWhiteSpace(candidate.Directory)); private static string? ExtractShellCommand(IDictionary? arguments) { @@ -308,7 +327,8 @@ private ToolAccessDecision CheckApprovalGate( ToolName toolName, ToolExecutionContext context, IDictionary? arguments, - IToolApprovalMatcher matcher) + IToolApprovalMatcher matcher, + ShellApprovalAnalysis? shellApproval = null) { var audience = ResolveAudience(context.Invocation); var profile = ToolAudienceProfileDefaults.GetResolvedProfile(_toolConfig.AudienceProfiles, audience); @@ -359,10 +379,14 @@ private ToolAccessDecision CheckApprovalGate( var analysisArguments = isShell ? WithResolvedShellWorkingDirectory(arguments, resolvedShellCwd) : arguments; - var patterns = matcher.ExtractPatterns(toolName, analysisArguments); - var candidates = matcher.ExtractCandidates(toolName, analysisArguments); - var displayText = matcher.FormatForDisplay(toolName, arguments); - var isMessy = matcher.IsMessy(toolName, analysisArguments); + var patterns = shellApproval?.Patterns + ?? matcher.ExtractPatterns(toolName, analysisArguments); + var candidates = shellApproval?.Candidates + ?? matcher.ExtractCandidates(toolName, analysisArguments); + var displayText = shellApproval?.DisplayText + ?? matcher.FormatForDisplay(toolName, arguments); + var isMessy = shellApproval?.IsMessy + ?? matcher.IsMessy(toolName, analysisArguments); IReadOnlyList approvalCandidates = candidates; diff --git a/src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs b/src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs index 638d4a838..89d6dab78 100644 --- a/src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs +++ b/src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs @@ -82,20 +82,13 @@ public void Load_public_overload_returns_current_OS_defaults() [Fact] public void Contains_uses_platform_correct_case_rules() { - var list = SafeVerbLoader.Load(isWindows: false); + var linux = SafeVerbLoader.Load(isWindows: false); + var windows = SafeVerbLoader.Load(isWindows: true); - if (OperatingSystem.IsWindows()) - { - // OrdinalIgnoreCase - Assert.True(list.Contains("LS")); - Assert.True(list.Contains("ls")); - } - else - { - // Ordinal — `LS` is a different binary from `ls` on POSIX. - Assert.False(list.Contains("LS")); - Assert.True(list.Contains("ls")); - } + Assert.False(linux.Contains("LS")); + Assert.True(linux.Contains("ls")); + Assert.True(windows.Contains("GET-CONTENT")); + Assert.True(windows.Contains("Get-Content")); } [Fact] diff --git a/src/Netclaw.Configuration/Resources/AGENTS.md b/src/Netclaw.Configuration/Resources/AGENTS.md index e322bb40d..cda99e4d0 100644 --- a/src/Netclaw.Configuration/Resources/AGENTS.md +++ b/src/Netclaw.Configuration/Resources/AGENTS.md @@ -57,6 +57,31 @@ pointing at `set_working_directory `. Read the hint, call the tool with the directory the user is asking about, then retry the original shell call — do not re-prompt the user. +## Native Shell Syntax + +The `[working-context]` block names the exact shell executable, grammar, and +PowerShell dialect available to `shell_execute`. Author commands in that +grammar: + +- Linux and macOS use Bash, for example `rg -n "TODO" src | head -40`. +- Windows uses native PowerShell, for example + `Get-ChildItem -Path src -Recurse | Select-String -Pattern TODO`. +- On Windows, use `&&` and `||` only when the context names PowerShell 7. + Windows PowerShell 5.1 does not support those pipeline-chain operators; use + separate statements or ordinary PowerShell conditionals. + +Shell languages do not nest implicitly. A `pwsh -Command ...` call submitted +to Bash is one external program invocation; Bash approval analysis does not +reinterpret its payload as PowerShell. Likewise, native PowerShell treats +`bash -c ...` as an external command. Prefer the native grammar shown in +context instead of adding a child-shell wrapper. + +The shell identity describes only Netclaw's selected executable and parser +contract. Do not assume a profile, module, alias outside the parser's catalog, +inherited variable value, executable lookup result, or external script body. +When a command depends on unknown ambient state or dynamic command identity, +expect the approval gate to keep it one-time and fail closed. + ## Grounding Rules - Never state runtime facts (versions, status, availability) without checking with a tool. diff --git a/src/Netclaw.Configuration/SafeVerbList.cs b/src/Netclaw.Configuration/SafeVerbList.cs index ea9bcf64a..a04b1ee73 100644 --- a/src/Netclaw.Configuration/SafeVerbList.cs +++ b/src/Netclaw.Configuration/SafeVerbList.cs @@ -18,10 +18,9 @@ namespace Netclaw.Configuration; /// through code review and a daemon release, not a config edit. The agent /// has no path to extend its own read-only verb list at runtime. /// -/// Membership is exact-equality against the verb chain extracted by the -/// shell parser (case rules from -/// : Ordinal on POSIX, -/// OrdinalIgnoreCase on Windows). Mutating verbs (e.g. git push, +/// Membership is exact equality against the verb chain from the shell parser. +/// The selected platform uses ordinal comparison on POSIX and case-insensitive +/// ordinal comparison on Windows. Mutating verbs (e.g. git push, /// sed -i) are intentionally absent — they remain subject to the /// interactive approval gate. /// @@ -72,8 +71,8 @@ internal sealed class SafeVerbListFile } /// -/// Loads the bundled safe-verbs list for the current OS from the embedded -/// resource. There is no user-override path — the safe-verbs list is +/// Loads a bundled platform safe-verb list from the embedded resource. +/// There is no user-override path. The safe-verbs list is /// immutable at runtime so the agent cannot widen its own read-only /// auto-pass set through file writes. Widening goes through code review /// and a daemon release. @@ -99,9 +98,14 @@ public static class SafeVerbLoader /// public static SafeVerbList Load() => Load(OperatingSystem.IsWindows()); - internal static SafeVerbList Load(bool isWindows) + /// + /// Loads the list for the specified platform identity. + /// + public static SafeVerbList Load(bool isWindows) { - var comparer = ToolApprovalEntryComparer.Comparer; + var comparer = isWindows + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; var verbs = new HashSet(comparer); foreach (var verb in LoadBundled(isWindows)) diff --git a/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json b/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json index 660fae725..0e205cc53 100644 --- a/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json +++ b/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json @@ -1,5 +1,5 @@ { - "$comment": "Bundled defaults for shell verbs the approval gate auto-allows on Windows when invoked inside a trusted zone (per-audience trustedZones + session_dir + audience baseline). Each entry is a verb chain matched by exact equality against the shell parser's verb-chain extraction. Includes both cmd.exe builtins and PowerShell read-only cmdlets, plus the same git/gh read subcommands as the Linux list. Inclusion bar: a verb is listed only if it cannot write or delete files, cannot execute arbitrary code, cannot POST/PATCH/DELETE to a network endpoint, and cannot expose credential-bearing ambient state (the process environment or the process table). Mutating and command-prefixing verbs are intentionally absent. Environment- and process-inspection verbs (Get-Process and the like) are intentionally absent — they expose other processes' state, which the safe-space gate cannot scope. gh auth status is absent because its --show-token form prints the GitHub token and flags are stripped from verb-chain matching. This list is immutable at runtime: no on-disk override file is read. Widening the list goes through code review and a daemon release so the agent cannot extend its own auto-pass surface via file writes.", + "$comment": "Bundled defaults for shell verbs the approval gate auto-allows on Windows when invoked inside a trusted zone (per-audience trustedZones + session_dir + audience baseline). Each entry is a verb chain matched by exact equality against the PowerShell parser's verb-chain extraction. The list includes read-only PowerShell cmdlets, aliases, native utilities, and the same git/gh read subcommands as the Linux list. Inclusion bar: a verb is listed only if it cannot write or delete files, cannot execute arbitrary code, cannot POST/PATCH/DELETE to a network endpoint, and cannot expose credential-bearing ambient state (the process environment or the process table). Mutating and command-prefixing verbs are intentionally absent. Environment- and process-inspection verbs (Get-Process and the like) are intentionally absent — they expose other processes' state, which the safe-space gate cannot scope. gh auth status is absent because its --show-token form prints the GitHub token and flags are stripped from verb-chain matching. This list is immutable at runtime: no on-disk override file is read. Widening the list goes through code review and a daemon release so the agent cannot extend its own auto-pass surface via file writes.", "verbs": [ "cd", "chdir", diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index 60e05d1a5..4cd17149d 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -30,7 +30,6 @@ using Netclaw.Configuration; using Netclaw.Configuration.Http; using Netclaw.Providers; -using ShellSyntaxTree; using Netclaw.Providers.OAuth; using Netclaw.Providers.OpenAi; using Netclaw.Providers.OpenRouter; @@ -128,6 +127,13 @@ static async Task RunDaemonAsync(string[] args, DaemonRestartSignal restartSigna Directory.CreateDirectory(netclawTempDir); Environment.CurrentDirectory = netclawTempDir; + // Resolve inside each daemon generation. A soft restart is allowed to + // select a newly installed compatible host, but one running generation + // keeps this exact environment for parsing, authorization, and execution. + var shellResolution = await ShellExecutionEnvironmentResolver + .CreateDefault(TimeProvider.System) + .ResolveAsync(ShellExecutionEnvironmentResolver.DetectCurrentPlatform()); + var builder = WebApplication.CreateBuilder(args); // Register process-lifetime restart signal so services can trigger a restart @@ -143,7 +149,13 @@ static async Task RunDaemonAsync(string[] args, DaemonRestartSignal restartSigna builder.WebHost.UseUrls($"http://{daemonConfig.Host}:{daemonConfig.Port}"); var daemonLogLevel = builder.ConfigureNetclawLogging(paths); builder.AddNetclawTelemetry(); - ConfigureDaemonServices(builder.Services, builder.Configuration, paths, daemonLogLevel, daemonConfig); + ConfigureDaemonServices( + builder.Services, + builder.Configuration, + paths, + daemonLogLevel, + daemonConfig, + shellResolution); // Authentication — a PolicyScheme selector is the default scheme. // It routes to DeviceBearer when an Authorization: Bearer header is present, @@ -198,6 +210,23 @@ static async Task RunDaemonAsync(string[] args, DaemonRestartSignal restartSigna var app = builder.Build(); crashMonitor.AttachServices(app.Services); + var startupLogger = app.Services + .GetRequiredService() + .CreateLogger("Netclaw.Startup"); + startupLogger.LogInformation( + "Selected native shell {Executable} at {ExecutablePath} with {Grammar} grammar and {Dialect} dialect.", + shellResolution.Environment.ExecutableName, + shellResolution.Environment.ExecutablePath, + shellResolution.Environment.Grammar, + shellResolution.Environment.PowerShellDialect?.ToString() ?? "not-applicable"); + if (shellResolution.FallbackReason is { } fallbackReason) + { + startupLogger.LogWarning( + "Selected Windows PowerShell 5.1 fallback because {FallbackReason}; rejected preferred version: {RejectedVersion}.", + fallbackReason, + shellResolution.RejectedPreferredVersion?.ToString() ?? "not-found"); + } + if (daemonConfig.ExposureMode == ExposureMode.ReverseProxy) { var forwardedHeadersOptions = new ForwardedHeadersOptions @@ -387,8 +416,12 @@ static void ConfigureDaemonServices( IConfigurationManager configuration, NetclawPaths paths, LogLevel daemonLogLevel, - DaemonConfig daemonConfig) + DaemonConfig daemonConfig, + ShellEnvironmentResolution shellResolution) { + var shellEnvironment = shellResolution.Environment; + services.AddSingleton(shellEnvironment); + // Daemon bind address and exposure mode (computed once in RunDaemonAsync) services.AddSingleton(daemonConfig); @@ -618,7 +651,11 @@ static void ConfigureDaemonServices( paths.LockFilePath, paths.RestartManifestPath, }; - var toolPathPolicy = new ToolPathPolicy(writeDenyList, readDenyList, shellIndicatorList); + var toolPathPolicy = new ToolPathPolicy( + shellEnvironment, + writeDenyList, + readDenyList, + shellIndicatorList); services.AddSingleton(toolPathPolicy); // Load operator-authored hard-deny overrides (additive only — see @@ -630,10 +667,13 @@ static void ConfigureDaemonServices( var hardDenyOverrides = hardDenyOverridesLoader.Load(paths.HardDenyOverridesPath); services.AddSingleton(hardDenyOverridesLoader); - var shellCommandPolicy = new ShellCommandPolicy(toolConfig.HardDenyPatterns, hardDenyOverrides); + var shellCommandPolicy = new ShellCommandPolicy( + shellEnvironment, + toolConfig.HardDenyPatterns, + hardDenyOverrides); services.AddSingleton(shellCommandPolicy); - services.AddShellParser(); + services.AddShellParser(shellEnvironment); // Subagent timeout configuration var subAgentConfig = configuration.GetSection("SubAgents") @@ -671,12 +711,9 @@ static void ConfigureDaemonServices( // list goes through code review and a daemon release, not a config // edit, so the agent has no path to extend its own auto-pass surface // at runtime. - var safeVerbs = SafeVerbLoader.Load(); + var safeVerbs = SafeVerbLoader.Load(shellEnvironment.Platform == ShellPlatform.Windows); services.AddSingleton(safeVerbs); - var bashParser = new BashParser(); - services.AddSingleton(bashParser); - var toolAccessPolicy = new ToolAccessPolicy( toolConfig, effectivePolicyDefaults, @@ -1056,7 +1093,7 @@ static void ConfigureDaemonServices( : null; akkaBuilder.WithNetclawSerialization(); - akkaBuilder.WithNetclawActors(reminderStorage); + akkaBuilder.WithNetclawActors(shellEnvironment, reminderStorage); akkaBuilder.WithSessionLogDispatcher(paths.SessionLogsDirectory, sp.GetRequiredService()); akkaBuilder.WithSignalRGateway(); akkaBuilder.WithDailyStatsActor(); diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs index c16ea3165..9bbb327cb 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs @@ -10,20 +10,19 @@ namespace Netclaw.Security.Tests; /// /// Multi-line shell command coverage for . -/// A bare newline separates statements; on POSIX both ExtractPatterns -/// and ExtractCandidates route through BashParser, so a multi-line -/// command decomposes into one approval unit per statement. +/// A bare newline separates Bash statements. The matcher returns one approval +/// unit for each statement and keeps pipeline stages in one unit. /// public sealed class ShellApprovalMatcherMultilineTests { - private readonly ShellApprovalMatcher _matcher = ShellApprovalMatcher.Instance; + private readonly ShellApprovalMatcher _matcher = new( + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux)); private static Dictionary Args(string command) => new() { ["Command"] = command }; /// - /// xunit.v3 SkipUnless hook: the matcher routes through BashParser - /// on POSIX only — the Windows path uses the legacy newline-blind - /// ShellTokenizer splitter, so these assertions don't hold there. + /// xunit.v3 SkipUnless hook for tests that require POSIX paths and + /// filesystem behavior. Native PowerShell cases have a separate matrix. /// public static bool IsPosix => !OperatingSystem.IsWindows(); diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 3cccbd076..abf0277e1 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -6,13 +6,15 @@ using System.Text.Json; using Netclaw.Configuration; using Netclaw.Tools; +using ShellSyntaxTree; using Xunit; namespace Netclaw.Security.Tests; public sealed class ShellApprovalMatcherTests { - private readonly ShellApprovalMatcher _matcher = ShellApprovalMatcher.Instance; + private readonly ShellApprovalMatcher _matcher = new( + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux)); private static Dictionary Args(string command) => new() { ["Command"] = command }; @@ -27,46 +29,127 @@ public sealed class ShellApprovalMatcherTests private static ApprovalEntry InDir(string verb, string dir) => new(verb) { Directory = dir }; /// - /// xunit.v3 SkipUnless hook for POSIX-only tests. The v2 - /// matcher falls through to the legacy ShellTokenizer path - /// on Windows (ShellSyntaxTree is bash-only), so tests that pin - /// BashParser cwd attribution / arg.Resolved canonicalization - /// don't apply. Marking them [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")] - /// produces a proper "Skipped" entry in the test log on Windows - /// runners instead of hiding the gap behind an early-return. + /// xunit.v3 SkipUnless hook for tests that require the POSIX + /// filesystem in addition to the matcher's explicit Bash environment. /// public static bool IsPosix => !OperatingSystem.IsWindows(); public static bool IsWindows => OperatingSystem.IsWindows(); [Theory] - [InlineData("pwsh -NoProfile -NonInteractive -Command git-status")] - [InlineData("powershell.exe -NoProfile -NonInteractive -Command git-status")] - [InlineData("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -Command git-status")] - [InlineData("cmd.exe /d /s /c \"powershell.exe -Command git-status\"")] - [InlineData("cmd.exe /d /s /c \"power^shell.exe -Command git-status\"")] - [InlineData("cmd.exe /d /s /c power\"shell\".exe -Command git-status")] - [InlineData("cmd.exe /d /s /c pw\"sh\".exe -Command git-status")] - public void Unanalyzed_power_shell_host_is_detected(string command) + [InlineData("pwsh -NoProfile -Command 'git status'", "pwsh")] + [InlineData("powershell.exe -File script.ps1", "powershell.exe")] + public void Bash_matcher_keeps_power_shell_as_one_external_approval_unit( + string command, + string expectedVerb) { - Assert.True(ShellApprovalMatcher.ContainsUnanalyzedPowerShellHost(command)); + var analysis = _matcher.AnalyzeInvocation( + new ToolName("shell_execute"), + Args(command)); + + Assert.False(analysis.IsMessy); + Assert.Equal(expectedVerb, Assert.Single(analysis.Candidates).Verb); } - [SlopwatchSuppress("SW001", "This regression verifies the Windows fail-closed PowerShell matcher path.")] - [Fact(SkipUnless = nameof(IsWindows), Skip = "The legacy Windows matcher is active only on Windows.")] - public void Windows_power_shell_wrapper_cannot_reuse_host_approval() + [Fact] + public void Power_shell_matcher_uses_the_native_power_shell_grammar() { - const string command = - "cmd.exe /d /s /c power\"shell\" -NoProfile -NonInteractive -Command git-status"; - var arguments = Args(command); + var matcher = new ShellApprovalMatcher( + ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7)); - Assert.Empty(_matcher.ExtractPatterns(new ToolName("shell_execute"), arguments)); - Assert.Empty(_matcher.ExtractCandidates(new ToolName("shell_execute"), arguments)); - Assert.True(_matcher.IsMessy(new ToolName("shell_execute"), arguments)); - Assert.False(_matcher.IsApproved( + var analysis = matcher.AnalyzeInvocation( new ToolName("shell_execute"), - arguments, - [Verb("cmd.exe")], - cwd: null)); + Args("Get-ChildItem -Path . -Filter *.cs", @"C:\work")); + + Assert.False(analysis.IsMessy); + Assert.Equal("Get-ChildItem", Assert.Single(analysis.Candidates).Verb); + } + + [Theory] + [InlineData(@"Get-Content Env:\Path")] + [InlineData(@"Get-Item HKLM:\Software\Vendor")] + [InlineData(@"Remove-Item CustomDrive:\target")] + [InlineData(@"Write-Output Env:\Path")] + [InlineData(@"Get-Item Registry::HKEY_LOCAL_MACHINE\Software")] + [InlineData(@"Get-Item Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\Software")] + public void Power_shell_provider_drives_do_not_create_persistent_candidates(string command) + { + var matcher = new ShellApprovalMatcher( + ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7)); + + var analysis = matcher.AnalyzeInvocation( + new ToolName("shell_execute"), + Args(command, @"C:\work")); + + Assert.True(analysis.IsMessy); + Assert.Empty(analysis.Patterns); + Assert.Empty(analysis.Candidates); + } + + [Fact] + public void Power_shell_file_system_provider_keeps_directory_scope() + { + var matcher = new ShellApprovalMatcher( + ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7)); + + var analysis = matcher.AnalyzeInvocation( + new ToolName("shell_execute"), + Args(@"Get-Content FileSystem::C:\work\input.txt", @"C:\work")); + + Assert.False(analysis.IsMessy); + var candidate = Assert.Single(analysis.Candidates); + Assert.Equal("Get-Content", candidate.Verb); + Assert.Equal("C:/work", candidate.Directory); + } + + [Fact] + public void Power_shell_redirect_uses_the_environment_path_style() + { + var matcher = new ShellApprovalMatcher( + ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7)); + + var analysis = matcher.AnalyzeInvocation( + new ToolName("shell_execute"), + Args(@"Get-Content .\input.txt > .\output.txt", @"C:\work")); + + Assert.False(analysis.IsMessy); + var candidate = Assert.Single(analysis.Candidates); + Assert.Equal("Get-Content", candidate.Verb); + Assert.Equal("C:/work", candidate.Directory); + } + + [Fact] + public void Dialect_change_reparses_before_candidates_can_match_a_grant() + { + const string command = @"Get-ChildItem && Get-Content .\input.txt"; + var powerShell7 = new ShellApprovalMatcher( + ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7)); + var windowsPowerShell = new ShellApprovalMatcher( + ShellExecutionEnvironment.CreatePowerShell( + @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + PwshDialect.WindowsPowerShell51)); + + var acceptedDialect = powerShell7.AnalyzeInvocation( + new ToolName("shell_execute"), + Args(command, @"C:\work")); + var changedDialect = windowsPowerShell.AnalyzeInvocation( + new ToolName("shell_execute"), + Args(command, @"C:\work")); + + Assert.False(acceptedDialect.IsMessy); + Assert.Equal(2, acceptedDialect.Candidates.Count); + Assert.True(changedDialect.IsMessy); + Assert.Empty(changedDialect.Patterns); + Assert.Empty(changedDialect.Candidates); } [Fact] @@ -588,9 +671,8 @@ public void IsApproved_returns_false_for_messy_command_even_with_global_wildcard [Fact] public void ExtractPatterns_strips_bare_integer_positional_arguments() { - // BashParser is bash-only, so on Windows the matcher falls through to - // the legacy ShellTokenizer path. This test exercises the POSIX path. - // Windows skips with a pass to keep the test active (no Slopwatch SW001). + // This test uses POSIX filesystem semantics and a Linux Bash environment. + // Windows skips with a pass to keep the test active for Slopwatch. if (OperatingSystem.IsWindows()) return; // The approval pattern for `freshdesk ticket get 123` should be @@ -661,7 +743,8 @@ public void ExtractCandidateVerbs_strips_bare_integer_positional_arguments() /// public sealed class ShellApprovalMatcherPathExtractionTests { - private readonly ShellApprovalMatcher _matcher = ShellApprovalMatcher.Instance; + private readonly ShellApprovalMatcher _matcher = new( + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux)); private static Dictionary Args(string command) => new() { ["Command"] = command }; @@ -773,13 +856,8 @@ public sealed class ShellApprovalMatcherPathExtractionTests }; /// - /// xunit.v3 SkipUnless hook for POSIX-only tests. The v2 - /// matcher falls through to the legacy ShellTokenizer path - /// on Windows (ShellSyntaxTree is bash-only), so tests that pin - /// BashParser cwd attribution / arg.Resolved canonicalization - /// don't apply. Marking them [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")] - /// produces a proper "Skipped" entry in the test log on Windows - /// runners instead of hiding the gap behind an early-return. + /// xunit.v3 SkipUnless hook for tests that require POSIX paths and + /// filesystem behavior. Native PowerShell cases have a separate matrix. /// public static bool IsPosix => !OperatingSystem.IsWindows(); diff --git a/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs b/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs index ebe8361af..da699438f 100644 --- a/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs +++ b/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs @@ -10,7 +10,14 @@ namespace Netclaw.Security.Tests; public sealed class ShellCommandAnalysisTests { - private readonly ShellCommandAnalyzer _analyzer = ShellCommandAnalyzer.Bash; + private static readonly ShellExecutionEnvironment BashEnvironment = + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + private static readonly ShellExecutionEnvironment PowerShellEnvironment = + ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7); + + private readonly ShellCommandAnalyzer _analyzer = new(BashEnvironment); [Theory] [InlineData("bash -lc")] @@ -56,62 +63,46 @@ public void Prefix_executable_is_retained_when_inner_command_is_expanded( } [Theory] - [InlineData("pwsh -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("pwsh -noprofile -NONINTERACTIVE -command \"git status\"")] - public void Exact_power_shell_wrapper_retains_host_and_child(string command) + [InlineData("pwsh -NoProfile -NonInteractive -Command 'git status'", "pwsh")] + [InlineData("powershell.exe -Command 'git status'", "powershell.exe")] + public void Bash_treats_power_shell_as_an_ordinary_external_command( + string command, + string expectedVerb) { var analysis = _analyzer.Analyze(command, "/work"); Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); Assert.False(analysis.HasDynamicSyntax); - Assert.Equal(["pwsh", "git status"], analysis.Commands.Select( - occurrence => occurrence.Clause.Verb.Joined)); - Assert.False(analysis.Commands[0].Clause.IsCommandStringWrapped); - Assert.True(analysis.Commands[1].Clause.IsCommandStringWrapped); + var occurrence = Assert.Single(analysis.Commands); + Assert.Equal(expectedVerb, occurrence.Clause.Verb.Joined); + Assert.False(occurrence.Clause.IsCommandStringWrapped); } [Fact] - public void Power_shell_host_is_retained_for_ambient_bash_resolution_controls() + public void Bash_does_not_interpret_a_power_shell_command_payload() { var analysis = _analyzer.Analyze( - "pwsh -NoProfile -NonInteractive -Command 'git status'", + "pwsh -NoProfile -Command 'Write-Output ok; netclaw daemon stop'", "/work"); Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); - Assert.Equal("pwsh", analysis.Commands[0].Clause.Verb.Joined); - Assert.False(analysis.Commands[0].Clause.IsCommandStringWrapped); - - // BASH_ENV and exported functions can replace this authored host at - // execution time. The policy must therefore approve it separately. - Assert.Equal("git status", analysis.Commands[1].Clause.Verb.Joined); + var occurrence = Assert.Single(analysis.Commands); + Assert.Equal("pwsh", occurrence.Clause.Verb.Joined); + Assert.DoesNotContain( + analysis.Commands, + command => command.Clause.Verb.Joined == "netclaw daemon stop"); } [Theory] - [InlineData("PWSH -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("pwsh.exe -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("powershell -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("pwsh -NonInteractive -NoProfile -Command 'git status'")] - [InlineData("pwsh -NoProfile -Command 'git status'")] - [InlineData("pwsh -NoProfile -NonInteractive -WorkingDirectory /etc -Command 'git status'")] - [InlineData("pwsh -NoProfile -NonInteractive -File script.ps1")] - [InlineData("pwsh -NoProfile -NonInteractive -EncodedCommand RwBlAHQALQBEAGEAdABlAA==")] - [InlineData("pwsh -NoProfile -NonInteractive -CommandWithArgs 'git status'")] - [InlineData("pwsh -NoProfile -NonInteractive -Command -")] - [InlineData("pwsh -NoProfile -NonInteractive -Command git status")] - [InlineData("pwsh -NoProfile -NonInteractive -Command 'git status' trailing")] - [InlineData("pwsh -NoProfile -NonInteractive -Command 'git status' > out.txt")] - [InlineData("env pwsh -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("builtin command pwsh -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("/usr/bin/env pwsh -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("xargs pwsh -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("/usr/bin/env -i pwsh -NoProfile -NonInteractive -Command 'git status'")] - [InlineData("xargs -n1 pwsh -NoProfile -NonInteractive -Command 'git status'")] - public void Power_shell_wrapper_near_miss_is_unresolved(string command) + [InlineData("PWSH -NoProfile -Command 'git status'")] + [InlineData("pwsh.exe -File script.ps1")] + [InlineData("powershell -EncodedCommand RwBlAHQALQBEAGEAdABlAA==")] + public void Bash_does_not_apply_power_shell_wrapper_rules(string command) { var analysis = _analyzer.Analyze(command, "/work"); - Assert.Equal(ShellAnalysisFailure.Unresolved, analysis.Failure); - Assert.Empty(analysis.Commands); + Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); + Assert.Single(analysis.Commands); } [Theory] @@ -119,16 +110,16 @@ public void Power_shell_wrapper_near_miss_is_unresolved(string command) [InlineData("rg pwsh .")] [InlineData("printf '%s\\n' pwsh")] [InlineData("git commit -m pwsh")] - public void Power_shell_host_token_used_as_data_stays_one_shot(string command) + public void Power_shell_host_token_used_as_data_is_not_special(string command) { var analysis = _analyzer.Analyze(command, "/work"); - Assert.Equal(ShellAnalysisFailure.Unresolved, analysis.Failure); - Assert.Empty(analysis.Commands); + Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); + Assert.Single(analysis.Commands); } [Fact] - public void Bash_dynamic_power_shell_payload_is_unresolved() + public void Bash_dynamic_argument_to_power_shell_stays_dynamic() { var analysis = _analyzer.Analyze( "pwsh -NoProfile -NonInteractive -Command \"git $operation\"", @@ -139,14 +130,15 @@ public void Bash_dynamic_power_shell_payload_is_unresolved() } [Fact] - public void Bash_decoded_power_shell_payload_is_the_child_source_of_truth() + public void Bash_does_not_decode_a_power_shell_payload() { var analysis = _analyzer.Analyze( "pwsh -NoProfile -NonInteractive -Command 'Write-Output '' ; netclaw daemon stop; #'''", "/work"); Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); - Assert.Contains( + Assert.Equal("pwsh", Assert.Single(analysis.Commands).Clause.Verb.Joined); + Assert.DoesNotContain( analysis.Commands, command => command.Clause.Verb.Joined == "netclaw daemon stop"); } @@ -154,9 +146,10 @@ public void Bash_decoded_power_shell_payload_is_the_child_source_of_truth() [Fact] public void Power_shell_dynamic_child_stays_dynamic() { - var analysis = _analyzer.Analyze( + var analyzer = new ShellCommandAnalyzer(PowerShellEnvironment); + var analysis = analyzer.Analyze( "pwsh -NoProfile -NonInteractive -Command 'git $operation'", - "/work"); + @"C:\work"); Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); Assert.True( @@ -164,19 +157,20 @@ public void Power_shell_dynamic_child_stays_dynamic() string.Join(" | ", analysis.Commands.Select(command => $"{command.Clause.Verb.Joined}:{command.IsComplete}:" + string.Join(",", command.Clause.Args.Select(arg => $"{arg.Raw}={arg.Kind}"))))); - Assert.Equal("pwsh", analysis.Commands[0].Clause.Verb.Joined); + Assert.Equal("git", analysis.Commands[0].Clause.Verb.Joined); } [Fact] public void Power_shell_proved_execution_region_is_complete() { - var analysis = _analyzer.Analyze( + var analyzer = new ShellCommandAnalyzer(PowerShellEnvironment); + var analysis = analyzer.Analyze( "pwsh -NoProfile -NonInteractive -Command '& { git push }'", - "/work"); + @"C:\work"); Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); Assert.Equal( - ["pwsh", "git push"], + ["git push"], analysis.Commands.Select(command => command.Clause.Verb.Joined)); Assert.False( analysis.HasDynamicSyntax, @@ -187,6 +181,22 @@ public void Power_shell_proved_execution_region_is_complete() $"args={string.Join(',', command.Clause.Args.Select(arg => $"{arg.Raw}/{arg.Kind}/{arg.Resolved}"))}"))); } + [Fact] + public void Power_shell_treats_bash_as_an_ordinary_external_command() + { + var analyzer = new ShellCommandAnalyzer(PowerShellEnvironment); + var analysis = analyzer.Analyze( + "bash -c 'Remove-Item victim.txt'", + @"C:\work"); + + Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); + var occurrence = Assert.Single(analysis.Commands); + Assert.Equal("bash", occurrence.Clause.Verb.Joined); + Assert.DoesNotContain( + analysis.Commands, + command => command.Clause.Verb.Joined == "Remove-Item"); + } + [Fact] public void Command_inspection_option_is_not_treated_as_transparent_shell_dispatch() { @@ -348,7 +358,7 @@ public void Finite_here_string_data_for_argument_free_cat_is_not_dynamic() ], IsComplete = true }; - var analysis = new ShellCommandAnalysis([occurrence], ShellAnalysisFailure.None); + var analysis = CreateAnalysis(occurrence); Assert.False(analysis.HasDynamicSyntax); } @@ -379,7 +389,7 @@ public void Malformed_stdin_facts_for_cat_fail_closed(RedirectAnalysis redirect) Redirects = [redirect], IsComplete = true }; - var analysis = new ShellCommandAnalysis([occurrence], ShellAnalysisFailure.None); + var analysis = CreateAnalysis(occurrence); Assert.True(analysis.HasDynamicSyntax); } @@ -415,7 +425,7 @@ public void Malformed_or_future_redirect_facts_fail_closed(RedirectAnalysis redi Redirects = [redirect], IsComplete = true }; - var analysis = new ShellCommandAnalysis([occurrence], ShellAnalysisFailure.None); + var analysis = CreateAnalysis(occurrence); Assert.True(analysis.HasDynamicSyntax); } @@ -440,7 +450,7 @@ public void Unknown_ancestry_fails_closed() ], IsComplete = true }; - var analysis = new ShellCommandAnalysis([occurrence], ShellAnalysisFailure.None); + var analysis = CreateAnalysis(occurrence); Assert.True(analysis.HasDynamicSyntax); } @@ -462,7 +472,7 @@ public void Unsupported_working_directory_domain_fails_closed( WorkingDirectory = new ShellValueDomain { Kind = workingDirectoryKind }, IsComplete = true }; - var analysis = new ShellCommandAnalysis([occurrence], ShellAnalysisFailure.None); + var analysis = CreateAnalysis(occurrence); Assert.True(analysis.HasDynamicSyntax); } @@ -648,6 +658,14 @@ public void Unsupported_working_directory_domain_fails_closed( }) }; + private static ShellCommandAnalysis CreateAnalysis(CommandOccurrence occurrence) + => new( + BashEnvironment, + source: string.Empty, + workingDirectory: null, + commands: [occurrence], + ShellAnalysisFailure.None); + private static RedirectAnalysis HereDocumentRedirect( HereDocumentAnalysis? hereDocument, ShellValueDomain? target = null) diff --git a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs index fda565f3b..7d159694c 100644 --- a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs +++ b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using ShellSyntaxTree; using Xunit; namespace Netclaw.Security.Tests; @@ -132,15 +133,81 @@ public void Denies_bourne_shell_wrapping_denied_command(string command) } [Fact] - public void Denies_power_shell_child_hard_deny_command() + public void Bash_does_not_interpret_power_shell_child_source() { var decision = _policy.EvaluateBash( "pwsh -NoProfile -NonInteractive -Command 'netclaw daemon stop'"); + Assert.True(decision.Allowed); + } + + [Fact] + public void Native_power_shell_denies_same_language_child_hard_deny_command() + { + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7); + var policy = new ShellCommandPolicy(environment); + + var decision = policy.Evaluate( + "pwsh -NoProfile -Command 'netclaw daemon stop'", + @"C:\work"); + Assert.False(decision.Allowed); Assert.Equal(DenyCategory.SelfDestructive, decision.DenyCategory); } + [Theory] + [InlineData("Start-Process pwsh -Verb RunAs")] + [InlineData("Start-Process pwsh -Ve RunAs")] + [InlineData("Start-Process pwsh -V 'RunAs'")] + [InlineData("Start-Process pwsh -Verb:\"RunAs\"")] + [InlineData("saps pwsh -Verb RunAs")] + public void Native_power_shell_denies_elevation_parameter_forms(string command) + { + var policy = PowerShellPolicy(); + + var decision = policy.Evaluate(command, @"C:\work"); + + Assert.False(decision.Allowed); + Assert.Equal(DenyCategory.PrivilegeEscalation, decision.DenyCategory); + } + + [Fact] + public void Native_power_shell_does_not_treat_verbose_as_the_verb_parameter() + { + var decision = PowerShellPolicy().Evaluate( + "Start-Process pwsh -Verbose RunAs", + @"C:\work"); + + Assert.True(decision.Allowed); + } + + [Theory] + [InlineData(@"Remove-Item C:\ -Recurse")] + [InlineData(@"Remove-Item 'C:\' -Re")] + [InlineData(@"Remove-Item -LiteralPath FileSystem::C:\ -R -Confirm:$false")] + [InlineData(@"Remove-Item -Path:C:\ -Recurse")] + [InlineData(@"ri C:\ -Recurse")] + public void Native_power_shell_denies_recursive_root_removal_without_force(string command) + { + var decision = PowerShellPolicy().Evaluate(command, @"C:\work"); + + Assert.False(decision.Allowed); + Assert.Equal(DenyCategory.SystemDestructive, decision.DenyCategory); + } + + [Theory] + [InlineData(@"Remove-Item C:\ -Force")] + [InlineData(@"Remove-Item C:\ -Recurse:$false -Force")] + public void Native_power_shell_does_not_categorically_deny_non_recursive_root_removal( + string command) + { + var decision = PowerShellPolicy().Evaluate(command, @"C:\work"); + + Assert.True(decision.Allowed); + } + [Fact] public void Allows_bash_c_wrapping_safe_command() { @@ -179,4 +246,9 @@ public void Custom_pattern_does_not_affect_unrelated_commands() var decision = policy.Evaluate("docker build -t myapp ."); Assert.True(decision.Allowed); } + + private static ShellCommandPolicy PowerShellPolicy() + => new(ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7)); } diff --git a/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs b/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs index 034dff80f..93c1324df 100644 --- a/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs +++ b/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs @@ -32,7 +32,26 @@ public void Parser_resolves_through_DI_registration() using var provider = services.BuildServiceProvider(); var parser = provider.GetRequiredService(); - Assert.IsType(parser); + var result = parser.Parse("git status"); + Assert.False(result.IsUnparseable); + Assert.Equal("git status", Assert.Single(result.Commands).Clause.Verb.Joined); + } + + [Fact] + public void Explicit_environment_DI_registration_uses_selected_power_shell_dialect() + { + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7); + var services = new ServiceCollection(); + services.AddShellParser(environment); + + using var provider = services.BuildServiceProvider(); + var parser = provider.GetRequiredService(); + + var result = parser.Parse("Get-ChildItem && Get-Content .\\input.txt"); + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Commands.Count); } [Fact] diff --git a/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs b/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs index 9fd56902e..2f048cd7f 100644 --- a/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs +++ b/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs @@ -1,8 +1,9 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using ShellSyntaxTree; using Xunit; namespace Netclaw.Security.Tests; @@ -66,16 +67,16 @@ public void CommandReferencesDeniedPath_allows_safe_commands() Assert.False(policy.CommandReferencesDeniedPath("echo hello")); } - [SlopwatchSuppress("SW001", "This regression verifies POSIX Bash decoding before PowerShell child path policy.")] - [Fact(SkipUnless = nameof(IsPosix), Skip = "The PowerShell child wrapper requires the POSIX Bash host.")] - public void CommandReferencesDeniedPath_checks_decoded_power_shell_child_path() + [Fact] + public void CommandReferencesDeniedPath_checks_native_power_shell_path() { - var policy = new ToolPathPolicy(["/protected/config"]); - const string command = - "pwsh -NoProfile -NonInteractive -Command 'Get-Content /protected/con\"fig/file.txt\"'"; + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7); + var policy = new ToolPathPolicy(environment, [@"C:\protected\config"]); + const string command = @"Get-Content C:\protected\config\file.txt"; - Assert.DoesNotContain("/protected/config", command, StringComparison.Ordinal); - Assert.True(policy.CommandReferencesDeniedPath(command, "/work")); + Assert.True(policy.CommandReferencesDeniedPath(command, @"C:\work")); } [Fact] @@ -291,49 +292,49 @@ public void IsReadDenied_blocks_symlinked_directory_traversal(SymlinkTraversalSh switch (shape) { case SymlinkTraversalShape.SingleSymlinkedDirectory: - { - var linkDir = Path.Combine(scratch, "link"); - Directory.CreateSymbolicLink(linkDir, deniedDir); - createdLinks.Add(linkDir); - - // Lexically this path lives in scratch/link, outside any - // denied root — only segment-walk symlink resolution - // catches it. - viaLink = Path.Combine(linkDir, "netclaw.json"); - break; - } + { + var linkDir = Path.Combine(scratch, "link"); + Directory.CreateSymbolicLink(linkDir, deniedDir); + createdLinks.Add(linkDir); + + // Lexically this path lives in scratch/link, outside any + // denied root — only segment-walk symlink resolution + // catches it. + viaLink = Path.Combine(linkDir, "netclaw.json"); + break; + } case SymlinkTraversalShape.MultiDepthSymlinkChain: - { - // linkA -> linkB -> deniedDir. A resolver that only - // follows one hop would stop at linkB; the walk must - // reach the final real target. - var linkB = Path.Combine(scratch, "linkB"); - var linkA = Path.Combine(scratch, "linkA"); - Directory.CreateSymbolicLink(linkB, deniedDir); - Directory.CreateSymbolicLink(linkA, linkB); - createdLinks.Add(linkB); - createdLinks.Add(linkA); - - viaLink = Path.Combine(linkA, "netclaw.json"); - break; - } + { + // linkA -> linkB -> deniedDir. A resolver that only + // follows one hop would stop at linkB; the walk must + // reach the final real target. + var linkB = Path.Combine(scratch, "linkB"); + var linkA = Path.Combine(scratch, "linkA"); + Directory.CreateSymbolicLink(linkB, deniedDir); + Directory.CreateSymbolicLink(linkA, linkB); + createdLinks.Add(linkB); + createdLinks.Add(linkA); + + viaLink = Path.Combine(linkA, "netclaw.json"); + break; + } case SymlinkTraversalShape.DotDotTraversalAfterResolvedLink: - { - var linkDir = Path.Combine(scratch, "link"); - Directory.CreateSymbolicLink(linkDir, deniedDir); - createdLinks.Add(linkDir); - - // "nested" need not exist: Path.GetFullPath collapses the - // ".." lexically before any symlink is resolved, leaving - // "link/netclaw.json" — the link segment itself survives - // the collapse untouched, so resolution still lands - // inside deniedDir. Locks in that a decoy ".." placed - // after the link cannot be used to dodge the walk. - viaLink = Path.Combine(linkDir, "nested", "..", "netclaw.json"); - break; - } + { + var linkDir = Path.Combine(scratch, "link"); + Directory.CreateSymbolicLink(linkDir, deniedDir); + createdLinks.Add(linkDir); + + // "nested" need not exist: Path.GetFullPath collapses the + // ".." lexically before any symlink is resolved, leaving + // "link/netclaw.json" — the link segment itself survives + // the collapse untouched, so resolution still lands + // inside deniedDir. Locks in that a decoy ".." placed + // after the link cannot be used to dodge the walk. + viaLink = Path.Combine(linkDir, "nested", "..", "netclaw.json"); + break; + } default: throw new ArgumentOutOfRangeException(nameof(shape), shape, null); diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index bc0a3082c..d767d4078 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -101,19 +101,34 @@ bool IsApproved( } /// -/// Shell-specific approval matcher. Verb-chain extraction stops at the first -/// flag, path, or URL token; && / || / ; split -/// approval units while | stays inside one unit; bash -c / -/// sh -c wrappers recurse into the inner command. +/// Shell-specific approval matcher bound to one canonical grammar. Approval +/// units and same-language child occurrences come from the selected +/// ShellSyntaxTree parser; unresolved syntax never creates a persistent grant. /// +public sealed record ShellApprovalAnalysis( + IReadOnlyList Patterns, + IReadOnlyList Candidates, + string DisplayText, + bool IsMessy); + public sealed class ShellApprovalMatcher : IToolApprovalMatcher { public static readonly ShellApprovalMatcher Instance = new(); - private static readonly ShellCommandAnalyzer Analyzer = ShellCommandAnalyzer.Bash; + private readonly ShellCommandAnalyzer _analyzer; + + public ShellApprovalMatcher() + : this(ShellExecutionEnvironmentDefaults.Bash) + { + } + + public ShellApprovalMatcher(ShellExecutionEnvironment environment) + { + Environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _analyzer = new ShellCommandAnalyzer(environment); + } - private static readonly char[] WindowsCommandTokenSeparators = - [' ', '\t', '\r', '\n', '\'', '"', '&', '|', '(', ')', '<', '>', ';', '=', ',']; + public ShellExecutionEnvironment Environment { get; } public string GetApprovalModeKey(ToolName toolName, IDictionary? arguments) => toolName.Value; @@ -122,42 +137,37 @@ public bool IsFailClosedOnPersonal(ToolName toolName, IDictionary true; public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary? arguments) + => AnalyzeInvocation(toolName, arguments).Patterns; + + public ShellApprovalAnalysis AnalyzeInvocation( + ToolName toolName, + IDictionary? arguments, + ShellCommandAnalysis? analysis = null) { var command = GetCommand(arguments); if (string.IsNullOrWhiteSpace(command)) - return []; + return new ShellApprovalAnalysis([], [], "(empty command)", IsMessy: false); var workingDirectory = GetWorkingDirectory(arguments); - var patterns = new HashSet(StringComparer.OrdinalIgnoreCase); + analysis ??= _analyzer.Analyze(command, workingDirectory); + ValidateAnalysis(analysis, command, workingDirectory); - // POSIX commands route through BashParser so the approval units - // match the clause decomposition ExtractCandidates already uses — in - // particular a bare newline separates statements, so a multi-line - // command yields one unit per statement. Windows keeps the legacy - // ShellTokenizer path — ShellSyntaxTree is bash-only. - if (!OperatingSystem.IsWindows()) - { - foreach (var unit in ExtractApprovalUnitsViaBashAnalysis(command, workingDirectory)) - { - var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory); - if (!string.IsNullOrEmpty(normalized)) - patterns.Add(normalized); - } - - return patterns.ToList(); - } - - if (ContainsUnanalyzedPowerShellHost(command)) - return []; - - TraverseApprovalUnits(command, unit => + var patterns = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var unit in ExtractApprovalUnitsViaAnalysis(analysis)) { - var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory); + var normalized = ShellTokenizer.NormalizeApprovalUnit( + unit, + workingDirectory, + Environment.PathStyle); if (!string.IsNullOrEmpty(normalized)) patterns.Add(normalized); - }); + } - return patterns.ToList(); + return new ShellApprovalAnalysis( + patterns.ToList(), + ExtractCandidatesViaAnalysis(analysis), + FormatForDisplay(command, analysis), + IsMessy(analysis)); } public IReadOnlyList ExtractCandidateVerbs(ToolName toolName, IDictionary? arguments) @@ -167,62 +177,39 @@ public IReadOnlyList ExtractCandidateVerbs(ToolName toolName, IDictionar .ToList(); public IReadOnlyList ExtractCandidates(ToolName toolName, IDictionary? arguments) - { - var command = GetCommand(arguments); - if (string.IsNullOrWhiteSpace(command)) - return []; - - // POSIX commands route through BashParser so we pick up the parser's - // cd-in-compound cwd attribution. The parser walks `cd X && verb`, - // `bash -c "cd X && verb"`, and multi-step `cd A && cd B && verb` - // chains; the candidate's directory inherits the latest cd target - // when the clause itself has no anchored path arg. Windows keeps - // the legacy ShellTokenizer path — ShellSyntaxTree is bash-only. - if (!OperatingSystem.IsWindows()) - return ExtractCandidatesViaBashAnalysis(command, GetWorkingDirectory(arguments)); - - if (ContainsUnanalyzedPowerShellHost(command)) - return []; + => AnalyzeInvocation(toolName, arguments).Candidates; - var seen = new HashSet<(string, string?)>(); - var candidates = new List(); - TraverseApprovalUnits(command, unit => - { - var verb = ShellTokenizer.ExtractVerbChain(unit); - if (string.IsNullOrEmpty(verb)) - return; - - var directory = ShellTokenizer.ExtractFirstPathArgument(unit); - var key = (verb.ToLowerInvariant(), directory); - if (seen.Add(key)) - candidates.Add(new ApprovalCandidate(verb, directory)); - }); - - return candidates; - } - - /// - /// Returns null when the parser cannot resolve the complete command. - /// Callers then offer only one-time approval or show the raw command. - /// - private static ShellCommandAnalysis? TryAnalyzeCommand( + private void ValidateAnalysis( + ShellCommandAnalysis analysis, string command, - string? workingDirectory = null) + string? workingDirectory) { - var result = Analyzer.Analyze(command, workingDirectory); - return result.Failure == ShellAnalysisFailure.None && result.Commands.Count > 0 - ? result - : null; + if (!ReferenceEquals(analysis.Environment, Environment)) + throw new ArgumentException( + "The command analysis belongs to another shell environment.", + nameof(analysis)); + if (!string.Equals(analysis.Source, command, StringComparison.Ordinal) + || !string.Equals( + analysis.WorkingDirectory, + workingDirectory, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "The command analysis does not match the submitted source and working directory.", + nameof(analysis)); + } } - private static IReadOnlyList ExtractCandidatesViaBashAnalysis( - string command, - string? workingDirectory) + private IReadOnlyList ExtractCandidatesViaAnalysis( + ShellCommandAnalysis result) { - var result = TryAnalyzeCommand(command, workingDirectory); - if (result is null || result.HasDynamicSyntax) + if (!result.IsResolved + || result.HasDynamicSyntax + || HasUnscopedPowerShellProviderOperand(result)) return []; + var workingDirectory = result.WorkingDirectory; + // The prompt groups a pipe as one approval unit. Authorization still // checks each clause so an unsafe tail cannot hide behind a safe head. var seen = new HashSet<(string, string?)>(); @@ -254,7 +241,8 @@ private static IReadOnlyList ExtractCandidatesViaBashAnalysis var directories = ResolveCommandDirectories( occurrence, isSideEffectVerb, - workingDirectory); + workingDirectory, + Environment.PathStyle); if (directories is null) return []; @@ -272,7 +260,8 @@ private static IReadOnlyList ExtractCandidatesViaBashAnalysis private static IReadOnlyList? ResolveCommandDirectories( ShellSyntaxTree.CommandOccurrence occurrence, bool isSideEffectVerb, - string? workingDirectory) + string? workingDirectory, + ShellPathStyle pathStyle) { var clause = occurrence.Clause; var directories = new List(); @@ -290,12 +279,16 @@ private static IReadOnlyList ExtractCandidatesViaBashAnalysis { foreach (var arg in clause.Args) { - if (arg.IsCwdAttribution || !IsAuthorizationPathArg(arg, clauseWorkingDirectory)) + if (arg.IsCwdAttribution + || !IsAuthorizationPathArg(arg, clauseWorkingDirectory, pathStyle)) continue; if (arg.Kind == ShellSyntaxTree.ArgKind.Glob) { - var coveringDirectory = ResolveGlobCoveringDirectory(arg, clauseWorkingDirectory); + var coveringDirectory = ResolveGlobCoveringDirectory( + arg, + clauseWorkingDirectory, + pathStyle); if (coveringDirectory is null) return null; @@ -308,13 +301,13 @@ private static IReadOnlyList ExtractCandidatesViaBashAnalysis if (string.IsNullOrWhiteSpace(arg.Resolved)) return null; - directories.Add(ShellTokenizer.ApplyFileParentRule(arg.Resolved)); + directories.Add(ShellTokenizer.ApplyFileParentRule(arg.Resolved, pathStyle)); } } foreach (var redirect in occurrence.Redirects) { - var redirectDirectories = ResolveRedirectDirectories(redirect); + var redirectDirectories = ResolveRedirectDirectories(redirect, pathStyle); if (redirectDirectories is null) return null; @@ -356,9 +349,10 @@ private static IReadOnlyList ExtractCandidatesViaBashAnalysis private static string? ResolveGlobCoveringDirectory( ShellSyntaxTree.Arg arg, - string? workingDirectory) + string? workingDirectory, + ShellPathStyle pathStyle) { - if (ShellGlobPath.HasUnresolvedDescendantScope(arg)) + if (ShellGlobPath.HasUnresolvedDescendantScope(arg, pathStyle)) return null; var path = arg.Raw.Trim(); @@ -384,15 +378,15 @@ private static IReadOnlyList ExtractCandidatesViaBashAnalysis return null; var staticPrefix = path[..firstGlob]; - var separator = staticPrefix.LastIndexOf('/'); - var coveringPath = separator switch - { - < 0 => ".", - 0 => "/", - _ => staticPrefix[..separator] - }; - - var coveringDirectory = ShellTokenizer.NormalizePathToken(coveringPath, workingDirectory); + var separator = pathStyle == ShellPathStyle.Windows + ? staticPrefix.LastIndexOfAny(['/', '\\']) + : staticPrefix.LastIndexOf('/'); + var coveringPath = CoveringPath(staticPrefix, separator, pathStyle); + + var coveringDirectory = ShellTokenizer.NormalizePathToken( + coveringPath, + workingDirectory, + pathStyle); if (coveringDirectory is null || ContainsSymlinkEntry(coveringDirectory)) { @@ -402,6 +396,27 @@ private static IReadOnlyList ExtractCandidatesViaBashAnalysis return coveringDirectory; } + private static string CoveringPath( + string staticPrefix, + int separator, + ShellPathStyle pathStyle) + { + if (separator < 0) + return "."; + if (separator == 0) + return staticPrefix[..1]; + if (pathStyle == ShellPathStyle.Windows + && separator == 2 + && staticPrefix.Length >= 3 + && char.IsAsciiLetter(staticPrefix[0]) + && staticPrefix[1] == ':') + { + return staticPrefix[..3]; + } + + return staticPrefix[..separator]; + } + private static bool ContainsSymlinkEntry(string directory) { if (!Directory.Exists(directory)) @@ -433,7 +448,8 @@ or UnauthorizedAccessException private static bool IsAuthorizationPathArg( ShellSyntaxTree.Arg arg, - string? workingDirectory) + string? workingDirectory, + ShellPathStyle pathStyle) { if (!arg.IsPath) return false; @@ -446,7 +462,10 @@ private static bool IsAuthorizationPathArg( return false; } - if (ShellTokenizer.IsPathToken(arg.Raw) || !arg.Raw.Contains('/', StringComparison.Ordinal)) + var containsSeparator = pathStyle == ShellPathStyle.Windows + ? arg.Raw.IndexOfAny(['/', '\\']) >= 0 + : arg.Raw.Contains('/', StringComparison.Ordinal); + if (ShellTokenizer.IsPathToken(arg.Raw) || !containsSeparator) return true; // An internal slash can also name a ref such as feature/x. Native @@ -522,7 +541,8 @@ or UnauthorizedAccessException : null; private static IReadOnlyList? ResolveRedirectDirectories( - ShellSyntaxTree.RedirectAnalysis redirect) + ShellSyntaxTree.RedirectAnalysis redirect, + ShellPathStyle pathStyle) { if (!redirect.IsPathRelevant) return []; @@ -552,48 +572,96 @@ or UnauthorizedAccessException if (string.IsNullOrWhiteSpace(target)) return null; - try - { - var normalizedTarget = PathUtility.Normalize(target); - var pathRoot = Path.GetPathRoot(normalizedTarget); - if (string.IsNullOrWhiteSpace(pathRoot) - || PathUtility.ContainsSymlinkSegment(pathRoot, normalizedTarget)) - { - return null; - } + var directory = GetRedirectDirectory(target, pathStyle); + if (directory is null) + return null; - directories.Add(Path.GetDirectoryName(normalizedTarget) ?? normalizedTarget); - } - catch (Exception ex) when (ex is ArgumentException - or IOException - or NotSupportedException - or PathTooLongException - or UnauthorizedAccessException - or System.Security.SecurityException) - { + if (UsesHostPathStyle(pathStyle) && HasUnsafeHostPath(target)) return null; - } + + directories.Add(directory); } return directories; } + private static string? GetRedirectDirectory(string target, ShellPathStyle pathStyle) + { + if (!IsRootedForPathStyle(target, pathStyle)) + return null; + + var separator = pathStyle == ShellPathStyle.Windows + ? target.LastIndexOfAny(['/', '\\']) + : target.LastIndexOf('/'); + if (separator < 0) + return null; + if (separator == 0) + return target[..1]; + if (pathStyle == ShellPathStyle.Windows + && separator == 2 + && char.IsAsciiLetter(target[0]) + && target[1] == ':') + { + return target[..3]; + } + + return target[..separator]; + } + + private static bool IsRootedForPathStyle(string path, ShellPathStyle pathStyle) + => pathStyle switch + { + ShellPathStyle.Posix => path.Length > 0 && path[0] == '/', + ShellPathStyle.Windows => (path.Length >= 3 + && char.IsAsciiLetter(path[0]) + && path[1] == ':' + && path[2] is '/' or '\\') + || (path.Length >= 5 + && path[0] is '/' or '\\' + && path[1] is '/' or '\\'), + _ => false + }; + + private static bool UsesHostPathStyle(ShellPathStyle pathStyle) + => pathStyle == ShellPathStyle.Windows + ? OperatingSystem.IsWindows() + : !OperatingSystem.IsWindows(); + + private static bool HasUnsafeHostPath(string target) + { + try + { + var pathRoot = Path.GetPathRoot(target); + return string.IsNullOrWhiteSpace(pathRoot) + || PathUtility.ContainsSymlinkSegment(pathRoot, target); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException + or PathTooLongException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + return true; + } + } + /// - /// Splits a POSIX command into approval-unit strings via BashParser: + /// Splits the environment-bound command analysis into approval-unit strings: /// one unit per statement, with consecutive | clauses folded into /// the same unit so cat x | wc -l stays a single decision. /// Returns an empty list for messy, unparseable, or parser-rejected - /// commands — mirroring the legacy + /// commands. This result matches the legacy /// empty-result contract so the prompt builder offers only Once/Deny. /// - private static IReadOnlyList ExtractApprovalUnitsViaBashAnalysis( - string command, - string? workingDirectory) + private IReadOnlyList ExtractApprovalUnitsViaAnalysis( + ShellCommandAnalysis result) { // The parser is the sole structural authority. Dynamic or unresolved // syntax cannot produce a persistent approval unit. - var result = TryAnalyzeCommand(command, workingDirectory); - if (result is null || result.HasDynamicSyntax) + if (!result.IsResolved + || result.HasDynamicSyntax + || HasUnscopedPowerShellProviderOperand(result)) return []; try @@ -780,7 +848,7 @@ private static bool IsCallSpecificValueToken(string token) /// internal whitespace, stays in the pattern, and normalizes the same as /// its unquoted form. /// A path arg is exempt. Its directory is authorization state that - /// resolves separately from + /// candidate extraction resolves separately from /// the same parsed Arg, so a quoted path with a space /// (cat "my file.txt") keeps its scope. Only a value operand, never /// a path, drops here. @@ -879,26 +947,24 @@ public bool IsApproved( } public bool IsMessy(ToolName toolName, IDictionary? arguments) - { - var command = GetCommand(arguments); - if (string.IsNullOrWhiteSpace(command)) - return false; + => AnalyzeInvocation(toolName, arguments).IsMessy; - if (OperatingSystem.IsWindows()) - { - return ContainsUnanalyzedPowerShellHost(command) - || ShellTokenizer.IsMessyCompoundCommand(command); - } - - var workingDirectory = GetWorkingDirectory(arguments); - var analysis = TryAnalyzeCommand(command, workingDirectory); - if (analysis is null || analysis.HasDynamicSyntax) + private bool IsMessy(ShellCommandAnalysis analysis) + { + if (!analysis.IsResolved + || analysis.HasDynamicSyntax + || HasUnscopedPowerShellProviderOperand(analysis)) return true; + var workingDirectory = analysis.WorkingDirectory; + if (analysis.Commands .SelectMany(static command => command.Clause.Args) .Where(static arg => arg.IsPath && arg.Kind == ShellSyntaxTree.ArgKind.Glob) - .Any(arg => ResolveGlobCoveringDirectory(arg, workingDirectory) is null)) + .Any(arg => ResolveGlobCoveringDirectory( + arg, + workingDirectory, + Environment.PathStyle) is null)) { return true; } @@ -907,26 +973,61 @@ public bool IsMessy(ToolName toolName, IDictionary? arguments) ResolveCommandDirectories( command, IsSideEffectCommand(command), - workingDirectory) is null)) + workingDirectory, + Environment.PathStyle) is null)) { return true; } + return false; + } + + private bool HasUnscopedPowerShellProviderOperand(ShellCommandAnalysis analysis) + { + if (Environment.Grammar != ShellGrammar.PowerShell) + return false; + return analysis.Commands - .SelectMany(static command => command.Redirects) - .Any(static redirect => ResolveRedirectDirectories(redirect) is null); + .SelectMany(static occurrence => occurrence.Clause.Args) + .Any(static arg => LooksLikeNonFileSystemProviderPath(arg.Raw)); } - internal static bool ContainsUnanalyzedPowerShellHost(string command) - // cmd.exe can place a complete child command inside quotes and can - // escape command-name characters with a caret. The legacy tokenizer - // does not model those rules, so use conservative word detection. - => command.Replace("^", string.Empty, StringComparison.Ordinal) - .Replace("\"", string.Empty, StringComparison.Ordinal) - .Split( - WindowsCommandTokenSeparators, - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Any(ShellCommandAnalyzer.IsPowerShellHostLike); + private static bool LooksLikeNonFileSystemProviderPath(string raw) + { + var value = raw.Trim(); + if (value.Length >= 2 && value[0] is '\'' or '"' && value[^1] == value[0]) + value = value[1..^1]; + + var providerSeparator = value.IndexOf("::", StringComparison.Ordinal); + if (providerSeparator > 0) + { + var provider = value[..providerSeparator]; + return !provider.Equals("FileSystem", StringComparison.OrdinalIgnoreCase) + && !provider.EndsWith( + "\\FileSystem", + StringComparison.OrdinalIgnoreCase); + } + + var colon = value.IndexOf(':', StringComparison.Ordinal); + if (colon <= 1 || !char.IsAsciiLetter(value[0])) + return false; + + // URI schemes are data operands, not PowerShell provider drives. + if (value.Length > colon + 2 + && value[colon + 1] == '/' + && value[colon + 2] == '/') + { + return false; + } + + for (var i = 1; i < colon; i++) + { + if (!char.IsAsciiLetterOrDigit(value[i]) && value[i] is not ('_' or '-')) + return false; + } + + return true; + } private static bool IsSideEffectCommand(ShellSyntaxTree.CommandOccurrence occurrence) { @@ -938,11 +1039,12 @@ private static bool IsSideEffectCommand(ShellSyntaxTree.CommandOccurrence occurr } public string FormatForDisplay(ToolName toolName, IDictionary? arguments) - { - var command = GetCommand(arguments); - if (string.IsNullOrWhiteSpace(command)) - return "(empty command)"; + => AnalyzeInvocation(toolName, arguments).DisplayText; + private string FormatForDisplay( + string command, + ShellCommandAnalysis analysis) + { // Fast path: a command with no embedded line break renders verbatim. if (!ContainsLineBreak(command)) return command; @@ -950,15 +1052,13 @@ public string FormatForDisplay(ToolName toolName, IDictionary? // Issue #1402: channel renderers embed DisplayText in single-line // code fences, so a multi-line quoted string (a message body, an // inline script) dumped verbatim corrupts the approval prompt. On - // POSIX, rebuild a one-line view from the parse tree with multi-line - // args summarized by size; Windows flattens — ShellSyntaxTree is - // bash-only. Heredoc and here-string fallbacks encode line breaks + // either grammar, rebuild a one-line view from the environment-bound + // parse tree with multi-line args summarized by size. Heredoc and + // here-string fallbacks encode line breaks // before the trailing replacement so command boundaries stay visible. // The trailing replacement catches any other leaked line breaks and // collapses CRLF to a single space. - var display = OperatingSystem.IsWindows() - ? command - : BuildSanitizedDisplayViaParser(command); + var display = BuildSanitizedDisplayViaParser(command, analysis); return display.ReplaceLineEndings(" "); } @@ -977,10 +1077,11 @@ public string FormatForDisplay(ToolName toolName, IDictionary? /// misstate which statements a pipe or && guard applies /// to. The raw fallback is ugly but fully disclosed. /// - private static string BuildSanitizedDisplayViaParser(string command) + private static string BuildSanitizedDisplayViaParser( + string command, + ShellCommandAnalysis result) { - var result = TryAnalyzeCommand(command); - if (result is null) + if (!result.IsResolved) return command; if (result.Commands.Any(static occurrence => @@ -1090,25 +1191,6 @@ private static string SummarizeMultilineArg(string raw) private static string? GetWorkingDirectory(IDictionary? arguments) => ToolArgumentHelper.GetString(arguments, "WorkingDirectory"); - private static void TraverseApprovalUnits(string command, Action visitUnit) - { - // Approval units recurse through shell wrappers but keep the outer - // splitting rules stable, so `bash -c "grep ... | wc -l" && git push` - // still becomes two independent approval decisions. - foreach (var segment in ShellTokenizer.SplitCompoundCommand(command)) - { - var innerCommands = ShellTokenizer.ExtractInnerCommands(segment); - if (innerCommands.Count > 0) - { - foreach (var inner in innerCommands) - TraverseApprovalUnits(inner, visitUnit); - - continue; - } - - visitUnit(segment); - } - } } /// diff --git a/src/Netclaw.Security/SecurityServiceExtensions.cs b/src/Netclaw.Security/SecurityServiceExtensions.cs index 1530ad79e..988ae10c1 100644 --- a/src/Netclaw.Security/SecurityServiceExtensions.cs +++ b/src/Netclaw.Security/SecurityServiceExtensions.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -31,13 +31,26 @@ public static IServiceCollection AddContentSecurity(this IServiceCollection serv } /// - /// Registers for the approval gate evaluator. - /// The bash implementation is the only one shipped today; PowerShell and - /// cmd parsers are deferred to ShellSyntaxTree v0.2+. + /// Registers the compatibility Bash parser used by hosts that have not yet + /// supplied an explicit shell environment. /// public static IServiceCollection AddShellParser(this IServiceCollection services) + => services.AddShellParser(ShellExecutionEnvironmentDefaults.Bash); + + /// + /// Registers a parser adapter bound to the daemon's canonical shell environment. + /// + public static IServiceCollection AddShellParser( + this IServiceCollection services, + ShellExecutionEnvironment environment) { - services.AddSingleton(); + ArgumentNullException.ThrowIfNull(environment); + services.AddSingleton(new EnvironmentShellParser(environment)); return services; } + + private sealed class EnvironmentShellParser(ShellExecutionEnvironment environment) : IShellParser + { + public ParsedCommand Parse(string source) => environment.Parse(source); + } } diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs index 49cb1fa43..d33779768 100644 --- a/src/Netclaw.Security/ShellApprovalSemantics.cs +++ b/src/Netclaw.Security/ShellApprovalSemantics.cs @@ -30,9 +30,12 @@ internal static class ShellApprovalSemantics private static readonly IShellApprovalSemantics Posix = PosixShellApprovalSemantics.Instance; private static readonly IShellApprovalSemantics Windows = WindowsShellApprovalSemantics.Instance; - public static IShellApprovalSemantics Current { get; } = OperatingSystem.IsWindows() - ? Windows - : Posix; + public static IShellApprovalSemantics ForPathStyle(ShellPathStyle pathStyle) => pathStyle switch + { + ShellPathStyle.Posix => Posix, + ShellPathStyle.Windows => Windows, + _ => throw new ArgumentOutOfRangeException(nameof(pathStyle), pathStyle, "Unknown shell path style.") + }; public static IShellApprovalSemantics ForCommand(string? command) { @@ -481,8 +484,9 @@ internal sealed class WindowsShellApprovalSemantics : ShellApprovalSemanticsBase public static readonly WindowsShellApprovalSemantics Instance = new(); public override IReadOnlyList SplitCompoundCommand(string command) - // Windows approval splitting handles both cmd.exe control operators (`&`, `&&`, `||`) - // and PowerShell's `;` because nested PowerShell invocations are common under `cmd /c`. + // This legacy tokenizer preserves the historical Windows surface for + // public compatibility callers. Runtime authorization uses the selected + // PowerShell dialect through ShellCommandAnalyzer instead. => SplitCompoundCommand(command, splitOnSemicolon: true, splitOnSingleAmpersand: true); public override IReadOnlyList ExtractInnerCommands(string command) diff --git a/src/Netclaw.Security/ShellCommandAnalysis.cs b/src/Netclaw.Security/ShellCommandAnalysis.cs index 3469677f9..07dce5c16 100644 --- a/src/Netclaw.Security/ShellCommandAnalysis.cs +++ b/src/Netclaw.Security/ShellCommandAnalysis.cs @@ -8,27 +8,33 @@ namespace Netclaw.Security; /// -/// Parses Bash commands and expands nested shell command strings. +/// Parses commands with one canonical shell environment and expands the extra +/// bundled Bash wrapper forms that remain outside ShellSyntaxTree's contract. /// Approval and hard-deny policies share this analysis. /// internal sealed class ShellCommandAnalyzer { private const int MaxWrapperDepth = 8; + private readonly ShellExecutionEnvironment _environment; - public static readonly ShellCommandAnalyzer Bash = new(); - - private ShellCommandAnalyzer() + public ShellCommandAnalyzer(ShellExecutionEnvironment environment) { + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); } public ShellCommandAnalysis Analyze(string command, string? workingDirectory = null) { var commands = new List(); var failure = Analyze(command, workingDirectory, depth: 0, commands); - return new ShellCommandAnalysis(commands, failure); + return new ShellCommandAnalysis( + _environment, + command, + workingDirectory, + commands, + failure); } - private static ShellAnalysisFailure Analyze( + private ShellAnalysisFailure Analyze( string command, string? workingDirectory, int depth, @@ -39,16 +45,14 @@ private static ShellAnalysisFailure Analyze( // Stable v0.3 excludes background lists. Keep this guard until the // parser exposes their concurrency and shell-state boundaries. - if (ContainsBackgroundListOperator(command)) + if (_environment.Grammar == ShellGrammar.Bash + && ContainsBackgroundListOperator(command)) return ShellAnalysisFailure.Unresolved; ParsedCommand parsed; try { - var parser = string.IsNullOrWhiteSpace(workingDirectory) - ? new BashParser() - : new BashParser(new BashParserOptions { WorkingDirectory = workingDirectory }); - parsed = parser.Parse(command); + parsed = _environment.Parse(command, workingDirectory); } catch { @@ -58,19 +62,9 @@ private static ShellAnalysisFailure Analyze( if (parsed.IsUnparseable || parsed.Commands.Count == 0) return ShellAnalysisFailure.Unresolved; - if (ContainsPowerShellHost(parsed.Commands)) + if (_environment.Grammar == ShellGrammar.PowerShell) { - if (!TryAnalyzePowerShellChild( - command, - workingDirectory, - parsed.Commands, - out var childCommands)) - { - return ShellAnalysisFailure.Unresolved; - } - - commands.Add(parsed.Commands[0]); - commands.AddRange(childCommands); + commands.AddRange(parsed.Commands); return ShellAnalysisFailure.None; } @@ -117,131 +111,6 @@ private static ShellAnalysisFailure Analyze( return ShellAnalysisFailure.None; } - private static bool TryAnalyzePowerShellChild( - string source, - string? workingDirectory, - IReadOnlyList outerCommands, - out IReadOnlyList childCommands) - { - childCommands = []; - if (outerCommands.Count != 1) - return false; - - var outer = outerCommands[0]; - var clause = outer.Clause; - if (!outer.IsComplete - || clause.Operator != CompoundOperator.None - || clause.IsSubshell - || clause.IsCommandStringWrapped - || clause.Verb.IsDynamic - || clause.Redirects.Count != 0 - || outer.Redirects.Count != 0 - || clause.Elements.Count != 5 - || clause.Args.Any(static arg => arg.Kind == ArgKind.DynamicSkip) - || clause.Elements.Any(static element => - element.SourceStart is null - || element.SourceLength is null - || element.SourceLength < 0)) - { - return false; - } - - var elements = clause.Elements; - if (!string.Equals(elements[0].Value, "pwsh", StringComparison.Ordinal) - || !string.Equals(elements[1].Value, "-NoProfile", StringComparison.OrdinalIgnoreCase) - || !string.Equals(elements[2].Value, "-NonInteractive", StringComparison.OrdinalIgnoreCase) - || !string.Equals(elements[3].Value, "-Command", StringComparison.OrdinalIgnoreCase) - || elements[4].Kind != ArgKind.Literal - || string.IsNullOrWhiteSpace(elements[4].Value) - || string.Equals(elements[4].Value, "-", StringComparison.Ordinal) - || !HasExactSourceCoverage(source, elements) - || !HasStaticQuotedPayload(elements[4])) - { - return false; - } - - ParsedCommand parsed; - try - { - parsed = new PwshParser(new PwshParserOptions - { - WorkingDirectory = workingDirectory, - InitialStateMode = PwshInitialStateMode.Unknown - }).Parse(elements[4].Value); - } - catch - { - return false; - } - - if (parsed.IsUnparseable || parsed.Commands.Count == 0) - { - return false; - } - - childCommands = parsed.Commands - .Select(static occurrence => occurrence with - { - Clause = occurrence.Clause with { IsCommandStringWrapped = true } - }) - .ToList(); - return true; - } - - private static bool HasExactSourceCoverage( - string source, - IReadOnlyList elements) - { - foreach (var element in elements) - { - var start = element.SourceStart!.Value; - var length = element.SourceLength!.Value; - if (start < 0 - || start > source.Length - || length > source.Length - start - || !source.AsSpan(start, length).SequenceEqual(element.Raw.AsSpan())) - { - return false; - } - } - - var firstStart = elements[0].SourceStart!.Value; - var last = elements[^1]; - var lastEnd = last.SourceStart!.Value + last.SourceLength!.Value; - return string.IsNullOrWhiteSpace(source[..firstStart]) - && string.IsNullOrWhiteSpace(source[lastEnd..]); - } - - private static bool HasStaticQuotedPayload(ClauseElement payload) - { - var raw = payload.Raw; - if (raw.Length < 2 || raw[0] is not ('\'' or '"') || raw[^1] != raw[0]) - return false; - - if (raw[0] == '\'') - return true; - - var content = raw.AsSpan(1, raw.Length - 2); - return content.IndexOfAny('$', '`', '\\') < 0; - } - - private static bool ContainsPowerShellHost(IReadOnlyList commands) - => commands.Any(static command => command.Clause.Elements.Any(static element => - IsPowerShellHostLike(element.Value))); - - internal static bool IsPowerShellHostLike(string value) - { - var normalized = ShellTokenizer.TrimShellPunctuation(value); - var separator = Math.Max( - normalized.LastIndexOf('/'), - normalized.LastIndexOf('\\')); - var fileName = separator >= 0 ? normalized[(separator + 1)..] : normalized; - return fileName.Equals("pwsh", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("powershell", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase); - } - private static bool TryResolveWrapperWorkingDirectory( CommandOccurrence occurrence, string? inheritedWorkingDirectory, @@ -386,7 +255,9 @@ internal enum ShellAnalysisFailure internal static class ShellGlobPath { - public static bool HasUnresolvedDescendantScope(Arg arg) + public static bool HasUnresolvedDescendantScope( + Arg arg, + ShellPathStyle pathStyle) { if (!arg.IsPath || arg.Kind != ArgKind.Glob) return false; @@ -398,18 +269,44 @@ public static bool HasUnresolvedDescendantScope(Arg arg) // scope instead of degrading to a one-shot "complex command". A real // segment after the wildcard (foo/*/x, foo/*/*) keeps its separator and // stays unresolved. - var scope = arg.Raw.TrimEnd('/'); + var scope = pathStyle == ShellPathStyle.Windows + ? arg.Raw.TrimEnd('/', '\\') + : arg.Raw.TrimEnd('/'); var firstGlob = scope.IndexOfAny(['*', '?', '[']); - return firstGlob >= 0 - && scope.IndexOf('/', firstGlob + 1) >= 0; + if (firstGlob < 0) + return false; + + return pathStyle == ShellPathStyle.Windows + ? scope.AsSpan(firstGlob + 1).IndexOfAny('/', '\\') >= 0 + : scope.IndexOf('/', firstGlob + 1) >= 0; } } -internal sealed record ShellCommandAnalysis( - IReadOnlyList Commands, - ShellAnalysisFailure Failure) +public sealed record ShellCommandAnalysis { - public bool HasDynamicSyntax => Commands.Any(static command => + internal ShellCommandAnalysis( + ShellExecutionEnvironment environment, + string source, + string? workingDirectory, + IReadOnlyList commands, + ShellAnalysisFailure failure) + { + Environment = environment; + Source = source; + WorkingDirectory = workingDirectory; + Commands = commands; + Failure = failure; + } + + public string Source { get; } + + public string? WorkingDirectory { get; } + + public IReadOnlyList Commands { get; } + + public bool IsResolved => Failure == ShellAnalysisFailure.None && Commands.Count > 0; + + public bool HasDynamicSyntax => Commands.Any(command => !command.IsComplete || !Enum.IsDefined(command.ImmediateRole) || command.ImmediateRole == CommandOccurrenceRole.Unknown @@ -432,9 +329,14 @@ internal sealed record ShellCommandAnalysis( && string.IsNullOrWhiteSpace(arg.Resolved)) // A glob in a directory segment can hide traversal or a symlink. // Only a leaf glob has a fixed directory scope. - || command.Clause.Args.Any(ShellGlobPath.HasUnresolvedDescendantScope) + || command.Clause.Args.Any(arg => + ShellGlobPath.HasUnresolvedDescendantScope(arg, Environment.PathStyle)) || HasUnresolvedRedirect(command)); + internal ShellExecutionEnvironment Environment { get; } + + internal ShellAnalysisFailure Failure { get; } + private static bool HasUnsupportedWorkingDirectory(ShellValueDomain workingDirectory) { if (!Enum.IsDefined(workingDirectory.Kind)) diff --git a/src/Netclaw.Security/ShellCommandPolicy.cs b/src/Netclaw.Security/ShellCommandPolicy.cs index cd5cd34fe..20507cbc7 100644 --- a/src/Netclaw.Security/ShellCommandPolicy.cs +++ b/src/Netclaw.Security/ShellCommandPolicy.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -23,12 +23,22 @@ public sealed record ShellCommandDecision(bool Allowed, string? DenyReason = nul /// public sealed class ShellCommandPolicy { - private static readonly ShellCommandAnalyzer Analyzer = ShellCommandAnalyzer.Bash; + private readonly ShellCommandAnalyzer _analyzer; private readonly IReadOnlyList _denyPatterns; private readonly IReadOnlyList _rawStringPatterns; public ShellCommandPolicy(IEnumerable? additionalDenyPatterns = null) - : this(additionalDenyPatterns, overrideRules: null) + : this( + ShellExecutionEnvironmentDefaults.Bash, + additionalDenyPatterns, + overrideRules: null) + { + } + + public ShellCommandPolicy( + ShellExecutionEnvironment environment, + IEnumerable? additionalDenyPatterns = null) + : this(environment, additionalDenyPatterns, overrideRules: null) { } @@ -43,7 +53,20 @@ public ShellCommandPolicy(IEnumerable? additionalDenyPatterns = null) public ShellCommandPolicy( IEnumerable? additionalDenyPatterns, IEnumerable? overrideRules) + : this( + ShellExecutionEnvironmentDefaults.Bash, + additionalDenyPatterns, + overrideRules) { + } + + public ShellCommandPolicy( + ShellExecutionEnvironment environment, + IEnumerable? additionalDenyPatterns, + IEnumerable? overrideRules) + { + Environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _analyzer = new ShellCommandAnalyzer(environment); var structured = new List(DefaultDenyPatterns); var raw = new List(DefaultRawStringPatterns); @@ -70,6 +93,16 @@ public ShellCommandPolicy( _rawStringPatterns = raw; } + public ShellExecutionEnvironment Environment { get; } + + public ShellCommandAnalysis Analyze( + string command, + string? workingDirectory = null) + { + ArgumentNullException.ThrowIfNull(command); + return _analyzer.Analyze(command, workingDirectory); + } + private static void TranslateRule( HardDenyRule rule, List structured, @@ -113,20 +146,33 @@ private static void TranslateRule( /// Recursively scans bash -c / sh -c inner commands. /// public ShellCommandDecision Evaluate(string command) + => Evaluate(command, workingDirectory: null); + + public ShellCommandDecision Evaluate( + string command, + string? workingDirectory) { if (string.IsNullOrWhiteSpace(command)) return ShellCommandDecision.Allow(); + return Evaluate(Analyze(command, workingDirectory)); + } + + public ShellCommandDecision Evaluate(ShellCommandAnalysis analysis) + { + ArgumentNullException.ThrowIfNull(analysis); + if (!ReferenceEquals(analysis.Environment, Environment)) + throw new ArgumentException( + "The command analysis belongs to another shell environment.", + nameof(analysis)); + // Check raw-string patterns first (fork bombs, etc.) before splitting, // since compound-command splitting destroys the original formatting. - var rawDecision = EvaluateRawString(command); + var rawDecision = EvaluateRawString(analysis.Source); if (!rawDecision.Allowed) return rawDecision; - if (OperatingSystem.IsWindows()) - return EvaluateLegacySegments(command); - - return EvaluateBashAnalysis(command); + return EvaluateStructuralAnalysis(analysis); } /// @@ -138,15 +184,14 @@ internal ShellCommandDecision EvaluateBash(string command) if (string.IsNullOrWhiteSpace(command)) return ShellCommandDecision.Allow(); - var rawDecision = EvaluateRawString(command); - return rawDecision.Allowed ? EvaluateBashAnalysis(command) : rawDecision; + return Evaluate(command); } - private ShellCommandDecision EvaluateBashAnalysis(string command) + private ShellCommandDecision EvaluateStructuralAnalysis( + ShellCommandAnalysis analysis) { - var analysis = Analyzer.Analyze(command); if (analysis.Failure == ShellAnalysisFailure.Unresolved || analysis.Commands.Count == 0) - return EvaluateLegacySegments(command); + return EvaluateLegacySegments(analysis.Source); foreach (var occurrence in analysis.Commands) { @@ -202,10 +247,32 @@ private ShellCommandDecision EvaluateClause(ShellSyntaxTree.Clause clause) { var tokens = new List( clause.Verb.Tokens.Count + clause.Args.Count + clause.Redirects.Count); - tokens.AddRange(clause.Verb.Tokens); - tokens.AddRange(clause.Args - .Where(static arg => !arg.IsCwdAttribution) - .Select(static arg => arg.Raw)); + if (clause.Verb.CanonicalVerb is { Length: > 0 } canonicalVerb) + { + tokens.Add(canonicalVerb); + tokens.AddRange(clause.Verb.Tokens.Skip(1)); + } + else + { + tokens.AddRange(clause.Verb.Tokens); + } + if (Environment.Grammar == ShellGrammar.PowerShell + && clause.Elements.Count > 0) + { + // PowerShell can bind a parameter and its value from one source + // element, such as -Recurse:$false. The projected Args split that + // element and lose the binding. Hard-deny rules need the authored + // element so they do not reinterpret an explicit false switch. + tokens.AddRange(clause.Elements + .Where(static element => element.Role == ShellSyntaxTree.ClauseElementRole.Argument) + .Select(static element => element.Raw)); + } + else + { + tokens.AddRange(clause.Args + .Where(static arg => !arg.IsCwdAttribution) + .Select(static arg => arg.Raw)); + } tokens.AddRange(clause.Redirects .Where(static redirect => !string.IsNullOrEmpty(redirect.Target)) .Select(static redirect => redirect.Target)); @@ -326,7 +393,7 @@ internal sealed record ProcessKillDenyPattern(string Reason, DenyCategory Catego { private static readonly HashSet KillVerbs = new(StringComparer.OrdinalIgnoreCase) { - "kill", "killall", "pkill" + "kill", "killall", "pkill", "Stop-Process" }; public override bool Matches(IReadOnlyList tokens) @@ -358,8 +425,40 @@ public override bool Matches(IReadOnlyList tokens) return false; var verb = ShellTokenizer.TrimShellPunctuation(tokens[0]); - return EscalationVerbs.Contains(verb); + if (EscalationVerbs.Contains(verb)) + return true; + + if (!string.Equals(verb, "Start-Process", StringComparison.OrdinalIgnoreCase)) + return false; + + for (var i = 1; i < tokens.Count; i++) + { + var token = ShellTokenizer.TrimShellPunctuation(tokens[i]); + if (!TryReadParameter(token, out var parameterName, out var inlineValue) + || !IsParameterAbbreviation(parameterName, "Verb")) + { + continue; + } + + if (inlineValue is not null && IsRunAsValue(inlineValue)) + return true; + + if (inlineValue is null + && i + 1 < tokens.Count + && IsRunAsValue(tokens[i + 1])) + { + return true; + } + } + + return false; } + + private static bool IsRunAsValue(string token) + => string.Equals( + TrimStaticQuotes(ShellTokenizer.TrimShellPunctuation(token)), + "RunAs", + StringComparison.OrdinalIgnoreCase); } /// @@ -374,7 +473,12 @@ public override bool Matches(IReadOnlyList tokens) return false; var verb = ShellTokenizer.TrimShellPunctuation(tokens[0]); - if (!string.Equals(verb, "rm", StringComparison.OrdinalIgnoreCase)) + var isBashRemove = string.Equals(verb, "rm", StringComparison.OrdinalIgnoreCase); + var isPowerShellRemove = string.Equals( + verb, + "Remove-Item", + StringComparison.OrdinalIgnoreCase); + if (!isBashRemove && !isPowerShellRemove) return false; var hasRecursive = false; @@ -385,43 +489,159 @@ public override bool Matches(IReadOnlyList tokens) { var token = tokens[i]; - // Check for -rf, -fr, --recursive + --force, etc. - if (token.StartsWith('-') && !token.StartsWith("--", StringComparison.Ordinal)) + if (isPowerShellRemove) { - if (token.Contains('r', StringComparison.Ordinal) || token.Contains('R', StringComparison.Ordinal)) + if (TryReadParameter(token, out var parameterName, out var inlineValue) + && IsParameterAbbreviation(parameterName, "Recurse") + && !IsExplicitFalse(inlineValue)) + { hasRecursive = true; + } + + if (TryReadParameter(token, out parameterName, out inlineValue) + && IsParameterAbbreviation(parameterName, "Force") + && !IsExplicitFalse(inlineValue)) + { + hasForce = true; + } + } + else if (token.StartsWith('-') && !token.StartsWith("--", StringComparison.Ordinal)) + { + if (token.Contains('r', StringComparison.Ordinal) + || token.Contains('R', StringComparison.Ordinal)) + { + hasRecursive = true; + } if (token.Contains('f', StringComparison.Ordinal)) hasForce = true; } - else if (token == "--recursive") + else if (token.Equals("--recursive", StringComparison.Ordinal)) { hasRecursive = true; } - else if (token == "--force") + else if (token.Equals("--force", StringComparison.Ordinal)) { hasForce = true; } // Check for dangerous targets - if (IsDangerousRmTarget(token)) + if (IsDangerousRemoveTarget(token)) hasDangerousTarget = true; } - return hasRecursive && hasForce && hasDangerousTarget; + return hasRecursive + && hasDangerousTarget + && (isPowerShellRemove || hasForce); } - private static bool IsDangerousRmTarget(string token) + private static bool IsDangerousRemoveTarget(string token) { + token = TrimStaticQuotes(ShellTokenizer.TrimShellPunctuation(token)); + if (TryReadParameter(token, out _, out var inlineValue)) + { + if (inlineValue is null) + return false; + + token = TrimStaticQuotes(inlineValue); + } + + const string fileSystemProvider = "FileSystem::"; + const string qualifiedFileSystemProvider = "Microsoft.PowerShell.Core\\FileSystem::"; + if (token.StartsWith(qualifiedFileSystemProvider, StringComparison.OrdinalIgnoreCase)) + token = token[qualifiedFileSystemProvider.Length..]; + if (token.StartsWith(fileSystemProvider, StringComparison.OrdinalIgnoreCase)) + token = token[fileSystemProvider.Length..]; + // "/" trimmed becomes "" but the original is clearly root - if (token is "/" or "//") + if (token is "/" or "//" or "\\" or "\\\\") return true; var trimmed = token.TrimEnd('/', '\\'); return trimmed is "~" or "$HOME" or "${HOME}" - || string.Equals(trimmed, Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + or "$env:USERPROFILE" or "${env:USERPROFILE}" + || IsWindowsDriveRoot(token) + || IsUncShareRoot(token) + || string.Equals(trimmed, System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), StringComparison.OrdinalIgnoreCase); } + + private static bool IsExplicitFalse(string? value) + { + if (value is null) + return false; + + var normalized = TrimStaticQuotes(value); + return normalized.Equals("$false", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("${false}", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsWindowsDriveRoot(string token) + { + if (token.Length < 3 + || !char.IsAsciiLetter(token[0]) + || token[1] != ':' + || token[2] is not ('\\' or '/')) + { + return false; + } + + return token.AsSpan(2).IndexOfAnyExcept('\\', '/') < 0; + } + + private static bool IsUncShareRoot(string token) + { + if (token.Length < 5 + || token[0] is not ('\\' or '/') + || token[1] is not ('\\' or '/')) + { + return false; + } + + var segments = token[2..] + .Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); + return segments.Length == 2; + } + } + + private static bool TryReadParameter( + string token, + out string parameterName, + out string? inlineValue) + { + parameterName = string.Empty; + inlineValue = null; + if (token.Length < 2 || token[0] != '-') + return false; + + var body = token.AsSpan(1); + var separator = body.IndexOfAny(':', '='); + var name = separator < 0 ? body : body[..separator]; + if (name.IsEmpty) + return false; + + parameterName = name.ToString(); + if (separator >= 0 && separator < body.Length - 1) + inlineValue = body[(separator + 1)..].ToString(); + + return true; + } + + private static bool IsParameterAbbreviation(string candidate, string parameterName) + => candidate.Length > 0 + && parameterName.StartsWith(candidate, StringComparison.OrdinalIgnoreCase); + + private static string TrimStaticQuotes(string token) + { + var trimmed = token.Trim(); + if (trimmed.Length >= 2 + && trimmed[0] is '\'' or '"' + && trimmed[^1] == trimmed[0]) + { + return trimmed[1..^1]; + } + + return trimmed; } /// diff --git a/src/Netclaw.Security/ShellExecutionEnvironment.cs b/src/Netclaw.Security/ShellExecutionEnvironment.cs index f970872fd..eccdfe594 100644 --- a/src/Netclaw.Security/ShellExecutionEnvironment.cs +++ b/src/Netclaw.Security/ShellExecutionEnvironment.cs @@ -245,3 +245,11 @@ private static bool IsFullyQualifiedWindowsPath(string path) private static bool IsWindowsSeparator(char value) => value is '\\' or '/'; } + +internal static class ShellExecutionEnvironmentDefaults +{ + // Compatibility-only constructors in Netclaw.Security retain the historical + // Bash contract. Daemon composition always supplies its resolved environment. + internal static ShellExecutionEnvironment Bash { get; } = + ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); +} diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index 2b63fa489..eb9eee11b 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -239,8 +239,9 @@ private static bool FlushWordAsKeyword(StringBuilder word) /// /// Extracts the verb chain (command name + subcommand chain) from a - /// shell command. Backed by ShellSyntaxTree.BashParser on POSIX - /// shells: extends through every "verb-like" token (no slash, no dot, + /// shell command. This compatibility API selects semantics from the current + /// platform. Production approval code uses its canonical shell environment. + /// The result extends through every "verb-like" token (no slash, no dot, /// no flag prefix) until it hits a path or flag. Path-aware verbs /// (cat, grep, find, ls, ...) and single-token side-effect verbs /// (echo, printf, ...) are capped at depth 1 by post-check so the @@ -301,6 +302,13 @@ public static bool IsPathToken(string token) public static string NormalizeApprovalUnit(string command, string? workingDirectory = null) => ShellApprovalSemantics.ForCommand(command).NormalizeApprovalUnit(command, workingDirectory); + internal static string NormalizeApprovalUnit( + string command, + string? workingDirectory, + ShellPathStyle pathStyle) + => ShellApprovalSemantics.ForPathStyle(pathStyle) + .NormalizeApprovalUnit(command, workingDirectory); + /// /// Normalizes a path token using the active shell family's path semantics. /// Returns null when the token cannot be normalized as a local path. @@ -308,6 +316,13 @@ public static string NormalizeApprovalUnit(string command, string? workingDirect public static string? NormalizePathToken(string path, string? workingDirectory = null) => ShellApprovalSemantics.ForCommand(path).NormalizePathToken(path, workingDirectory); + internal static string? NormalizePathToken( + string path, + string? workingDirectory, + ShellPathStyle pathStyle) + => ShellApprovalSemantics.ForPathStyle(pathStyle) + .NormalizePathToken(path, workingDirectory); + /// /// Extracts inner commands from bash -c / sh -c wrappers. Returns the /// inner command strings for recursive scanning. Returns an empty list @@ -387,22 +402,39 @@ internal static string TrimShellPunctuation(string token) /// shape. /// internal static string? ApplyFileParentRule(string token) + => ApplyFileParentRule( + token, + OperatingSystem.IsWindows() ? ShellPathStyle.Windows : ShellPathStyle.Posix); + + internal static string? ApplyFileParentRule(string token, ShellPathStyle pathStyle) { if (string.IsNullOrEmpty(token)) return token; - var hasExtension = Path.HasExtension(token); - if (!hasExtension && !LooksLikeDotfile(token)) + var lastSeparator = pathStyle == ShellPathStyle.Windows + ? token.LastIndexOfAny(['/', '\\']) + : token.LastIndexOf('/'); + var basename = token[(lastSeparator + 1)..]; + var lastDot = basename.LastIndexOf('.'); + var hasExtension = lastDot > 0 && lastDot < basename.Length - 1; + var isDotfile = basename.Length > 1 && basename[0] == '.'; + if (!hasExtension && !isDotfile) return token; - var parent = Path.GetDirectoryName(token); - // GetDirectoryName returns "" for a bare filename and the literal - // separator for root-level files. Fall back to the token unchanged - // when we can't sensibly compute a parent. - if (string.IsNullOrEmpty(parent)) + if (lastSeparator < 0) return token; + if (lastSeparator == 0) + return token[..1]; + if (pathStyle == ShellPathStyle.Windows + && lastSeparator == 2 + && token.Length >= 3 + && char.IsAsciiLetter(token[0]) + && token[1] == ':') + { + return token[..3]; + } - return parent; + return token[..lastSeparator]; } internal static bool LooksLikeDotfile(string token) diff --git a/src/Netclaw.Security/ToolPathPolicy.cs b/src/Netclaw.Security/ToolPathPolicy.cs index 8f94c4c2c..733911d2b 100644 --- a/src/Netclaw.Security/ToolPathPolicy.cs +++ b/src/Netclaw.Security/ToolPathPolicy.cs @@ -23,13 +23,23 @@ namespace Netclaw.Security; /// public sealed class ToolPathPolicy { + private readonly ShellCommandAnalyzer _analyzer; private readonly HashSet _writeDeniedPaths; private readonly HashSet _readDeniedPaths; private readonly HashSet _shellDeniedPaths; private readonly HashSet _commandIndicators; public ToolPathPolicy(IEnumerable deniedPaths) + : this(ShellExecutionEnvironmentDefaults.Bash, deniedPaths) { + } + + public ToolPathPolicy( + ShellExecutionEnvironment environment, + IEnumerable deniedPaths) + { + Environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _analyzer = new ShellCommandAnalyzer(environment); var materialized = deniedPaths.ToList(); _writeDeniedPaths = BuildNormalizedSet(materialized); _readDeniedPaths = _writeDeniedPaths; @@ -41,7 +51,22 @@ public ToolPathPolicy( IEnumerable writeDeniedPaths, IEnumerable readDeniedPaths, IEnumerable shellIndicatorPaths) + : this( + ShellExecutionEnvironmentDefaults.Bash, + writeDeniedPaths, + readDeniedPaths, + shellIndicatorPaths) { + } + + public ToolPathPolicy( + ShellExecutionEnvironment environment, + IEnumerable writeDeniedPaths, + IEnumerable readDeniedPaths, + IEnumerable shellIndicatorPaths) + { + Environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _analyzer = new ShellCommandAnalyzer(environment); _writeDeniedPaths = BuildNormalizedSet(writeDeniedPaths); _readDeniedPaths = BuildNormalizedSet(readDeniedPaths); var shellList = shellIndicatorPaths.ToList(); @@ -49,6 +74,8 @@ public ToolPathPolicy( _commandIndicators = BuildCommandIndicators(shellList); } + public ShellExecutionEnvironment Environment { get; } + private static HashSet BuildNormalizedSet(IEnumerable paths) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -174,6 +201,21 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory if (string.IsNullOrWhiteSpace(command)) return false; + return CommandReferencesDeniedPath( + _analyzer.Analyze(command, workingDirectory)); + } + + public bool CommandReferencesDeniedPath(ShellCommandAnalysis analysis) + { + ArgumentNullException.ThrowIfNull(analysis); + if (!ReferenceEquals(analysis.Environment, Environment)) + throw new ArgumentException( + "The command analysis belongs to another shell environment.", + nameof(analysis)); + + var command = analysis.Source; + var workingDirectory = analysis.WorkingDirectory; + var tokens = ShellTokenizer.Tokenize(command).ToList(); var slashCommand = command.Replace('\\', '/'); foreach (var indicator in _commandIndicators) @@ -182,8 +224,7 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory return true; } - if (!OperatingSystem.IsWindows() - && StructuredAnalysisReferencesDeniedPath(command, workingDirectory)) + if (StructuredAnalysisReferencesDeniedPath(analysis)) { return true; } @@ -193,7 +234,10 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory if (!LooksLikePath(token)) continue; - var normalized = ShellTokenizer.NormalizePathToken(token, workingDirectory); + var normalized = ShellTokenizer.NormalizePathToken( + token, + workingDirectory, + Environment.PathStyle); if (normalized is not null && IsDeniedNormalized(normalized, _shellDeniedPaths)) { return true; @@ -243,10 +287,8 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory } private bool StructuredAnalysisReferencesDeniedPath( - string command, - string? workingDirectory) + ShellCommandAnalysis analysis) { - var analysis = ShellCommandAnalyzer.Bash.Analyze(command, workingDirectory); if (analysis.Failure != ShellAnalysisFailure.None) return false; From c111be60128de3def0d2a4052f7a6d6929f51f98 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 10:30:32 +0000 Subject: [PATCH 2/4] test(shell): use native host in cross-platform tests --- .../Jobs/BackgroundJobIntegrationTests.cs | 11 ++- .../Jobs/BackgroundJobManagerActorTests.cs | 33 +++++--- .../TestShellEnvironment.cs | 10 +++ .../Tools/DispatchingToolExecutorTests.cs | 79 +++++++++++++------ .../Tools/ShellToolTests.cs | 8 +- .../Tools/ToolApprovalGateTests.cs | 7 +- .../Tools/ToolArgumentValidatorTests.cs | 9 ++- .../ShellApprovalMatcherTests.cs | 4 +- 8 files changed, 110 insertions(+), 51 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs index 95d5f1914..74be1ee0d 100644 --- a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -42,7 +42,10 @@ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IService builder.StartActors((system, registry, _) => { var manager = system.ActorOf( - Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System)), + Props.Create(() => new BackgroundJobManagerActor( + _store, + TimeProvider.System, + TestShellEnvironment.Current)), "background-job-manager"); registry.Register(manager); }); @@ -134,7 +137,7 @@ public async Task BackgroundJob_WithMissingWorkingDirectory_FailsWithHelpfulErro TimeSpan.FromSeconds(15), cancellationToken: TestContext.Current.CancellationToken); Assert.Contains("does not exist", delivered.Content); - Assert.Contains("mkdir", delivered.Content); + Assert.Contains(TestShellEnvironment.CreateDirectoryCommandName, delivered.Content); Assert.Contains("failed", delivered.Content.ToLowerInvariant()); await AwaitAssertAsync(() => @@ -183,7 +186,7 @@ public async Task CancelRunningJob_ViaCheckBackgroundJobTool() ActorRegistry.For(Sys).Register(autoAckRef); var started = await manager.Ask( - MakeStartCommand("sleep 300"), + MakeStartCommand(TestShellEnvironment.LongRunningCommand), TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs index eab2ab54d..96a16fcdc 100644 --- a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs +++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs @@ -35,7 +35,10 @@ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IService builder.StartActors((system, registry, _) => { var manager = system.ActorOf( - Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System)), + Props.Create(() => new BackgroundJobManagerActor( + _store, + TimeProvider.System, + TestShellEnvironment.Current)), "background-job-manager"); registry.Register(manager); }); @@ -94,7 +97,7 @@ public async Task ConcurrencyLimit_QueuesOverflowJobs() for (var i = 0; i < BackgroundJobManagerActor.MaxConcurrentJobs + 2; i++) { var started = await manager.Ask( - MakeStartCommand($"sleep {i + 60}"), + MakeStartCommand(TestShellEnvironment.DelayCommand(i + 60)), TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); jobIds.Add(started.JobId); @@ -113,7 +116,7 @@ public async Task Completion_DispatchesQueuedJob() for (var i = 0; i < BackgroundJobManagerActor.MaxConcurrentJobs + 1; i++) { var started = await manager.Ask( - MakeStartCommand($"sleep {i + 60}"), + MakeStartCommand(TestShellEnvironment.DelayCommand(i + 60)), TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); jobIds.Add(started.JobId); @@ -146,13 +149,13 @@ public async Task KillJobsForSession_ReapsOwnedJobs_LeavesOtherSessionsAlone() var sessionB = new SessionId("reap/session-b"); var jobA1 = await manager.Ask( - MakeStartCommand("sleep 300") with { SessionId = sessionA }, + MakeStartCommand(TestShellEnvironment.LongRunningCommand) with { SessionId = sessionA }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); var jobA2 = await manager.Ask( - MakeStartCommand("sleep 300") with { SessionId = sessionA }, + MakeStartCommand(TestShellEnvironment.LongRunningCommand) with { SessionId = sessionA }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); var jobB = await manager.Ask( - MakeStartCommand("sleep 300") with { SessionId = sessionB }, + MakeStartCommand(TestShellEnvironment.LongRunningCommand) with { SessionId = sessionB }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // Guard against environmental spawn failure (fork pressure under the @@ -199,7 +202,7 @@ public async Task ReapedJob_ProducesNoCompletionDelivery() ActorRegistry.For(Sys).Register(gatewayProbe.Ref); var started = await manager.Ask( - MakeStartCommand("sleep 300") with { SessionId = sessionId }, + MakeStartCommand(TestShellEnvironment.LongRunningCommand) with { SessionId = sessionId }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); await manager.Ask( @@ -276,7 +279,10 @@ await File.WriteAllTextAsync( // A fresh manager's PreStart reconciliation marks the orphan Lost and // must notify the owning session through the gateway. var manager = Sys.ActorOf( - Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System)), + Props.Create(() => new BackgroundJobManagerActor( + _store, + TimeProvider.System, + TestShellEnvironment.Current)), "lost-notify-manager"); // Readiness barrier: reconciliation runs before this reply. @@ -318,7 +324,10 @@ public async Task StartupReconciliation_MarksOrphanedJobsAsLost() // Create a second manager — its PreStart reconciliation should mark the orphan as Lost var manager = Sys.ActorOf( - Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System)), + Props.Create(() => new BackgroundJobManagerActor( + _store, + TimeProvider.System, + TestShellEnvironment.Current)), "reconcile-test-manager"); // Readiness barrier: reconciliation runs before this reply. @@ -356,7 +365,11 @@ public async Task StartupReconciliation_EmitsAlert_ForLegacyJobMissingTrustField var sink = new RecordingNotificationSink(); var legacyManager = Sys.ActorOf( - Props.Create(() => new BackgroundJobManagerActor(store, TimeProvider.System, sink)), + Props.Create(() => new BackgroundJobManagerActor( + store, + TimeProvider.System, + TestShellEnvironment.Current, + sink)), "legacy-job-alert-manager"); // Readiness barrier: startup alert emission runs before this reply. diff --git a/src/Netclaw.Actors.Tests/TestShellEnvironment.cs b/src/Netclaw.Actors.Tests/TestShellEnvironment.cs index 0e5be8822..2dc63b619 100644 --- a/src/Netclaw.Actors.Tests/TestShellEnvironment.cs +++ b/src/Netclaw.Actors.Tests/TestShellEnvironment.cs @@ -23,6 +23,16 @@ internal static class TestShellEnvironment ? "Start-Sleep -Seconds 300" : "sleep 300"; + public static string DelayCommand(int seconds) => + Current.Grammar == ShellGrammar.PowerShell + ? $"Start-Sleep -Seconds {seconds}" + : $"sleep {seconds}"; + + public static string CreateDirectoryCommandName => + Current.Grammar == ShellGrammar.PowerShell + ? "New-Item" + : "mkdir"; + public static string StandardErrorCommand => Current.Grammar == ShellGrammar.PowerShell ? "[Console]::Error.WriteLine('error')" diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index cc55f7ed9..43bbef13e 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -20,6 +20,7 @@ namespace Netclaw.Actors.Tests.Tools; public class DispatchingToolExecutorTests { + private static readonly ShellExecutionEnvironment ShellEnvironment = TestShellEnvironment.Current; private readonly DispatchingToolExecutor _executor; private readonly DispatchingToolExecutor _restrictedExecutor; @@ -34,8 +35,10 @@ public DispatchingToolExecutorTests() } }; + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + var pathPolicy = new ToolPathPolicy(ShellEnvironment, []); var registry = new ToolRegistry(); - registry.WithFirstPartyTools(baseConfig, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); + registry.WithFirstPartyTools(baseConfig, new NetclawPaths(), pathPolicy, commandPolicy); _executor = new DispatchingToolExecutor( registry, new ToolAccessPolicy( @@ -45,8 +48,8 @@ public DispatchingToolExecutorTests() TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([]))); + commandPolicy, + pathPolicy)); var restrictedConfig = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; restrictedConfig.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig @@ -58,8 +61,14 @@ public DispatchingToolExecutorTests() }; restrictedConfig.AudienceProfiles.Team.AllowedTools = ["file_read", "file_list", "file_write", "file_edit", "attach_file", "shell_execute"]; restrictedConfig.AudienceProfiles.Public.AllowedTools = ["file_read", "file_list", "attach_file"]; + var restrictedCommandPolicy = new ShellCommandPolicy(ShellEnvironment); + var restrictedPathPolicy = new ToolPathPolicy(ShellEnvironment, []); var restrictedRegistry = new ToolRegistry(); - restrictedRegistry.WithFirstPartyTools(restrictedConfig, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); + restrictedRegistry.WithFirstPartyTools( + restrictedConfig, + new NetclawPaths(), + restrictedPathPolicy, + restrictedCommandPolicy); _restrictedExecutor = new DispatchingToolExecutor( restrictedRegistry, new ToolAccessPolicy( @@ -69,8 +78,8 @@ public DispatchingToolExecutorTests() TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([]))); + restrictedCommandPolicy, + restrictedPathPolicy)); } [Fact] @@ -334,8 +343,10 @@ public async Task Shell_execute_is_denied_when_missing_from_personal_audience_pr config.AudienceProfiles.Personal.ToolsMode = ToolProfileMode.Allowlist; config.AudienceProfiles.Personal.AllowedTools = ["file_read", "file_write", "attach_file"]; + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + var pathPolicy = new ToolPathPolicy(ShellEnvironment, []); var registry = new ToolRegistry(); - registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); + registry.WithFirstPartyTools(config, new NetclawPaths(), pathPolicy, commandPolicy); var executor = new DispatchingToolExecutor( registry, @@ -346,8 +357,8 @@ public async Task Shell_execute_is_denied_when_missing_from_personal_audience_pr TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([]))); + commandPolicy, + pathPolicy)); var toolCall = new FunctionCallContent( "call-shell-profile-deny", "shell_execute", @@ -371,8 +382,10 @@ public async Task Shell_execute_is_denied_when_shell_mode_is_off_even_in_persona config.AudienceProfiles.Personal.ToolsMode = ToolProfileMode.Allowlist; config.AudienceProfiles.Personal.AllowedTools.Add("shell_execute"); + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + var pathPolicy = new ToolPathPolicy(ShellEnvironment, []); var registry = new ToolRegistry(); - registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); + registry.WithFirstPartyTools(config, new NetclawPaths(), pathPolicy, commandPolicy); var executor = new DispatchingToolExecutor( registry, @@ -383,8 +396,8 @@ public async Task Shell_execute_is_denied_when_shell_mode_is_off_even_in_persona TrustAudience.Personal, ShellExecutionMode.Off, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([]))); + commandPolicy, + pathPolicy)); var toolCall = new FunctionCallContent( "call-shell-off", "shell_execute", @@ -1085,8 +1098,10 @@ public async Task One_time_approval_allows_immediate_retry_only() } }; + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + var pathPolicy = new ToolPathPolicy(ShellEnvironment, []); var registry = new ToolRegistry(); - registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); + registry.WithFirstPartyTools(config, new NetclawPaths(), pathPolicy, commandPolicy); var system = ActorSystem.Create($"tool-approval-{Guid.NewGuid():N}"); try @@ -1102,8 +1117,8 @@ public async Task One_time_approval_allows_immediate_retry_only() TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([])), + commandPolicy, + pathPolicy), approvalService); var toolCall = new FunctionCallContent( @@ -1157,8 +1172,10 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns( } }; + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + var pathPolicy = new ToolPathPolicy(ShellEnvironment, []); var registry = new ToolRegistry(); - registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); + registry.WithFirstPartyTools(config, new NetclawPaths(), pathPolicy, commandPolicy); var executor = new DispatchingToolExecutor( registry, @@ -1169,8 +1186,8 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns( TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([]))); + commandPolicy, + pathPolicy)); var toolCall = new FunctionCallContent( "call-approve-once-bypass", @@ -1300,8 +1317,10 @@ public async Task One_time_approval_uses_filtered_unapproved_patterns_on_retry() } }; + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + var pathPolicy = new ToolPathPolicy(ShellEnvironment, []); var registry = new ToolRegistry(); - registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); + registry.WithFirstPartyTools(config, new NetclawPaths(), pathPolicy, commandPolicy); var system = ActorSystem.Create($"tool-approval-filtered-once-{Guid.NewGuid():N}"); try @@ -1317,8 +1336,8 @@ public async Task One_time_approval_uses_filtered_unapproved_patterns_on_retry() TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([])), + commandPolicy, + pathPolicy), approvalService); var context = TestToolExecutionContext.CreateBound("signalr/thread-filtered", null, new TestToolExecutionContextOptions @@ -1329,11 +1348,21 @@ public async Task One_time_approval_uses_filtered_unapproved_patterns_on_retry() InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true) }); + var approvedPattern = ShellEnvironment.Grammar == ShellGrammar.PowerShell + ? "Get-Location" + : "pwd"; + var unapprovedPattern = ShellEnvironment.Grammar == ShellGrammar.PowerShell + ? "Get-ChildItem" + : "ls"; + var command = ShellEnvironment.Grammar == ShellGrammar.PowerShell + ? "Get-Location; Get-ChildItem" + : "pwd && ls"; + await approvalService.RecordApprovalAsync( "signalr/thread-filtered", TrustAudience.Personal, new ToolName("shell_execute"), - ["pwd"], + [approvedPattern], persistent: false, cwd: null, TestContext.Current.CancellationToken); @@ -1341,13 +1370,13 @@ await approvalService.RecordApprovalAsync( var call = new FunctionCallContent( "call-filtered-once", "shell_execute", - ToolInput.Create("Command", "pwd && ls")); + ToolInput.Create("Command", command)); var firstAttempt = await Assert.ThrowsAsync(() => executor.ExecuteAsync(call, context, TestContext.Current.CancellationToken)); - Assert.Equal(["ls"], firstAttempt.ApprovalContext.Patterns); - Assert.Equal(["ls"], firstAttempt.ApprovalContext.CandidateVerbs); + Assert.Equal([unapprovedPattern], firstAttempt.ApprovalContext.Patterns); + Assert.Equal([unapprovedPattern], firstAttempt.ApprovalContext.CandidateVerbs); context.OneTimeApprovedToolName = call.Name; context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(firstAttempt.ApprovalContext)); diff --git a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs index 2007e402c..33c921e99 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs @@ -255,7 +255,7 @@ public async Task Cwd_creates_missing_session_directory_when_used_as_default() try { var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, TrustAudience.Personal); - var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); + var args = ToolInput.Create("Command", TestShellEnvironment.PrintWorkingDirectoryCommand); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -284,7 +284,7 @@ public async Task Cwd_explicit_arg_overrides_project_and_session_directories() var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, new TestToolExecutionContextOptions { Audience = TrustAudience.Personal, ProjectDirectory = projectDir }); var args = ToolInput.Create( - "Command", OperatingSystem.IsWindows() ? "cd" : "pwd", + "Command", TestShellEnvironment.PrintWorkingDirectoryCommand, "WorkingDirectory", explicitDir); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -313,7 +313,7 @@ public async Task Cwd_does_not_inherit_daemon_process_directory() // Environment.CurrentDirectory happens to be — proving the // ProcessStartInfo default-fall-through is gone. var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, TrustAudience.Personal); - var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); + var args = ToolInput.Create("Command", TestShellEnvironment.PrintWorkingDirectoryCommand); var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); @@ -348,7 +348,7 @@ public async Task Missing_explicit_working_directory_returns_helpful_error() Assert.Contains("does not exist", result); Assert.Contains(missingDir, result); - Assert.Contains("mkdir", result); + Assert.Contains(TestShellEnvironment.CreateDirectoryCommandName, result); // The process must never start with a missing cwd... Assert.DoesNotContain("Exit code", result); // ...and the tool must not silently create the directory either. diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index 5f66a4afc..df1434e06 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -849,6 +849,7 @@ private static string CreateTrustZoneRoot(string tempDir) private static ToolAccessPolicy CreatePolicyWithTrustZone(IShellTrustZonePolicy trustZone) { + var environment = TestShellEnvironment.Current; var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig { @@ -865,8 +866,8 @@ private static ToolAccessPolicy CreatePolicyWithTrustZone(IShellTrustZonePolicy TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - shellCommandPolicy: new ShellCommandPolicy(), - toolPathPolicy: new ToolPathPolicy([]), + shellCommandPolicy: new ShellCommandPolicy(environment), + toolPathPolicy: new ToolPathPolicy(environment, []), shellTrustZonePolicy: trustZone); } diff --git a/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs index 6e1e3d442..b35cd7a26 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs @@ -25,6 +25,9 @@ public class ToolArgumentValidatorTests public ToolArgumentValidatorTests() { + var environment = TestShellEnvironment.Current; + var commandPolicy = new ShellCommandPolicy(environment); + var pathPolicy = new ToolPathPolicy(environment, []); var config = new ToolConfig(); config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig { @@ -35,7 +38,7 @@ public ToolArgumentValidatorTests() }; var registry = new ToolRegistry(); - registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); + registry.WithFirstPartyTools(config, new NetclawPaths(), pathPolicy, commandPolicy); _executor = new DispatchingToolExecutor( registry, new ToolAccessPolicy( @@ -45,8 +48,8 @@ public ToolArgumentValidatorTests() TrustAudience.Personal, ShellExecutionMode.HostAllowed, UsedStrictFallback: false), - new ShellCommandPolicy(), - new ToolPathPolicy([]))); + commandPolicy, + pathPolicy)); } private static ToolExecutionContext PersonalContext(string sessionDir) diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index abf0277e1..cf3fd32c9 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -104,7 +104,7 @@ public void Power_shell_file_system_provider_keeps_directory_scope() Assert.False(analysis.IsMessy); var candidate = Assert.Single(analysis.Candidates); Assert.Equal("Get-Content", candidate.Verb); - Assert.Equal("C:/work", candidate.Directory); + Assert.Equal(OperatingSystem.IsWindows() ? @"C:\work" : "C:/work", candidate.Directory); } [Fact] @@ -122,7 +122,7 @@ public void Power_shell_redirect_uses_the_environment_path_style() Assert.False(analysis.IsMessy); var candidate = Assert.Single(analysis.Candidates); Assert.Equal("Get-Content", candidate.Verb); - Assert.Equal("C:/work", candidate.Directory); + Assert.Equal(OperatingSystem.IsWindows() ? @"C:\work" : "C:/work", candidate.Directory); } [Fact] From 311b9155b4ff93beb1f2694d82e2021ec9c1499a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 10:47:47 +0000 Subject: [PATCH 3/4] test(shell): correct native path fixtures --- src/Netclaw.Actors.Tests/TestShellEnvironment.cs | 5 +++++ .../Tools/ToolApprovalGateTests.cs | 10 ++++++++-- .../ShellApprovalMatcherTests.cs | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Netclaw.Actors.Tests/TestShellEnvironment.cs b/src/Netclaw.Actors.Tests/TestShellEnvironment.cs index 2dc63b619..a68f9aa4e 100644 --- a/src/Netclaw.Actors.Tests/TestShellEnvironment.cs +++ b/src/Netclaw.Actors.Tests/TestShellEnvironment.cs @@ -33,6 +33,11 @@ public static string DelayCommand(int seconds) => ? "New-Item" : "mkdir"; + public static string ReadFileCommand(string path) => + Current.Grammar == ShellGrammar.PowerShell + ? $"Get-Content '{path.Replace("'", "''", StringComparison.Ordinal)}'" + : $"cat '{path.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'"; + public static string StandardErrorCommand => Current.Grammar == ShellGrammar.PowerShell ? "[Console]::Error.WriteLine('error')" diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index df1434e06..5b8a0c18a 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -638,7 +638,10 @@ public void Non_interactive_shell_with_path_outside_trust_zone_is_denied() var ctx = PersonalContext(supportsApproval: false); var decision = policy.AuthorizeInvocation(tool, ctx, - new Dictionary { ["command"] = $"cat {outsidePath}" }); + new Dictionary + { + ["command"] = TestShellEnvironment.ReadFileCommand(outsidePath) + }); Assert.False(decision.Allowed); Assert.Equal("shell_path_outside_trust_zone", decision.DenyReason); @@ -657,7 +660,10 @@ public void Non_interactive_shell_with_path_inside_trust_zone_proceeds_to_approv var ctx = PersonalContext(supportsApproval: false); var decision = policy.AuthorizeInvocation(tool, ctx, - new Dictionary { ["command"] = $"cat {insidePath}" }); + new Dictionary + { + ["command"] = TestShellEnvironment.ReadFileCommand(insidePath) + }); // Path is within trust zone — proceeds to the approval gate (RequiresApproval) Assert.True(decision.NeedsApproval); diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index cf3fd32c9..671db0e54 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -122,7 +122,7 @@ public void Power_shell_redirect_uses_the_environment_path_style() Assert.False(analysis.IsMessy); var candidate = Assert.Single(analysis.Candidates); Assert.Equal("Get-Content", candidate.Verb); - Assert.Equal(OperatingSystem.IsWindows() ? @"C:\work" : "C:/work", candidate.Directory); + Assert.Equal("C:/work", candidate.Directory); } [Fact] From 4e863b5f0ccb8d1a2b175d5a0f6a8a88968fbc3e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 11:00:02 +0000 Subject: [PATCH 4/4] test(shell): qualify PowerShell file system paths --- src/Netclaw.Actors.Tests/TestShellEnvironment.cs | 2 +- src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Netclaw.Actors.Tests/TestShellEnvironment.cs b/src/Netclaw.Actors.Tests/TestShellEnvironment.cs index a68f9aa4e..a50cbfd61 100644 --- a/src/Netclaw.Actors.Tests/TestShellEnvironment.cs +++ b/src/Netclaw.Actors.Tests/TestShellEnvironment.cs @@ -35,7 +35,7 @@ public static string DelayCommand(int seconds) => public static string ReadFileCommand(string path) => Current.Grammar == ShellGrammar.PowerShell - ? $"Get-Content '{path.Replace("'", "''", StringComparison.Ordinal)}'" + ? $"Get-Content 'FileSystem::{path.Replace("'", "''", StringComparison.Ordinal)}'" : $"cat '{path.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'"; public static string StandardErrorCommand => diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 671db0e54..ddb797b0b 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -99,7 +99,7 @@ public void Power_shell_file_system_provider_keeps_directory_scope() var analysis = matcher.AnalyzeInvocation( new ToolName("shell_execute"), - Args(@"Get-Content FileSystem::C:\work\input.txt", @"C:\work")); + Args(@"Get-Content 'FileSystem::C:\work\input.txt'", @"C:\work")); Assert.False(analysis.IsMessy); var candidate = Assert.Single(analysis.Candidates);