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)