diff --git a/Directory.Packages.props b/Directory.Packages.props index 5a3c3f73d..86dabae7d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -75,7 +75,7 @@ - + diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1be91783a..01b969755 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,6 +1,6 @@ # Netclaw Implementation Plan -Last updated: 2026-06-01 +Last updated: 2026-08-09 This is the execution plan for Netclaw. Autonomous agents and RALPH-style loops SHALL work from `NOW` by default. `NEXT` and `LATER` work belongs in @@ -144,6 +144,15 @@ Done when: - [x] Netclaw consumes ShellSyntaxTree `0.3.0-alpha.1` and promotes Bash command-resolution mutation and reserved execution forms into the strict 181-case review matrix. +- [x] Netclaw consumes ShellSyntaxTree `0.3.0-alpha.2` for one exact POSIX + `pwsh -NoProfile -NonInteractive -Command ''` wrapper. It + keeps the outer host and every complete PowerShell child as independent + approval occurrences. +- [x] The 204-case shell approval review table proves PowerShell host and child + grant composition, intrinsic direct-call script blocks, nested hard deny, + decoded protected paths, and strict handling for dynamic values, named + script-block receivers, command-resolution changes, host-option near misses, + Windows wrappers, `BASH_ENV`, and exported-function risk. - [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. diff --git a/openspec/changes/adopt-shellsyntax-alpha2-pwsh/.openspec.yaml b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/.openspec.yaml new file mode 100644 index 000000000..d77f64e53 --- /dev/null +++ b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-09 diff --git a/openspec/changes/adopt-shellsyntax-alpha2-pwsh/design.md b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/design.md new file mode 100644 index 000000000..8eb6ac277 --- /dev/null +++ b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/design.md @@ -0,0 +1,180 @@ +## Context + +Netclaw starts `/bin/bash -c` on POSIX hosts and `cmd.exe /c` on Windows +hosts. A PowerShell command therefore starts as an outer-host command that can +launch a new `pwsh` or `powershell` child process. + +The approval matcher uses ShellSyntaxTree for Bash on POSIX hosts. It uses a +legacy token splitter on Windows hosts. The hard-deny policy uses the same Bash +analysis on POSIX hosts, but it falls back to token segments on Windows hosts. +Neither path has a complete PowerShell occurrence model. + +The gate runs before actor dispatch. This change does not alter actor messages, +actor ownership, stored approval records, recovery, or tool arguments. + +## Goals / Non-Goals + +**Goals:** + +- Prove the outer host wrapper before PowerShell analysis. +- Parse complete child payloads with ShellSyntaxTree `0.3.0-alpha.2`. +- Evaluate every PowerShell occurrence in hard-deny and approval policy. +- Keep incomplete and state-dependent forms strict. +- Add review-table evidence for allow, prompt, deny, and stored approval. + +**Non-Goals:** + +- Change the shell tool schema or execution host. +- Claim isolated PowerShell initial state. +- Read or authorize external `.ps1` file contents. +- Add PowerShell-specific grant records or safe-verb configuration. +- Add profile, module, or `Start-ThreadJob` policy. + +## Decisions + +### Prove the outer host before parsing the child payload + +The analyzer will first use the POSIX Bash grammar. The Bash occurrence must +contain exactly these argv elements in this order: + +1. `pwsh`; +2. `-NoProfile`; +3. `-NonInteractive`; +4. `-Command`; +5. one quoted static payload that is not `-`. + +The host token must equal `pwsh` with ordinal case-sensitive comparison. The +three PowerShell option names use case-insensitive comparison. + +Each element must have complete direct-source provenance. A double-quoted +payload must contain no Bash expansion or escape that can decode differently +under PowerShell rules. + +The initial slice will reject every other host spelling, option, or ordering. +This includes `PWSH`, `pwsh.exe`, `powershell`, `powershell.exe`, +`-WorkingDirectory`, `-File`, `-EncodedCommand`, +`-CommandWithArgs`, abbreviated command flags, prefix wrappers, multiple +payloads, trailing arguments, outer redirects, stdin payload `-`, and dynamic +wrapper tokens. A rejected wrapper makes the complete command unresolved. +The detector checks every static authored Bash clause element. It does not use +a finite prefix list, so launcher options cannot move `pwsh` outside the +detected region. This makes command-launching forms such as `builtin command`, +an absolute `env`, and `xargs` strict. A data-only command that uses a standalone +PowerShell host token can therefore receive one-shot approval only. + +Windows `cmd.exe` commands keep the legacy matcher for ordinary commands. +ShellSyntaxTree does not parse `cmd` percent expansion, caret escaping, quote +removal, or control operators. A conservative token guard therefore marks a +visible `pwsh` or `powershell` host as complex and returns no approval patterns +or candidates. Safe Windows child reuse needs either a real `cmd` parser or a +direct PowerShell execution path, so it is outside this slice. + +Alternative: send the full Bash or `cmd.exe` source directly to `PwshParser`. +That choice can apply PowerShell quote and expansion rules to text that the +outer host changes first. + +Alternative: allow all static PowerShell host options. Options such as +`-WorkingDirectory` change the child state and can make Netclaw resolve a path +against the wrong approval directory. + +Alternative: accept `powershell` through the same grammar. That name normally +selects Windows PowerShell 5.1, while ShellSyntaxTree targets PowerShell 7.4. +The parser cannot prove a different dialect or a custom shim. + +### Use the safe PowerShell initial-state mode + +The child payload will use `PwshParserOptions.InitialStateMode = Unknown`. +Netclaw does not disable module auto-load or pin the complete module search +baseline. `-NoProfile -NonInteractive` alone does not meet the parser's +isolated-state contract. + +The wrapper proof will still require `-NoProfile` and `-NonInteractive` before +approval reuse. This prevents user profile code and interactive input from +adding hidden behavior. It does not promote loop values or other facts that +need isolated-state proof. + +Alternative: assert `IsolatedNonInteractiveNoProfile` from the host flags. +ShellSyntaxTree explicitly rejects that inference because the environment and +module state remain uncontrolled. + +### Retain the host occurrence with all child occurrences + +After complete wrapper and child analysis, the analyzer will retain the outer +`pwsh` occurrence and add every child `CommandOccurrence`. Bash can replace an +exact `pwsh` token through `BASH_ENV` or an exported function before a child +process starts. The parser does not prove that outer executable resolution. + +The wrapper candidate and every child candidate must receive independent +coverage. A safe child can compose with an existing `pwsh` approval. A `pwsh` +approval alone cannot cover a new child command. Prefix executables remain +visible or unresolved. + +The hard-deny policy, protected-path policy, and approval matcher will consume +the same analysis. The protected-path policy will inspect decoded exact and +finite child paths and redirect targets in addition to its existing raw-text +deny scan. Each path, redirect, dynamic value, execution region, and +command-resolution change will therefore receive one consistent decision. + +Alternative: remove the `pwsh` wrapper like a transparent direct `bash -c` +dispatch. An inherited Bash function can then use a safe child spelling to +hide arbitrary Bash behavior. Keeping all child occurrences prevents the +wrapper grant from covering an arbitrary future payload. + +### Keep existing safe-verb and grant shapes + +The child occurrence produces the existing `(verb, directory)` candidate. +Native safe verbs such as `git status` can use the current safe list. A +PowerShell cmdlet can use the existing stored approval path when its complete +candidate matches. No new candidate or persisted record field is added. + +The POSIX safe list does not gain PowerShell cmdlets. Adding `Get-Content` as a +POSIX executable-safe name would allow an unrelated executable with that name. +PowerShell-native cmdlets on POSIX therefore need an initial approval. The +stored narrow approval can then be reused. + +### Keep parser failure atomic + +If the outer proof, child parse, command list, occurrence facts, value facts, +or redirect facts are incomplete, the matcher returns no persistent +candidate. The prompt shows the raw command and offers one-shot approval or +deny. The hard-deny policy retains its legacy scan as an additional deny +check, but that fallback cannot authorize the command. + +## Risks / Trade-offs + +- [Risk] The first wrapper grammar rejects valid PowerShell launch forms. -> + The command stays one-shot only. Later slices can add proved forms. +- [Risk] PowerShell and the outer host use different quote rules. -> The outer + grammar supplies the exact child payload. The consumer does not reparse raw + text under the wrong shell. +- [Risk] Windows keeps its current approval fatigue. -> Windows stays strict + until Netclaw has a complete `cmd` grammar or a direct PowerShell host. +- [Risk] A host option changes child state. -> The exact argv whitelist rejects + all options outside the two required host flags and `-Command`. +- [Risk] A host spelling selects another PowerShell dialect. -> The whitelist + accepts only `pwsh`, the PowerShell 7 host name. +- [Risk] Unknown PowerShell state reduces approval reuse. -> Netclaw does not + claim stronger facts than its executor provides. +- [Risk] A future package adds an occurrence or enum value. -> Unknown and + incomplete facts remain strict. +- [Risk] Bash replaces the PowerShell host before launch. -> The outer `pwsh` + occurrence remains mandatory even when every child is safe or approved. +- [Risk] A hard-deny command or protected path is hidden by outer decoding. -> + Hard-deny, protected-path, and approval paths share the same complete + occurrence list. The matrix pins nested deny and decoded protected-path + cases. Exported-function and `BASH_ENV` cases pin the outer-host boundary. + +## Migration Plan + +1. Update the central ShellSyntaxTree version. +2. Add the shared PowerShell child analysis. +3. Route hard-deny, protected-path, and approval matching through that analysis. +4. Add focused tests and approval matrix rows. +5. Run security, actor, OpenSpec, header, and Slopwatch checks. + +Rollback restores the previous package and analysis path. Stored approvals and +configuration need no conversion. + +## Open Questions + +None. diff --git a/openspec/changes/adopt-shellsyntax-alpha2-pwsh/proposal.md b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/proposal.md new file mode 100644 index 000000000..4bf7b29bc --- /dev/null +++ b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/proposal.md @@ -0,0 +1,54 @@ +## Why + +PRD-002 and PRD-006 require default-deny shell approval with useful approval +reuse. Netclaw still uses legacy token splitting for PowerShell child-process +commands, so it cannot safely reuse narrow approvals for complete PowerShell +pipelines and execution regions. + +## What Changes + +- Update Netclaw from ShellSyntaxTree `0.3.0-alpha.1` to + `0.3.0-alpha.2`. +- Detect one exact PowerShell host argv shape that the active Bash host can + pass to a child process. +- Parse each complete PowerShell payload with `PwshParser` in the safe + `Unknown` initial-state mode. +- Evaluate the outer PowerShell host occurrence and every child occurrence + before Netclaw reuses a safe verb or stored approval. +- Add an approval review matrix for complete read-only commands, pipelines, + executable script-block regions, data script blocks, dynamic values, + command-resolution changes, hard-deny rules, and protected paths. +- Keep the raw command and one-shot approval as the fallback when either the + outer shell or PowerShell analysis is incomplete. + +In scope: the exact `pwsh` command wrapper under the POSIX Bash host, +PowerShell occurrence analysis, the package reference, focused security tests, +the approval review matrix, and the canonical approval specification. + +Out of scope: `powershell`, `powershell.exe`, and `pwsh.exe` runtime identity, +Windows `cmd.exe` wrapper reuse, a new shell tool argument, a direct PowerShell +execution host, PowerShell profile or module baselines, isolated initial-state +claims, `Start-ThreadJob` special handling, analysis of external `.ps1` file +contents, new grant shapes, and stable ShellSyntaxTree v0.3. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `tool-approval-gates`: Add complete PowerShell child-command analysis before + Netclaw reuses safe verbs or stored shell approvals. + +## Impact + +- Code: shell analysis and approval matching in `Netclaw.Security`. +- Dependency: ShellSyntaxTree changes to `0.3.0-alpha.2`. +- Tests: focused parser-consumer tests and the approval review matrix. +- Security: incomplete outer-shell syntax, incomplete PowerShell syntax, + unknown command identity, unknown executable regions, and all Windows + PowerShell wrappers stay strict. +- Operations: no configuration, stored approval, or migration change is + required. diff --git a/openspec/changes/adopt-shellsyntax-alpha2-pwsh/specs/tool-approval-gates/spec.md b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/specs/tool-approval-gates/spec.md new file mode 100644 index 000000000..4a4e2ad2e --- /dev/null +++ b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/specs/tool-approval-gates/spec.md @@ -0,0 +1,128 @@ +## ADDED Requirements + +### Requirement: Complete PowerShell child commands use occurrence approval + +On a POSIX host, Netclaw SHALL prove the Bash wrapper before it parses a +PowerShell child payload. Approval reuse SHALL require exactly the direct +PowerShell 7 host token `pwsh`, `-NoProfile`, `-NonInteractive`, `-Command`, +and one quoted static non-stdin payload in that order. It SHALL reject every +other host spelling, host option, flag order, payload count, outer redirect, +and trailing argument. The `pwsh` comparison SHALL be ordinal and +case-sensitive. The three option-name comparisons SHALL be case-insensitive. +The child payload SHALL produce a complete `PwshParser` result in `Unknown` +initial-state mode. +Netclaw SHALL pass the exact Bash-decoded payload value to `PwshParser`. It +SHALL NOT ask the PowerShell parser to reinterpret the outer Bash source. + +Netclaw SHALL retain and evaluate the outer PowerShell host occurrence and +every child `CommandOccurrence`, effective path, redirect, and execution +region. It SHALL apply hard-deny and protected-path rules before safe verbs and +stored approvals. An incomplete outer wrapper, PowerShell parse, command +identity, occurrence, value, redirect, or execution region SHALL produce no +persistent candidate. Netclaw SHALL NOT reuse a +PowerShell child approval through the Windows `cmd.exe` host until it has a +complete outer-host grammar or a direct PowerShell execution path. + +#### Scenario: Approved host composes with safe child commands + +- **GIVEN** a direct no-profile non-interactive PowerShell wrapper in a trusted project directory +- **AND** a stored approval covers the outer `pwsh` host +- **AND** its exact payload contains only complete native safe commands +- **WHEN** Netclaw evaluates the shell invocation +- **THEN** Netclaw composes the host approval with the existing safe-verb policy +- **AND** Netclaw evaluates every child occurrence + +#### Scenario: Stored approval covers only the complete child command + +- **GIVEN** a direct no-profile non-interactive PowerShell wrapper has an exact payload +- **AND** stored approvals match both the outer host and one complete child command +- **WHEN** Netclaw evaluates the shell invocation +- **THEN** Netclaw reuses the stored `(verb, directory)` approval +- **AND** a stored approval for `pwsh` alone does not cover the child command + +#### Scenario: Inherited Bash function cannot hide behind a safe child + +- **GIVEN** `BASH_ENV` or an exported function can replace the authored `pwsh` host +- **AND** the PowerShell payload contains only a safe child command +- **WHEN** no stored approval covers the outer `pwsh` occurrence +- **THEN** Netclaw requires approval for the outer host +- **AND** the safe child does not authorize the invocation by itself + +#### Scenario: Proved direct script-block body receives an independent decision + +- **GIVEN** a complete PowerShell payload uses the intrinsic direct-call form for a script block +- **WHEN** the script block contains a command that needs approval or hard deny +- **THEN** Netclaw evaluates that body command as a separate occurrence +- **AND** approval for the outer `pwsh` host does not cover the body command + +#### Scenario: Unproved named script-block receiver stays strict + +- **GIVEN** a PowerShell payload passes a script block to an unknown or unproved named receiver +- **WHEN** ShellSyntaxTree marks its execution region incomplete +- **THEN** Netclaw requires one-shot approval or deny +- **AND** Netclaw offers no persistent approval candidate + +#### Scenario: PowerShell command-resolution change stays strict + +- **GIVEN** a PowerShell payload changes an alias or function before a later command +- **WHEN** the later command identity is incomplete +- **THEN** Netclaw requires one-shot approval or deny +- **AND** an existing approval for the visible command name does not authorize it + +#### Scenario: Nested hard deny wins before approval + +- **GIVEN** a complete PowerShell child payload contains a hard-deny command +- **AND** stored approvals cover the visible command names +- **WHEN** Netclaw evaluates the shell invocation +- **THEN** Netclaw denies the complete invocation +- **AND** it does not check or reuse stored approval + +#### Scenario: Decoded protected path wins before approval + +- **GIVEN** outer Bash quoting or literal fragments hide a protected path from a raw-token scan +- **AND** the complete PowerShell child analysis resolves that protected path +- **WHEN** Netclaw evaluates the shell invocation +- **THEN** Netclaw denies the complete invocation before approval reuse +- **AND** an existing approval for the child command does not bypass the path rule + +#### Scenario: Dynamic Bash payload stays strict + +- **GIVEN** Bash can change the PowerShell command payload before launch +- **WHEN** Netclaw cannot prove the exact child source +- **THEN** Netclaw requires one-shot approval or deny +- **AND** Netclaw offers no persistent approval candidate + +#### Scenario: Bash quote boundaries cannot hide a child command + +- **GIVEN** adjacent Bash quote segments decode into more than one PowerShell statement +- **WHEN** the decoded payload contains a nested hard-deny command +- **THEN** Netclaw evaluates that nested command +- **AND** a stored approval for the visible outer host cannot authorize the invocation + +#### Scenario: Host working-directory option stays strict + +- **GIVEN** an otherwise complete wrapper adds `-WorkingDirectory` before its payload +- **WHEN** Netclaw evaluates the PowerShell child command +- **THEN** Netclaw does not reuse a directory-scoped child approval +- **AND** Netclaw offers no persistent approval candidate + +#### Scenario: Windows PowerShell host spelling stays strict + +- **GIVEN** a Bash command uses `powershell` or `powershell.exe` +- **WHEN** Netclaw cannot prove the PowerShell 7 runtime identity +- **THEN** Netclaw requires one-shot approval or deny +- **AND** Netclaw offers no persistent child approval candidate + +#### Scenario: Differently cased POSIX host stays strict + +- **GIVEN** a Bash command uses `PWSH` instead of `pwsh` +- **WHEN** Netclaw evaluates the executable identity +- **THEN** Netclaw requires one-shot approval or deny +- **AND** Netclaw offers no persistent child approval candidate + +#### Scenario: Windows PowerShell wrapper stays strict + +- **GIVEN** `cmd.exe` launches a PowerShell child command +- **WHEN** Netclaw evaluates the wrapper without a complete `cmd.exe` grammar +- **THEN** Netclaw requires one-shot approval or deny +- **AND** Netclaw offers no persistent approval candidate diff --git a/openspec/changes/adopt-shellsyntax-alpha2-pwsh/tasks.md b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/tasks.md new file mode 100644 index 000000000..2620ab815 --- /dev/null +++ b/openspec/changes/adopt-shellsyntax-alpha2-pwsh/tasks.md @@ -0,0 +1,29 @@ +## 1. Dependency and wrapper proof + +- [x] 1.1 Update the central ShellSyntaxTree version to `0.3.0-alpha.2`. +- [x] 1.2 Add the exact POSIX argv proof for `pwsh`, `-NoProfile`, `-NonInteractive`, `-Command`, and one quoted static payload. +- [x] 1.3 Compare `pwsh` case-sensitively, compare its three option names case-insensitively, and reject every other host spelling, host option, flag order, dynamic or stdin payload, prefix wrapper, outer redirect, trailing argument, and Windows wrapper. +- [x] 1.4 Retain the outer `pwsh` occurrence so inherited Bash command resolution cannot hide behind safe children. + +## 2. Shared child occurrence analysis + +- [x] 2.1 Parse proved payloads with `PwshParser` in `Unknown` initial-state mode. +- [x] 2.2 Route hard-deny, protected-path, and approval matching through the same complete child occurrence list. +- [x] 2.3 Keep unknown identities, values, paths, redirects, execution regions, and command-resolution changes strict. + +## 3. Approval review matrix + +- [x] 3.1 Add composed host, safe-child, and stored-child approval cases for complete PowerShell commands. +- [x] 3.2 Add prompt cases for incomplete wrappers, `powershell`, `-WorkingDirectory`, dynamic values, unknown receivers, and command-resolution changes. +- [x] 3.3 Add deny and prompt cases for executable script-block bodies. +- [x] 3.4 Add a deny case for a protected path exposed only after outer decoding. +- [x] 3.5 Add exported-function and `BASH_ENV` cases that require outer-host approval. +- [x] 3.6 Update and inspect the review-table snapshot. +- [x] 3.7 Add a Windows-only matcher regression that rejects PowerShell host approval reuse. + +## 4. Verification and tracking + +- [x] 4.1 Run focused Netclaw.Security tests and the actor approval matrix. +- [x] 4.2 Run Slopwatch, header verification, and strict OpenSpec validation. +- [x] 4.3 Run the full solution test suite on the final diff. +- [x] 4.4 Update `IMPLEMENTATION_PLAN.md` with the delivered PowerShell evidence. diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs index f485796fe..17beffe4a 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs @@ -493,6 +493,112 @@ public static class ShellApprovalCases Bash("bash -lc \"git push\""), Approvals.PersistentAnywhere("bash"), ExpectedApproval.Require(["git push"])), + Case( + "pwsh-safe-child-still-requires-host-approval", + Bash("pwsh -NoProfile -NonInteractive -Command 'git status'"), + 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"])), + Case( + "pwsh-bash-env-risk-still-requires-host-approval", + Bash("pwsh -NoProfile -NonInteractive -Command 'git status'"), + Approvals.PersistentAnywhere("git status"), + 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")), + 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"])), + 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"])), + 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")), + 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)), + 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)), + 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)), + 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)), + Case( + "windows-powershell-host-fails-closed", + Bash("powershell -NoProfile -NonInteractive -Command 'git status'"), + Approvals.PersistentAnywhere("powershell", "git status"), + 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)), + Case( + "pwsh-dynamic-child-fails-closed", + Bash("pwsh -NoProfile -NonInteractive -Command 'git $operation'"), + Approvals.PersistentAnywhere("pwsh", "git"), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + 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)), + 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"), + ExpectedApproval.Require([], isMessy: true, approvalChecks: 0)), + Case( + "pwsh-data-script-block-stays-strict", + Bash("pwsh -NoProfile -NonInteractive -Command 'Write-Output { Remove-Item victim.txt }'"), + Approvals.PersistentAnywhere("pwsh", "Write-Output", "Remove-Item"), + 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"])), + 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")), + 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"), + ExpectedApproval.Deny("hard_deny_self_destructive")), Case( "env-nested-shell-prompts", Bash("env bash -lc \"git push\""), 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 a9d15731b..0a007fd93 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 @@ -61,6 +61,26 @@ | 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-data-script-block-stays-strict | Personal | Project | Interactive | pwsh -NoProfile -NonInteractive -Command 'Write-Output { Remove-Item victim.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 | diff --git a/src/Netclaw.Actors.Tests/Tools/ToolAccessPolicyRequiredDependenciesTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolAccessPolicyRequiredDependenciesTests.cs index 9416e81d8..bb0ab24ce 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolAccessPolicyRequiredDependenciesTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolAccessPolicyRequiredDependenciesTests.cs @@ -28,6 +28,8 @@ namespace Netclaw.Actors.Tests.Tools; /// public sealed class ToolAccessPolicyRequiredDependenciesTests { + public static bool IsPosix => !OperatingSystem.IsWindows(); + private static ToolConfig ShellConfig() => new() { ShellMode = ShellExecutionMode.HostAllowed }; @@ -82,4 +84,33 @@ 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() + { + var deniedRoot = Path.Combine( + Path.GetTempPath(), + "netclaw-protected-root", + "config"); + 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); + + var decision = policy.AuthorizeInvocation( + ShellTool(), + PersonalContext(), + ToolInput.Create("Command", command)); + + Assert.False(decision.Allowed); + Assert.Equal("shell_references_protected_path", decision.DenyReason); + } } diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 829efbb4c..3cccbd076 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -36,6 +36,38 @@ public sealed class ShellApprovalMatcherTests /// runners instead of hiding the gap behind an early-return. /// 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) + { + Assert.True(ShellApprovalMatcher.ContainsUnanalyzedPowerShellHost(command)); + } + + [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() + { + const string command = + "cmd.exe /d /s /c power\"shell\" -NoProfile -NonInteractive -Command git-status"; + var arguments = Args(command); + + 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( + new ToolName("shell_execute"), + arguments, + [Verb("cmd.exe")], + cwd: null)); + } [Fact] public void ExtractPatterns_simple_command() diff --git a/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs b/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs index 8c7a3cef7..ebe8361af 100644 --- a/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs +++ b/src/Netclaw.Security.Tests/ShellCommandAnalysisTests.cs @@ -55,6 +55,138 @@ public void Prefix_executable_is_retained_when_inner_command_is_expanded( Assert.Contains(analysis.Commands, command => command.Clause.Verb.Joined == "git status"); } + [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) + { + 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); + } + + [Fact] + public void Power_shell_host_is_retained_for_ambient_bash_resolution_controls() + { + var analysis = _analyzer.Analyze( + "pwsh -NoProfile -NonInteractive -Command 'git status'", + "/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); + } + + [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) + { + var analysis = _analyzer.Analyze(command, "/work"); + + Assert.Equal(ShellAnalysisFailure.Unresolved, analysis.Failure); + Assert.Empty(analysis.Commands); + } + + [Theory] + [InlineData("echo pwsh")] + [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) + { + var analysis = _analyzer.Analyze(command, "/work"); + + Assert.Equal(ShellAnalysisFailure.Unresolved, analysis.Failure); + Assert.Empty(analysis.Commands); + } + + [Fact] + public void Bash_dynamic_power_shell_payload_is_unresolved() + { + var analysis = _analyzer.Analyze( + "pwsh -NoProfile -NonInteractive -Command \"git $operation\"", + "/work"); + + Assert.Equal(ShellAnalysisFailure.Unresolved, analysis.Failure); + Assert.Empty(analysis.Commands); + } + + [Fact] + public void Bash_decoded_power_shell_payload_is_the_child_source_of_truth() + { + var analysis = _analyzer.Analyze( + "pwsh -NoProfile -NonInteractive -Command 'Write-Output '' ; netclaw daemon stop; #'''", + "/work"); + + Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); + Assert.Contains( + analysis.Commands, + command => command.Clause.Verb.Joined == "netclaw daemon stop"); + } + + [Fact] + public void Power_shell_dynamic_child_stays_dynamic() + { + var analysis = _analyzer.Analyze( + "pwsh -NoProfile -NonInteractive -Command 'git $operation'", + "/work"); + + Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); + Assert.True( + analysis.HasDynamicSyntax, + 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); + } + + [Fact] + public void Power_shell_proved_execution_region_is_complete() + { + var analysis = _analyzer.Analyze( + "pwsh -NoProfile -NonInteractive -Command '& { git push }'", + "/work"); + + Assert.Equal(ShellAnalysisFailure.None, analysis.Failure); + Assert.Equal( + ["pwsh", "git push"], + analysis.Commands.Select(command => command.Clause.Verb.Joined)); + Assert.False( + analysis.HasDynamicSyntax, + string.Join(" | ", analysis.Commands.Select(command => + $"{command.Clause.Verb.Joined}:complete={command.IsComplete}:" + + $"role={command.ImmediateRole}:cwd={command.WorkingDirectory.Kind}:" + + $"ancestry={string.Join(',', command.Ancestry.Select(frame => $"{frame.AncestorKind}/{frame.Region}"))}:" + + $"args={string.Join(',', command.Clause.Args.Select(arg => $"{arg.Raw}/{arg.Kind}/{arg.Resolved}"))}"))); + } + [Fact] public void Command_inspection_option_is_not_treated_as_transparent_shell_dispatch() { diff --git a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs index c64f0bede..fda565f3b 100644 --- a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs +++ b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs @@ -131,6 +131,16 @@ public void Denies_bourne_shell_wrapping_denied_command(string command) Assert.Equal(DenyCategory.SelfDestructive, decision.DenyCategory); } + [Fact] + public void Denies_power_shell_child_hard_deny_command() + { + var decision = _policy.EvaluateBash( + "pwsh -NoProfile -NonInteractive -Command 'netclaw daemon stop'"); + + Assert.False(decision.Allowed); + Assert.Equal(DenyCategory.SelfDestructive, decision.DenyCategory); + } + [Fact] public void Allows_bash_c_wrapping_safe_command() { diff --git a/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs b/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs index e8598c473..9fd56902e 100644 --- a/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs +++ b/src/Netclaw.Security.Tests/ToolPathPolicyTests.cs @@ -9,6 +9,8 @@ namespace Netclaw.Security.Tests; public sealed class ToolPathPolicyTests { + public static bool IsPosix => !OperatingSystem.IsWindows(); + [Fact] public void IsDenied_blocks_exact_match() { @@ -64,6 +66,18 @@ 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() + { + var policy = new ToolPathPolicy(["/protected/config"]); + const string command = + "pwsh -NoProfile -NonInteractive -Command 'Get-Content /protected/con\"fig/file.txt\"'"; + + Assert.DoesNotContain("/protected/config", command, StringComparison.Ordinal); + Assert.True(policy.CommandReferencesDeniedPath(command, "/work")); + } + [Fact] public void CommandReferencesDeniedPath_returns_false_for_empty() { diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index f318bb278..bc0a3082c 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -112,6 +112,9 @@ public sealed class ShellApprovalMatcher : IToolApprovalMatcher private static readonly ShellCommandAnalyzer Analyzer = ShellCommandAnalyzer.Bash; + private static readonly char[] WindowsCommandTokenSeparators = + [' ', '\t', '\r', '\n', '\'', '"', '&', '|', '(', ')', '<', '>', ';', '=', ',']; + public string GetApprovalModeKey(ToolName toolName, IDictionary? arguments) => toolName.Value; @@ -144,6 +147,9 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary { var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory); @@ -175,6 +181,9 @@ public IReadOnlyList ExtractCandidates(ToolName toolName, IDi if (!OperatingSystem.IsWindows()) return ExtractCandidatesViaBashAnalysis(command, GetWorkingDirectory(arguments)); + if (ContainsUnanalyzedPowerShellHost(command)) + return []; + var seen = new HashSet<(string, string?)>(); var candidates = new List(); TraverseApprovalUnits(command, unit => @@ -876,7 +885,10 @@ public bool IsMessy(ToolName toolName, IDictionary? arguments) return false; if (OperatingSystem.IsWindows()) - return ShellTokenizer.IsMessyCompoundCommand(command); + { + return ContainsUnanalyzedPowerShellHost(command) + || ShellTokenizer.IsMessyCompoundCommand(command); + } var workingDirectory = GetWorkingDirectory(arguments); var analysis = TryAnalyzeCommand(command, workingDirectory); @@ -905,6 +917,17 @@ public bool IsMessy(ToolName toolName, IDictionary? arguments) .Any(static redirect => ResolveRedirectDirectories(redirect) is null); } + 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 IsSideEffectCommand(ShellSyntaxTree.CommandOccurrence occurrence) { var clause = occurrence.Clause; diff --git a/src/Netclaw.Security/ShellCommandAnalysis.cs b/src/Netclaw.Security/ShellCommandAnalysis.cs index a6c6c4662..3469677f9 100644 --- a/src/Netclaw.Security/ShellCommandAnalysis.cs +++ b/src/Netclaw.Security/ShellCommandAnalysis.cs @@ -58,6 +58,22 @@ private static ShellAnalysisFailure Analyze( if (parsed.IsUnparseable || parsed.Commands.Count == 0) return ShellAnalysisFailure.Unresolved; + if (ContainsPowerShellHost(parsed.Commands)) + { + if (!TryAnalyzePowerShellChild( + command, + workingDirectory, + parsed.Commands, + out var childCommands)) + { + return ShellAnalysisFailure.Unresolved; + } + + commands.Add(parsed.Commands[0]); + commands.AddRange(childCommands); + return ShellAnalysisFailure.None; + } + var innerCommands = PosixShellApprovalSemantics.Instance.ExtractInnerCommands(command); var unexpandedWrappers = parsed.Commands .Where(static occurrence => IsUnexpandedWrapperClause(occurrence.Clause)) @@ -101,6 +117,131 @@ 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, @@ -281,6 +422,10 @@ internal sealed record ShellCommandAnalysis( || command.Clause.Verb.IsDynamic || command.Clause.Args.Any(static arg => arg.Kind == ArgKind.DynamicSkip && !arg.IsCwdAttribution) + || command.Clause.Args.Any(static arg => + arg.Kind == ArgKind.EnvVar + && !arg.IsCwdAttribution + && string.IsNullOrWhiteSpace(arg.Resolved)) || command.Clause.Args.Any(static arg => arg.IsPath && arg.Kind != ArgKind.Glob diff --git a/src/Netclaw.Security/ToolPathPolicy.cs b/src/Netclaw.Security/ToolPathPolicy.cs index e948cd50e..8f94c4c2c 100644 --- a/src/Netclaw.Security/ToolPathPolicy.cs +++ b/src/Netclaw.Security/ToolPathPolicy.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Text; +using ShellSyntaxTree; namespace Netclaw.Security; @@ -181,6 +182,12 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory return true; } + if (!OperatingSystem.IsWindows() + && StructuredAnalysisReferencesDeniedPath(command, workingDirectory)) + { + return true; + } + foreach (var token in tokens) { if (!LooksLikePath(token)) @@ -235,6 +242,63 @@ public bool CommandReferencesDeniedPath(string command, string? workingDirectory return false; } + private bool StructuredAnalysisReferencesDeniedPath( + string command, + string? workingDirectory) + { + var analysis = ShellCommandAnalyzer.Bash.Analyze(command, workingDirectory); + if (analysis.Failure != ShellAnalysisFailure.None) + return false; + + foreach (var occurrence in analysis.Commands) + { + foreach (var argument in occurrence.Clause.Args) + { + if (argument.IsPath + && !string.IsNullOrWhiteSpace(argument.Resolved) + && IsDeniedAgainst(argument.Resolved, _shellDeniedPaths)) + { + return true; + } + } + + foreach (var effective in occurrence.EffectiveArguments) + { + if (effective.ClauseElementIndex < 0 + || effective.ClauseElementIndex >= occurrence.Clause.Elements.Count + || !occurrence.Clause.Elements[effective.ClauseElementIndex].IsPath) + { + continue; + } + + if (DomainReferencesDeniedPath(effective.Value)) + return true; + } + + foreach (var redirect in occurrence.Redirects) + { + if (redirect.IsPathRelevant && DomainReferencesDeniedPath(redirect.Target)) + return true; + } + } + + return false; + } + + private bool DomainReferencesDeniedPath(ShellValueDomain domain) + { + if (domain.Kind is ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet) + { + return domain.Values.Any(value => + !string.IsNullOrWhiteSpace(value) + && IsDeniedAgainst(value, _shellDeniedPaths)); + } + + return domain.Kind == ShellValueDomainKind.Pattern + && !string.IsNullOrWhiteSpace(domain.CoveringDirectory) + && IsDeniedAgainst(domain.CoveringDirectory, _shellDeniedPaths); + } + private static bool ContainsProtectedPathHint(string slashCommand) { return slashCommand.Contains(".netclaw/config", StringComparison.OrdinalIgnoreCase)