diff --git a/README.md b/README.md index 3424a7162..5b39cb96d 100644 --- a/README.md +++ b/README.md @@ -315,20 +315,22 @@ Packaged installs declare camera, microphone, and location capabilities. Windows # Speak text aloud on the Windows node (requires TTS enabled in Settings and tts.speak allowed on the gateway) openclaw nodes invoke --node --command tts.speak --params '{"text":"Hello from OpenClaw","provider":"windows"}' - # Execute a command on the Windows node - openclaw nodes invoke --node --command system.run --params '{"command":"Get-Process | Select -First 5","shell":"powershell","timeoutMs":10000}' + # Execute a command on the Windows node (raw node.invoke requires canonical argv) + openclaw gateway call node.invoke --params '{"nodeId":"","command":"system.run","params":{"command":["cmd.exe","/d","/s","/c","echo hello"],"rawCommand":"echo hello","timeoutMs":10000}}' --json # View exec approval policy openclaw nodes invoke --node --command system.execApprovals.get - # Update exec approval policy (add custom rules) - openclaw nodes invoke --node --command system.execApprovals.set --params '{"rules":[{"pattern":"echo *","action":"allow"},{"pattern":"*","action":"deny"}],"defaultAction":"deny"}' + # Update exec approvals using baseHash from the preceding get response + openclaw nodes invoke --node --command system.execApprovals.set --params '{"baseHash":"","file":{"version":1,"defaults":{"security":"allowlist","ask":"on-miss","askFallback":"deny","autoAllowSkills":false},"agents":{"main":{"security":"allowlist","ask":"on-miss","askFallback":"deny","autoAllowSkills":false,"allowlist":[]}}}}' ``` > ๐Ÿ“ท **Camera permission**: Desktop builds rely on Windows Privacy settings. Packaged MSIX builds will show the system consent prompt. - > ๐Ÿ”’ **Exec Policy**: `system.run` is gated by an approval policy on the Windows node at `%LOCALAPPDATA%\OpenClawTray\exec-policy.json` (schema: `{ "defaultAction": "...", "rules": [...] }`). This is separate from gateway-side `~/.openclaw/exec-approvals.json`. + > ๐Ÿ”’ **Exec approvals**: `system.run` is gated by the V2 approval coordinator and `%APPDATA%\OpenClawTray\exec-approvals.json`. The file uses the same `defaults`/`agents`/`allowlist` model as the macOS node host. `system.execApprovals.set` requires the current `baseHash` and rejects stale or unsafe remote updates. > - > Rules are matched against the full command line. Known wrapper payloads such as `cmd /c ...`, `powershell -Command ...`, `pwsh -EncodedCommand ...`, and `bash -c ...` are also evaluated before execution. Dangerous environment overrides like `PATH`, `PATHEXT`, `NODE_OPTIONS`, `GIT_SSH_COMMAND`, `LD_*`, and `DYLD_*` are rejected. + > Allowlist rules match resolved executable paths, using path-aware wildcards such as `**/git.exe`. Script interpreters and command hosts cannot receive reusable grants. Non-empty custom environments are rejected until they can be identity-bound and displayed safely. + > + > **V2 caller migration**: raw MCP, direct `node.invoke`, plugin, and `winnode` callers must replace string-form `{"command":"echo hello","shell":"cmd"}` with canonical `{"command":["cmd.exe","/d","/s","/c","echo hello"],"rawCommand":"echo hello"}`. The normal gateway `exec host=node` path already performs this wrapping. Remove custom `env`; non-empty environments are rejected. #### Command Center diagnostics @@ -341,7 +343,7 @@ Open the status detail/Command Center from the tray menu or with `openclaw://com - recent activity and node invoke results through the Activity Stream, storing command names/status/duration only (not payloads, screenshots, recordings, or secrets) > > ```bash - > openclaw nodes invoke --node --command system.execApprovals.set --params '{"rules":[{"pattern":"powershell.exe","action":"allow"},{"pattern":"pwsh.exe","action":"allow"},{"pattern":"echo *","action":"allow"},{"pattern":"*","action":"deny"}],"defaultAction":"deny"}' + > openclaw nodes invoke --node --command system.execApprovals.set --params '{"baseHash":"","file":{"version":1,"defaults":{"security":"allowlist","ask":"off","askFallback":"deny","autoAllowSkills":false},"agents":{"main":{"security":"allowlist","ask":"off","askFallback":"deny","autoAllowSkills":false,"allowlist":[]}}}}' > ``` > ๐Ÿ” **Web Chat secure context**: Remote web chat requires `https://` (or localhost). If using a self-signed cert, trust it in Windows (Trusted Root Certification Authorities) or use an SSH tunnel to localhost. diff --git a/docs/MCP_MODE.md b/docs/MCP_MODE.md index 8f414364e..893579f34 100644 --- a/docs/MCP_MODE.md +++ b/docs/MCP_MODE.md @@ -145,7 +145,7 @@ MCP startup is reported from the actual listener state. `NodeService.McpStartupE ### Testing -The tray's most interesting code lives in capabilities โ€” `system.run` (LocalCommandRunner + ExecApprovalPolicy), `screen.snapshot` (Windows.Graphics.Capture + GraphicsCapturePicker), `canvas.*` (WebView2 with trusted origin enforcement), `camera.snap`/`camera.clip` (MediaCapture + consent prompt), `location.get` (Windows.Devices.Geolocation). All of that has nontrivial Windows-only behavior and almost none of it is currently exercised end-to-end without first standing up a gateway and authenticating. +The tray's most interesting code lives in capabilities: `system.run` (LocalCommandRunner + the V2 exec-approval coordinator), `screen.snapshot` (Windows.Graphics.Capture + GraphicsCapturePicker), `canvas.*` (WebView2 with trusted origin enforcement), `camera.snap`/`camera.clip` (MediaCapture + consent prompt), and `location.get` (Windows.Devices.Geolocation). All of that has nontrivial Windows-only behavior and almost none of it is currently exercised end-to-end without first standing up a gateway and authenticating. Local MCP changes that. Concrete benefits: @@ -260,7 +260,7 @@ The server is built on several defensive layers, not just one. Loopback alone is Together these three checks force a malicious cross-origin browser fetch into a CORS preflight that we deliberately do not honor (no `Access-Control-Allow-*` is ever emitted), so the actual call is blocked before reaching capability code. 4. **Bearer token.** Every request must include the persistent local MCP bearer token (`Authorization: Bearer `) once the server has created `%APPDATA%\OpenClawTray\mcp-token.txt`. This blocks drive-by local clients that know the port but cannot read the per-user token file. 5. **Concurrency cap.** A semaphore limits in-flight handlers to 8. A misbehaving local client cannot pin every threadpool thread on long-running screen/camera calls. -6. **Capability-level controls remain in force.** `SystemCapability.SetApprovalPolicy(...)` (the exec approval policy) still gates `system.run`. Camera and screen capture still go through Windows consent flows. MCP doesn't bypass any of those. +6. **Capability-level controls remain in force.** The V2 exec-approval coordinator still gates `system.run`. Camera and screen capture still go through Windows consent flows. MCP doesn't bypass any of those. **Authentication is local bearer-token based.** The token is persistent, generated by the tray, stored in the current user's OpenClawTray data directory, and verified before MCP method dispatch. It is defense-in-depth rather than a hard sandbox boundary: a malicious process already running as the same user may still be able to read user-profile files or invoke native APIs directly. If we need stronger isolation for shared machines or low-trust local processes, the next step is scoped or per-call tokens issued by the tray, not URL ACLs or HTTPS โ€” both add deployment pain without solving the same-user trust problem. diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 1dfcda77a..4f9c0b4ab 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -294,8 +294,10 @@ Reviewed attributes are: - `openclaw.node.tool.transport`: `gateway` or `mcp` - `openclaw.outcome`: `success`, `failure`, or `canceled` - `openclaw.error.category`: a finite typed category -- `openclaw.node.tool.system_run.approval.pipeline`: `legacy` for the existing - approval policy or `v2` for the opt-in direct-argv approval pipeline; present +- `openclaw.node.tool.system_run.approval.pipeline`: `v2` for the authoritative + canonical-argv approval pipeline; current `system.run` always emits this value. + `legacy` remains a finite historical value for backward-compatible telemetry + readers, but the runtime no longer selects the legacy approval path. Present only for `system.run` traces and failure/cancellation logs - `openclaw.node.tool.sandbox.requested`: whether sandboxing was configured - `openclaw.node.tool.sandbox.applied`: whether the command was known to run diff --git a/docs/WINDOWS_NODE_ARCHITECTURE.md b/docs/WINDOWS_NODE_ARCHITECTURE.md index 97dfd224d..e907874f1 100644 --- a/docs/WINDOWS_NODE_ARCHITECTURE.md +++ b/docs/WINDOWS_NODE_ARCHITECTURE.md @@ -254,8 +254,8 @@ Niche scenario. If the "server" must be Windows for some reason, this works but | `camera.clip` | โœ… | โœ… | โœ… | โŒ | **โœ…** | MediaCapture + MediaEncoding | | `camera.list` | โœ… | โœ… | โœ… | โŒ | **โœ…** | DeviceInformation.FindAllAsync | | `screen.record` | โœ… CGWindowListCreateImage | โœ… ReplayKit | โœ… MediaProjection | โŒ | **โœ…** | Windows.Graphics.Capture | -| `system.run` | โœ… | โŒ | โŒ | โœ… | **โœ…** | Process.Start (cmd/pwsh) + ExecApprovalPolicy | -| `system.execApprovals` | โŒ | โŒ | โŒ | โŒ | **โœ…** | JSON policy file (exec-policy.json) | +| `system.run` | โœ… | โŒ | โŒ | โœ… | **โœ…** | Process.Start + V2 exec-approval coordinator | +| `system.execApprovals` | โŒ | โŒ | โŒ | โŒ | **โœ…** | V2 store (`exec-approvals.json`) with base-hash CAS | | `system.notify` | โœ… NSUserNotification | โœ… UNUserNotification | โœ… NotificationManager | โŒ | **โœ…** | ToastNotificationManager | | `location.get` | โœ… CLLocationManager | โœ… CLLocationManager | โœ… FusedLocation | โŒ | **โœ…** | Windows.Devices.Geolocation | | `device.info/status` | โœ… shared schema | โœ… shared schema | โœ… shared schema | โŒ | **โœ…** | .NET runtime, storage, network | @@ -453,7 +453,51 @@ string stderr = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); ``` -**Critical:** Exec approvals must be enforced locally, same as macOS/headless nodes. Store in `%APPDATA%\OpenClaw\exec-approvals.json`. +**Critical:** Exec approvals must be enforced locally, same as macOS/headless nodes. The default store is `%APPDATA%\OpenClawTray\exec-approvals.json`; `OPENCLAW_STATE_DIR` overrides that location. + +#### Decision: retire Windows V1 exec policy without migration + +Windows V1 `exec-policy.json` command-text globs are not evaluated or converted after the +V2 cutover. When no valid V2 policy exists, the node uses an empty allowlist with +prompt-on-miss and deny fallback. + +**Consequences:** + +- Existing V1 files remain untouched during normal runtime but no longer authorize or deny execution. +- Prior V1 allows require attended V2 reapproval; unattended calls deny. +- Prior V1 denies are not imported and may be explicitly superseded through an attended V2 prompt. +- Malformed or untrusted V2 state remains hard deny and is never replaced from V1 state. + +This compatibility break is accepted because V1 matched shell command text while V2 binds +resolved executable identity and canonical argv. Mechanical conversion could widen a narrow +command rule into a reusable `cmd.exe` or PowerShell grant, while retaining both evaluators +would preserve parallel authorization paths and bypass drift. + +**Rejected alternatives:** V1 runtime fallback, mechanical rule conversion, and file-triggered +migration UI. Revisit this decision only if measured support impact justifies a separate +compatibility feature. + +#### Decision: version the low-level `system.run` boundary as canonical argv + +The V2 low-level node contract accepts `command` only as `string[]` canonical argv. +String-form `command`, implicit `shell`, separate `args`, and non-empty custom `env` +are intentional breaking changes. The normal gateway `exec host=node` flow already +constructs canonical Windows argv, so this affects raw MCP, direct `node.invoke`, +plugins, and other callers that bypass gateway exec orchestration. + +Migration example: + +```json +// Before +{"command":"echo hello","shell":"cmd"} + +// V2 +{"command":["cmd.exe","/d","/s","/c","echo hello"],"rawCommand":"echo hello"} +``` + +The node returns `command-array-required` for a string command and +`custom-env-not-supported` for a non-empty environment. This explicit boundary +keeps approval identity and process execution on one argv representation. ### Location โ†’ Windows.Devices.Geolocation diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index 8b7999a6b..c18062d10 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -99,6 +99,50 @@ function Resolve-AppDataDir { return Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) $DataDirectoryName } + function Resolve-UsablePathValue { + param([string]$Value) + $trimmed = if ($null -eq $Value) { '' } else { $Value.Trim() } + if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -in @('undefined', 'null')) { + return $null + } + return $trimmed + } + + function Expand-ExecApprovalsHomePath { + param([string]$Path, [string]$Home) + if ($Path -eq '~') { return $Home } + if ($Path.StartsWith('~\') -or $Path.StartsWith('~/')) { + return Join-Path $Home $Path.Substring(2) + } + return $Path + } + + function Resolve-ExecApprovalsPaths { + param([string]$DataDir) + + $legacyPath = Join-Path $DataDir 'exec-approvals.json' + $stateDir = Resolve-UsablePathValue $env:OPENCLAW_STATE_DIR + if (-not $stateDir) { return @($legacyPath) } + + $osHome = Resolve-UsablePathValue $env:HOME + if (-not $osHome) { $osHome = Resolve-UsablePathValue $env:USERPROFILE } + if (-not $osHome) { + $osHome = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) + } + if (-not $osHome) { $osHome = (Get-Location).Path } + + $openClawHome = Resolve-UsablePathValue $env:OPENCLAW_HOME + $effectiveHome = if ($openClawHome) { + [IO.Path]::GetFullPath((Expand-ExecApprovalsHomePath $openClawHome $osHome)) + } else { + [IO.Path]::GetFullPath($osHome) + } + $resolvedStateDir = [IO.Path]::GetFullPath( + (Expand-ExecApprovalsHomePath $stateDir $effectiveHome)) + $activePath = Join-Path $resolvedStateDir 'exec-approvals.json' + return @($activePath, $legacyPath) | Select-Object -Unique + } + function Get-JsonPropertyValue { param( [object]$Object, @@ -451,7 +495,12 @@ function Resolve-AppDataDir { Remove-FileIfExists -Path (Join-Path $dataDir 'setup-state.json') -Label 'legacy setup-state.json' Remove-FileIfExists -Path (Join-Path $localDataDir 'setup-state.json') -Label 'setup-state.json' Remove-FileIfExists -Path (Join-Path $localDataDir 'run.marker') -Label 'run.marker' + foreach ($execApprovalsPath in (Resolve-ExecApprovalsPaths -DataDir $dataDir)) { + Remove-FileIfExists -Path $execApprovalsPath -Label 'exec-approvals.json' + } + Remove-FileIfExists -Path (Join-Path $localDataDir 'exec-approvals.json') -Label 'local legacy exec-approvals.json' Remove-FileIfExists -Path (Join-Path $dataDir 'exec-policy.json') -Label 'exec-policy.json' + Remove-FileIfExists -Path (Join-Path $localDataDir 'exec-policy.json') -Label 'local exec-policy.json' Remove-KeepaliveMarker -LocalDataDir $localDataDir $registryCleanup = Remove-SetupManagedGatewayRecords -DataDir $dataDir diff --git a/scripts/validate-wsl-gateway-uninstall.ps1 b/scripts/validate-wsl-gateway-uninstall.ps1 index fc94a6a32..eff100998 100644 --- a/scripts/validate-wsl-gateway-uninstall.ps1 +++ b/scripts/validate-wsl-gateway-uninstall.ps1 @@ -66,7 +66,8 @@ Mirrors LocalGatewayUninstallOptions.PreserveLogs. .PARAMETER PreserveExecPolicy - When $true (default), exec-policy.json is not deleted in Full mode. + When $true (default), exec-approvals.json and the retired exec-policy.json + are not deleted in Full mode. Mirrors LocalGatewayUninstallOptions.PreserveExecPolicy. .PARAMETER OutputDir @@ -97,7 +98,7 @@ .\validate-wsl-gateway-uninstall.ps1 -Mode PostconditionOnly .EXAMPLE - # Full uninstall preserving logs but deleting exec-policy: + # Full uninstall preserving logs but deleting exec approval files: .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive -PreserveExecPolicy $false .NOTES @@ -166,7 +167,7 @@ OPTIONS: -ConfirmDestructive Required for Full mode (unless -DryRun is also set). -DistroName Default: OpenClawGateway (must be exact match) -PreserveLogs Default: $true (do not delete gateway logs) - -PreserveExecPolicy Default: $true (do not delete exec-policy.json) + -PreserveExecPolicy Default: $true (do not delete exec approval files) -OutputDir Default: .\uninstall-validation-output\\ -DryRun Full mode records steps without any destruction. -NoCli Full mode: skip CLI delegate; use inline PS replication. @@ -223,14 +224,67 @@ if ($Mode -eq 'Full' -and (-not $DryRun) -and (-not $ConfirmDestructive)) { # Path constants # --------------------------------------------------------------------------- +function Resolve-UsablePathValue { + param([string]$Value) + $trimmed = if ($null -eq $Value) { '' } else { $Value.Trim() } + if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -in @('undefined', 'null')) { + return $null + } + return $trimmed +} + +function Expand-ExecApprovalsHomePath { + param([string]$Path, [string]$Home) + if ($Path -eq '~') { return $Home } + if ($Path.StartsWith('~\') -or $Path.StartsWith('~/')) { + return Join-Path $Home $Path.Substring(2) + } + return $Path +} + +function Resolve-ExecApprovalsPaths { + param([string]$DataDir) + + $legacyPath = Join-Path $DataDir 'exec-approvals.json' + $stateDir = Resolve-UsablePathValue $env:OPENCLAW_STATE_DIR + if (-not $stateDir) { return @($legacyPath) } + + $osHome = Resolve-UsablePathValue $env:HOME + if (-not $osHome) { $osHome = Resolve-UsablePathValue $env:USERPROFILE } + if (-not $osHome) { + $osHome = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) + } + if (-not $osHome) { $osHome = (Get-Location).Path } + + $openClawHome = Resolve-UsablePathValue $env:OPENCLAW_HOME + $effectiveHome = if ($openClawHome) { + [IO.Path]::GetFullPath((Expand-ExecApprovalsHomePath $openClawHome $osHome)) + } else { + [IO.Path]::GetFullPath($osHome) + } + $resolvedStateDir = [IO.Path]::GetFullPath( + (Expand-ExecApprovalsHomePath $stateDir $effectiveHome)) + $activePath = Join-Path $resolvedStateDir 'exec-approvals.json' + return @($activePath, $legacyPath) | Select-Object -Unique +} + $appData = $env:APPDATA $localAppData = $env:LOCALAPPDATA +$trayDataDir = if ($env:OPENCLAW_TRAY_DATA_DIR) { + $env:OPENCLAW_TRAY_DATA_DIR +} else { + Join-Path $appData 'OpenClawTray' +} $setupStatePath = Join-Path $localAppData "OpenClawTray\setup-state.json" $deviceKeyPath = Join-Path $appData "OpenClawTray\device-key-ed25519.json" $mcpTokenPath = Join-Path $appData "OpenClawTray\mcp-token.txt" $settingsPath = Join-Path $appData "OpenClawTray\settings.json" $logsDir = Join-Path $localAppData "OpenClawTray\Logs" +$execApprovalsPaths = @(Resolve-ExecApprovalsPaths -DataDir $trayDataDir) +$execApprovalsPaths += Join-Path $localAppData "OpenClawTray\exec-approvals.json" +$execApprovalsPaths = @($execApprovalsPaths | Select-Object -Unique) +$execApprovalsPath = $execApprovalsPaths[0] $execPolicyPath = Join-Path $localAppData "OpenClawTray\exec-policy.json" $vhdDirPath = Join-Path $localAppData "OpenClawTray\wsl\$DistroName" $wslParentDirPath = Join-Path $localAppData "OpenClawTray\wsl" @@ -373,6 +427,7 @@ function Get-StateSnapshot { device_key_exists = (Test-Path -LiteralPath $deviceKeyPath) mcp_token_exists = (Test-Path -LiteralPath $mcpTokenPath) settings_exists = (Test-Path -LiteralPath $settingsPath) + exec_approvals_exists = (Test-Path -LiteralPath $execApprovalsPath) exec_policy_exists = (Test-Path -LiteralPath $execPolicyPath) vhd_dir_exists = (Test-Path -LiteralPath $vhdDirPath) wsl_parent_dir_exists = (Test-Path -LiteralPath $wslParentDirPath) @@ -767,24 +822,31 @@ function Invoke-UninstallSteps { } # ----------------------------------------------------------------- Step 10 - # Delete exec-policy.json (unless PreserveExecPolicy=true). + # Delete current and retired exec approval files (unless PreserveExecPolicy=true). # ----------------------------------------------------------------- Step 10 try { if ($PreserveExecPolicy) { - Add-Step -Name 'delete-exec-policy' -Status 'Skipped' -Message 'PreserveExecPolicy=true.' - } elseif (-not (Test-Path -LiteralPath $execPolicyPath)) { - Add-Step -Name 'delete-exec-policy' -Status 'Skipped' -Message 'exec-policy.json not found.' + Add-Step -Name 'delete-exec-approvals' -Status 'Skipped' -Message 'PreserveExecPolicy=true.' + } elseif (-not ($execApprovalsPaths | Where-Object { Test-Path -LiteralPath $_ }) -and -not (Test-Path -LiteralPath $execPolicyPath)) { + Add-Step -Name 'delete-exec-approvals' -Status 'Skipped' -Message 'Exec approval files not found.' } elseif ($IsDryRun) { - Add-Step -Name 'delete-exec-policy' -Status 'DryRun' ` - -Message "Would delete: $execPolicyPath" + Add-Step -Name 'delete-exec-approvals' -Status 'DryRun' ` + -Message "Would delete: $($execApprovalsPaths -join ', ') and retired $execPolicyPath" } else { - Remove-Item -LiteralPath $execPolicyPath -Force - Add-Step -Name 'delete-exec-policy' -Status 'Executed' ` - -Message "Deleted $execPolicyPath." + foreach ($path in $execApprovalsPaths) { + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Force + } + } + if (Test-Path -LiteralPath $execPolicyPath) { + Remove-Item -LiteralPath $execPolicyPath -Force + } + Add-Step -Name 'delete-exec-approvals' -Status 'Executed' ` + -Message "Deleted exec approval files." } } catch { - $stepErrors.Add("delete-exec-policy: $($_.Exception.Message)") - Add-Step -Name 'delete-exec-policy' -Status 'Failed' -Message $_.Exception.Message + $stepErrors.Add("delete-exec-approvals: $($_.Exception.Message)") + Add-Step -Name 'delete-exec-approvals' -Status 'Failed' -Message $_.Exception.Message } # ----------------------------------------------------------------- Step 11 diff --git a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs index 65f625e4b..05ab0b3d9 100644 --- a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs +++ b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs @@ -2,6 +2,7 @@ using Microsoft.Win32; using OpenClaw.Connection; using OpenClaw.Shared; +using OpenClaw.Shared.ExecApprovals; namespace OpenClaw.SetupEngine; @@ -52,8 +53,27 @@ public static void Run( // 2. Delete run.marker DeleteFileIfExists(Path.Combine(localDataDir, "run.marker"), "run.marker", logger); - // 3. Delete exec-policy.json + // 3. Delete current exec approvals (including an alternate OPENCLAW_STATE_DIR) + // and the retired policy file. + var activeExecApprovalsPath = ExecApprovalsStore.ResolveFilePath(appDataDir); + DeleteFileIfExists(activeExecApprovalsPath, "exec-approvals.json", logger); + var legacyExecApprovalsPath = Path.Combine(appDataDir, "exec-approvals.json"); + if (!string.Equals( + Path.GetFullPath(activeExecApprovalsPath), + Path.GetFullPath(legacyExecApprovalsPath), + StringComparison.OrdinalIgnoreCase)) + { + DeleteFileIfExists( + legacyExecApprovalsPath, + "legacy exec-approvals.json", + logger); + } + DeleteFileIfExists( + Path.Combine(localDataDir, "exec-approvals.json"), + "local legacy exec-approvals.json", + logger); DeleteFileIfExists(Path.Combine(appDataDir, "exec-policy.json"), "exec-policy.json", logger); + DeleteFileIfExists(Path.Combine(localDataDir, "exec-policy.json"), "local exec-policy.json", logger); // 4. Reset onboarding settings in settings.json ResetOnboardingSettings(appDataDir, logger, preserveNodeSettings: HasRemainingGatewayRecords(appDataDir, logger)); diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index eb60e621a..bde102ebe 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -36,49 +36,6 @@ public class SystemCapability : NodeCapabilityBase "system.execApprovals.set" }; - private static readonly string[] DangerousAllowPatternFragments = - [ - "remove-item", - "rm ", - "del ", - "erase ", - "rd ", - "rmdir ", - "format-", - "stop-computer", - "restart-computer", - "shutdown", - "invoke-webrequest", - "invoke-restmethod", - "invoke-expression", - "iex ", - "invoke-command", - "icm ", - "start-process", - "set-executionpolicy", - "reg ", - "net ", - // Living-off-the-land binaries: native tools whose purpose here is code execution or remote - // download-and-run โ€” the same intent already blocked for the PowerShell downloaders - // (invoke-webrequest/-restmethod) and process spawning (start-process), but in native-binary - // form the fragments above miss. A remote .set must not be able to whitelist one and invoke - // it (mshta/regsvr32/rundll32 run remote script; certutil/bitsadmin/curl/wget download). A - // denylist cannot be exhaustive against the LOLBAS set โ€” the local Permissions UI, not a - // remote caller, is the place to allow anything broader than the read-only defaults. - "mshta", - "rundll32", - "regsvr32", - "regsvcs", - "regasm", - "installutil", - "msbuild", - "wmic", - "certutil", - "bitsadmin", - "curl", - "wget" - ]; - private readonly bool _includeRunCommands; public override IReadOnlyList Commands => @@ -87,31 +44,18 @@ public class SystemCapability : NodeCapabilityBase // Event to let UI handle the actual notification display public event EventHandler? NotifyRequested; - /// - /// Fired when policy denies an exec request non-interactively (no native - /// prompt is shown โ€” e.g. default action is Deny, or matched rule action - /// is Deny). Lets UI surfaces (chat) render a denial card that would - /// otherwise be invisible to the user. Not raised when the user clicks - /// Deny in the native prompt โ€” that path already raises - /// . - /// - public event EventHandler? PolicyAutoDecided; - // Command runner for system.run (swappable: local, docker, wsl) private ICommandRunner? _commandRunner; - // Exec approval policy (optional - if null, all commands are allowed) - private ExecApprovalPolicy? _approvalPolicy; - private IExecApprovalPromptHandler? _promptHandler; + private ExecApprovalsStore? _approvalsStore; - // V2 exec approval handler (null = legacy path; inert until explicitly set) - private IExecApprovalV2Handler? _v2Handler; + private IExecApprovalV2Handler _v2Handler = ExecApprovalV2NullHandler.Instance; /// Capability logger. /// /// When false, system.run and system.run.prepare are dropped /// from and rejects them - /// with a clear error before any V2/legacy dispatch. The rest of the + /// with a clear error before V2 dispatch. The rest of the /// system category (notify/which/execApprovals.get/set) is /// unaffected. Wired from the tray "Run system tools" permission toggle /// via NodeCapabilityGating.ShouldRegisterSystemRun. @@ -120,7 +64,7 @@ public SystemCapability(IOpenClawLogger logger, bool includeRunCommands = true) { _includeRunCommands = includeRunCommands; } - + /// /// Set the command runner implementation (local, docker, wsl, etc.) /// @@ -129,22 +73,13 @@ public void SetCommandRunner(ICommandRunner runner) _commandRunner = runner; } - /// - /// Set the exec approval policy. When set, system.run checks approval before executing. - /// - public void SetApprovalPolicy(ExecApprovalPolicy policy) + public void SetApprovalsStore(ExecApprovalsStore approvalsStore) { - _approvalPolicy = policy; - } - - public void SetPromptHandler(IExecApprovalPromptHandler promptHandler) - { - _promptHandler = promptHandler; + _approvalsStore = approvalsStore; } /// - /// Install a V2 exec approval handler. When set, system.run routes to the V2 path - /// instead of the legacy path. The V2 path is inert until this is called. + /// Install the exec approval handler used by every system.run request. /// public void SetV2Handler(IExecApprovalV2Handler handler) { @@ -153,7 +88,7 @@ public void SetV2Handler(IExecApprovalV2Handler handler) public override async Task ExecuteAsync(NodeInvokeRequest request) { - // "Run system tools" kill switch โ€” applied before V2/legacy dispatch + // "Run system tools" kill switch โ€” applied before approval dispatch // so stale gateway allowlists and cached MCP clients still see the // capability as disabled when the user turned it off. if (!_includeRunCommands && @@ -171,8 +106,8 @@ public override async Task ExecuteAsync(NodeInvokeRequest re "system.run" => await HandleRunAsync(request), "system.run.prepare" => HandleRunPrepare(request), "system.which" => HandleWhich(request), - "system.execApprovals.get" => HandleExecApprovalsGet(), - "system.execApprovals.set" => HandleExecApprovalsSet(request), + "system.execApprovals.get" => await HandleExecApprovalsGetAsync(), + "system.execApprovals.set" => await HandleExecApprovalsSetAsync(request), _ => Error($"Unknown command: {request.Command}") }; } @@ -258,363 +193,120 @@ private NodeInvokeResponse HandleWhich(NodeInvokeRequest request) private static string FormatExecCommand(string[] argv) => ShellQuoting.FormatExecCommand(argv); /// - /// Parses a JSON "command" property as either a string array or a plain string. - /// Returns the argv array (command as first element) or null if missing/invalid. - /// - private static string[]? TryParseArgv(System.Text.Json.JsonElement requestArgs) - { - if (requestArgs.ValueKind == System.Text.Json.JsonValueKind.Undefined || - !requestArgs.TryGetProperty("command", out var cmdEl)) - return null; - - if (cmdEl.ValueKind == System.Text.Json.JsonValueKind.Array) - { - var list = new List(); - foreach (var item in cmdEl.EnumerateArray()) - { - if (item.ValueKind == System.Text.Json.JsonValueKind.String) - list.Add(item.GetString() ?? ""); - } - return list.Count > 0 ? list.ToArray() : null; - } - - if (cmdEl.ValueKind == System.Text.Json.JsonValueKind.String) - { - var command = cmdEl.GetString(); - return command != null ? new[] { command } : null; - } - - return null; - } - - /// - /// Pre-flight for system.run: echoes back the execution plan without running anything. + /// Pre-flight for system.run: validates and echoes back the canonical execution plan + /// without running anything. /// The gateway uses this to build its approval context before the actual run. /// private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request) { - var argv = TryParseArgv(request.Args); - if (argv == null || argv.Length == 0 || string.IsNullOrWhiteSpace(argv[0])) - { - return Error("Missing command parameter"); - } - - var command = argv[0]; + var validation = ExecApprovalV2InputValidator.Validate(request); + if (!validation.IsValid) + return Error($"Invalid system.run request: {validation.Error!.Reason}"); + + var validated = validation.Request!; + var argv = validated.Argv; var rawCommand = GetStringArg(request.Args, "rawCommand"); - var requestedShell = GetStringArg(request.Args, "shell"); - var effectiveShell = _commandRunner?.ResolveEffectiveShell(requestedShell) - ?? ResolveDefaultEffectiveShell(requestedShell); - var cwd = GetStringArg(request.Args, "cwd"); - var agentId = GetStringArg(request.Args, "agentId"); - var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey"); - + var sessionKey = request.SessionKey ?? validated.SessionKey; + Logger.Info( - $"system.run.prepare: {rawCommand} (shell={effectiveShell}, requestedShell={requestedShell ?? "auto"}, cwd={cwd ?? "default"})"); - + $"system.run.prepare: {rawCommand ?? FormatExecCommand(argv)} (cwd={validated.Cwd ?? "default"})"); + return Success(new { cmdText = rawCommand ?? FormatExecCommand(argv), plan = new { argv, - cwd, + cwd = validated.Cwd, rawCommand, - requestedShell = string.IsNullOrWhiteSpace(requestedShell) ? null : requestedShell.Trim(), - effectiveShell, - agentId, + agentId = validated.AgentId, sessionKey } }); } - - private static string ResolveDefaultEffectiveShell(string? requestedShell) - { - if (string.IsNullOrWhiteSpace(requestedShell)) - return "powershell"; - - return requestedShell.Trim().ToLowerInvariant() switch - { - "cmd" => "cmd", - "pwsh" => "pwsh", - "powershell" => "powershell", - _ => "powershell", - }; - } private async Task HandleRunAsync(NodeInvokeRequest request) { var correlationId = Guid.NewGuid().ToString("N")[..8]; var v2Handler = _v2Handler; - request.Telemetry?.SetApprovalPipeline( - v2Handler != null - ? NodeToolApprovalPipeline.V2 - : NodeToolApprovalPipeline.Legacy); + request.Telemetry?.SetApprovalPipeline(NodeToolApprovalPipeline.V2); - // Routing seam (rail 2): select path, delegate โ€” no approval logic here. - if (v2Handler != null) - { - Logger.Info($"[system.run] corr={correlationId} path=v2"); - var approvalSpan = request.Telemetry?.StartChild( - NodeToolInvocation.SystemRunAuthorizeSpanName, - GetTelemetryParentContext(request)); - ExecApprovalV2Result v2Result; - var approvalCategory = NodeToolErrorCategory.None; - Type? approvalErrorType = null; - if (_commandRunner is IDirectArgvSupportAwareCommandRunner argvAware - && !argvAware.CanExecuteDirectArgv()) - { - // Approved commands execute as a direct argv, which the active - // sandbox transport cannot carry yet. Fail closed before any - // evaluation or prompt so nothing gets approved that cannot - // execute. The sandbox is never bypassed or disabled from here. - v2Result = ExecApprovalV2Result.Unavailable( - "sandboxed system.run cannot execute the approved command form yet; " + - "keep the sandbox on and disable the new approvals path, or turn the sandbox off, to run commands"); - } - else - { - try - { - v2Result = await v2Handler.HandleAsync(request, correlationId); - } - catch (Exception ex) - { - // Rail 1: no silent fallback โ€” handler exceptions become typed denies. - Logger.Error($"[system.run] corr={correlationId} path=v2 handler threw", ex); - v2Result = ExecApprovalV2Result.ValidationFailed("Handler exception"); - approvalErrorType = ex.GetType(); - } - } - - approvalCategory = approvalErrorType == null - ? MapV2ErrorCategory(v2Result.Code) - : NodeToolErrorCategory.InternalFailure; - Logger.Info($"[system.run] corr={correlationId} decision={v2Result.Code} reason={v2Result.Reason}"); - NodeToolInvocation.CompleteChild( - approvalSpan, - approvalCategory == NodeToolErrorCategory.None - ? NodeToolOutcome.Success - : NodeToolOutcome.Failure, - approvalCategory, - errorType: approvalErrorType); - - if (v2Result.IsAllow && v2Result.Execution is { } approvedExecution) - return await RunApprovedAsync(approvedExecution, correlationId, request); - - // No fallback to legacy regardless of result code: any non-allow - // outcome from the approval handler is a terminal, typed error. - var response = Error($"exec-approvals-v2: {v2Result.Code} ({v2Result.Reason})"); - if (approvalCategory != NodeToolErrorCategory.None) - response.Diagnostic = new NodeToolDiagnostic(approvalCategory); - return response; - } - - // Legacy path โ€” untouched (rail 3). - Logger.Info($"[system.run] corr={correlationId} path=legacy decision=legacy reason=legacy"); - - if (_commandRunner == null) + Logger.Info($"[system.run] corr={correlationId} path=v2"); + var approvalSpan = request.Telemetry?.StartChild( + NodeToolInvocation.SystemRunAuthorizeSpanName, + GetTelemetryParentContext(request)); + ExecApprovalV2Result v2Result; + Type? approvalErrorType = null; + if (_commandRunner is IDirectArgvSupportAwareCommandRunner argvAware + && !argvAware.CanExecuteDirectArgv()) { - return ErrorWithDiagnostic( - "Command execution not available", - NodeToolErrorCategory.CapabilityUnavailable); + // Fail closed before evaluation when a runner explicitly reports + // that it cannot preserve an approved direct argv. + v2Result = ExecApprovalV2Result.Unavailable( + "system.run cannot execute the approved direct argv with the active command runner"); } - - // Per OpenClaw spec, "command" is an argv array (e.g. ["echo","Hello"]). - // Also accept a plain string for backward compatibility. - var argv = TryParseArgv(request.Args); - string? command = argv?[0]; - string[]? args = argv?.Length > 1 ? argv[1..] : null; - - // When command is a string, also check for separate "args" array - if (argv?.Length == 1 && request.Args.TryGetProperty("args", out var argsEl) && - argsEl.ValueKind == System.Text.Json.JsonValueKind.Array) + else { - var list = new List(); - foreach (var item in argsEl.EnumerateArray()) + try { - if (item.ValueKind == System.Text.Json.JsonValueKind.String) - list.Add(item.GetString() ?? ""); + v2Result = await v2Handler.HandleAsync(request, correlationId); } - if (list.Count > 0) - args = list.ToArray(); - } - - if (string.IsNullOrWhiteSpace(command)) - { - return ErrorWithDiagnostic("Missing command parameter", NodeToolErrorCategory.InvalidRequest); - } - - var shell = GetStringArg(request.Args, "shell"); - var cwd = GetStringArg(request.Args, "cwd"); - var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey"); - var timeoutMs = GetIntArg(request.Args, "timeoutMs", - GetIntArg(request.Args, "timeout", DefaultRunTimeoutMs)); - // Clamp caller-supplied timeouts. timeoutMs <= 0 historically meant - // "wait forever" inside LocalCommandRunner; that lets a wedged process - // pin a handler slot indefinitely, so we coerce to the default. The - // upper bound is generous but prevents a multi-day timeout request - // from accidentally outliving the tray. - if (timeoutMs <= 0) timeoutMs = DefaultRunTimeoutMs; - if (timeoutMs > MaxRunTimeoutMs) timeoutMs = MaxRunTimeoutMs; - - // Parse env dict if present - Dictionary? env = null; - if (request.Args.ValueKind != System.Text.Json.JsonValueKind.Undefined && - request.Args.TryGetProperty("env", out var envEl) && - envEl.ValueKind == System.Text.Json.JsonValueKind.Object) - { - env = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var prop in envEl.EnumerateObject()) + catch (Exception ex) { - if (prop.Value.ValueKind == System.Text.Json.JsonValueKind.String) - env[prop.Name] = prop.Value.GetString() ?? ""; + Logger.Error($"[system.run] corr={correlationId} path=v2 handler threw", ex); + v2Result = ExecApprovalV2Result.ValidationFailed("Handler exception"); + approvalErrorType = ex.GetType(); } } - var envResult = ExecEnvSanitizer.Sanitize(env); - if (envResult.Blocked.Length > 0) - { - var blockedNames = (string[])envResult.Blocked.Clone(); - Array.Sort(blockedNames, StringComparer.OrdinalIgnoreCase); - var blockedList = string.Join(", ", blockedNames); - Logger.Warn($"system.run DENIED: blocked environment overrides [{blockedList}]"); - return ErrorWithDiagnostic( - $"Unsafe environment variable override blocked: {blockedList}", - NodeToolErrorCategory.InvalidRequest); - } - env = envResult.Allowed; - - // Build the full command string for policy evaluation and logging. - // When command arrives as an argv array, we must evaluate the entire - // command line โ€” not just argv[0] โ€” so policy rules like "rm *" correctly - // match "rm -rf /". - var fullCommand = args != null - ? FormatExecCommand([command!, ..args]) - : command; - var requestedShell = string.IsNullOrWhiteSpace(shell) ? null : shell.Trim(); - var effectiveShell = _commandRunner.ResolveEffectiveShell(requestedShell); - var approvedHostFallbackShell = _commandRunner is IHostFallbackAwareCommandRunner fallbackAwareRunner - ? fallbackAwareRunner.ResolveHostFallbackShellForApproval(requestedShell, effectiveShell) - : null; - - Logger.Info($"system.run: {fullCommand} (shell={effectiveShell}, requestedShell={shell ?? "auto"}, timeout={timeoutMs}ms)"); - - // Check exec approval policy - if (_approvalPolicy != null) + var approvalCategory = approvalErrorType == null + ? MapV2ErrorCategory(v2Result.Code) + : NodeToolErrorCategory.InternalFailure; + Logger.Info($"[system.run] corr={correlationId} decision={v2Result.Code} reason={v2Result.Reason}"); + NodeToolInvocation.CompleteChild( + approvalSpan, + approvalCategory == NodeToolErrorCategory.None + ? NodeToolOutcome.Success + : NodeToolOutcome.Failure, + approvalCategory, + errorType: approvalErrorType); + + if (v2Result.IsAllow && v2Result.Execution is { } approvedExecution) { - var approvalSpan = request.Telemetry?.StartChild( - NodeToolInvocation.SystemRunAuthorizeSpanName, - GetTelemetryParentContext(request)); + ExecApprovalRevalidationResult revalidation; try { - var approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( - fullCommand, - effectiveShell, - sessionKey, + revalidation = await v2Handler.RevalidateAsync( + approvedExecution, correlationId); - if (approvalError != null) - { - approvalError.Diagnostic = new NodeToolDiagnostic(NodeToolErrorCategory.ExecPolicyDenied); - NodeToolInvocation.CompleteChild( - approvalSpan, - NodeToolOutcome.Failure, - NodeToolErrorCategory.ExecPolicyDenied); - return approvalError; - } - - if (!string.IsNullOrWhiteSpace(approvedHostFallbackShell) - && !string.Equals(approvedHostFallbackShell, effectiveShell, StringComparison.OrdinalIgnoreCase)) - { - approvalError = await EnsureCommandAndNestedTargetsApprovedAsync( - fullCommand, - approvedHostFallbackShell, - sessionKey, - correlationId); - if (approvalError != null) - { - approvalError.Diagnostic = new NodeToolDiagnostic(NodeToolErrorCategory.ExecPolicyDenied); - NodeToolInvocation.CompleteChild( - approvalSpan, - NodeToolOutcome.Failure, - NodeToolErrorCategory.ExecPolicyDenied); - return approvalError; - } - } - - NodeToolInvocation.CompleteChild(approvalSpan, NodeToolOutcome.Success); } catch (Exception ex) { - NodeToolInvocation.CompleteChild( - approvalSpan, - NodeToolOutcome.Failure, - NodeToolErrorCategory.InternalFailure, - errorType: ex.GetType()); - throw; + Logger.Error( + $"[system.run] corr={correlationId} path=v2 policy revalidation threw", + ex); + return ErrorWithDiagnostic( + "exec-approvals-v2: InternalError (policy-revalidation-failed)", + NodeToolErrorCategory.InternalFailure); } - } - - var runSpan = request.Telemetry?.StartChild( - NodeToolInvocation.SystemRunRunSpanName, - GetTelemetryParentContext(request)); - try - { - var result = await _commandRunner.RunAsync(new CommandRequest - { - Command = command, - Args = args, - Shell = requestedShell, - Cwd = cwd, - TimeoutMs = timeoutMs, - Env = env, - ApprovedEffectiveShell = effectiveShell, - ApprovedHostFallbackShell = approvedHostFallbackShell, - Telemetry = request.Telemetry, - TelemetryParentContext = runSpan?.Context ?? request.Telemetry?.Context ?? default - }); - var executionMode = result.ExecutionMode ?? NodeToolExecutionMode.Host; - var errorCategory = ClassifyCommandResult(result); - if (result.SandboxDenialReason.HasValue) - request.Telemetry?.SetSandboxDenialReason(result.SandboxDenialReason.Value); - NodeToolInvocation.CompleteChild( - runSpan, - errorCategory == NodeToolErrorCategory.None - ? NodeToolOutcome.Success - : NodeToolOutcome.Failure, - errorCategory, - executionMode, - sandboxDenialReason: result.SandboxDenialReason); - - var response = Success(new - { - stdout = result.Stdout, - stderr = result.Stderr, - exitCode = result.ExitCode, - timedOut = result.TimedOut, - success = result.ExitCode == 0 && !result.TimedOut, - durationMs = result.DurationMs - }); - if (errorCategory != NodeToolErrorCategory.None) + if (!revalidation.IsCurrent) { - response.Diagnostic = new NodeToolDiagnostic( - errorCategory, - executionMode, - result.SandboxDenialReason); + Logger.Warn( + $"[system.run] corr={correlationId} path=v2 execution denied " + + $"reason={revalidation.Reason}"); + return ErrorWithDiagnostic( + $"exec-approvals-v2: ValidationFailed ({revalidation.Reason})", + NodeToolErrorCategory.ExecPolicyDenied); } - return response; - } - catch (Exception ex) - { - Logger.Error("system.run failed", ex); - NodeToolInvocation.CompleteChild( - runSpan, - NodeToolOutcome.Failure, - NodeToolErrorCategory.InternalFailure, - errorType: ex.GetType()); - var response = ErrorWithDiagnostic("Execution failed", NodeToolErrorCategory.InternalFailure); - return response; + + return await RunApprovedAsync(approvedExecution, correlationId, request); } + + var response = Error($"exec-approvals-v2: {v2Result.Code} ({v2Result.Reason})"); + if (approvalCategory != NodeToolErrorCategory.None) + response.Diagnostic = new NodeToolDiagnostic(approvalCategory); + return response; } private NodeInvokeResponse ErrorWithDiagnostic( @@ -737,414 +429,244 @@ private async Task RunApprovedAsync( } } - private async Task EnsureApprovedAsync( - string command, - string? shell, - ExecApprovalResult approval, - string? sessionKey, - string correlationId, - CancellationToken cancellationToken = default) + private async Task HandleExecApprovalsGetAsync() { - if (approval.Allowed) - return new ExecApprovalCheckResult(true, null); + if (_approvalsStore == null) + return Error("No exec approvals store configured"); - if (approval.Action != ExecApprovalAction.Prompt || _promptHandler == null || _approvalPolicy == null) + try { - RaisePolicyAutoDenied(command, shell, approval); - return new ExecApprovalCheckResult(false, null); + var snapshot = await _approvalsStore.GetSnapshotAsync().ConfigureAwait(false); + return Success(ToExecApprovalsPayload(snapshot)); } - - var decision = await _promptHandler.RequestAsync(new ExecApprovalPromptRequest - { - Command = command, - Shell = shell, - MatchedPattern = approval.MatchedPattern, - Reason = approval.Reason ?? "Command requires approval", - SessionKey = sessionKey, - CorrelationId = correlationId - }, cancellationToken); - - if (decision.Kind == ExecApprovalPromptDecisionKind.Deny) + catch (Exception ex) { - Logger.Warn($"system.run DENIED by prompt: {command} ({decision.Reason})"); - return new ExecApprovalCheckResult(false, decision.Kind); + Logger.Error("execApprovals.get failed", ex); + return Error("Failed to read exec approvals"); } - - if (decision.Kind == ExecApprovalPromptDecisionKind.AlwaysAllow) - { - if (CanPersistExactAllowRule(command)) - { - _approvalPolicy.InsertRule(0, new ExecApprovalRule - { - Pattern = command, - Action = ExecApprovalAction.Allow, - Shells = string.IsNullOrWhiteSpace(shell) ? null : [shell], - Description = "Approved from Windows tray prompt" - }); - Logger.Info($"system.run prompt persisted exact allow rule: {command}"); - } - else - { - Logger.Warn($"system.run prompt could not persist wildcard command; allowing once only: {command}"); - } - } - - Logger.Info($"system.run APPROVED by prompt: {command} ({decision.Kind})"); - return new ExecApprovalCheckResult(true, decision.Kind); } - private async Task EnsureCommandAndNestedTargetsApprovedAsync( - string fullCommand, - string? shell, - string? sessionKey, - string correlationId) + private async Task HandleExecApprovalsSetAsync(NodeInvokeRequest request) { - if (_approvalPolicy == null) - return null; - - var parseResult = ExecShellWrapperParser.Expand(fullCommand, shell); - if (!string.IsNullOrWhiteSpace(parseResult.Error)) - { - Logger.Warn($"system.run DENIED: {fullCommand} ({parseResult.Error})"); - return Error($"Command denied by exec policy: {parseResult.Error}"); - } - - var approval = _approvalPolicy.Evaluate(fullCommand, shell); - var hasNestedTargets = parseResult.Targets.Count > 0; - var hasExplicitOuterRule = !string.IsNullOrWhiteSpace(approval.MatchedPattern); - var evaluateOuter = - !hasNestedTargets || - hasExplicitOuterRule || - approval.Allowed || - approval.Action == ExecApprovalAction.Prompt; - var approvalCheck = new ExecApprovalCheckResult(false, null); - if (evaluateOuter) - { - approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval, sessionKey, correlationId); - if (!approvalCheck.Allowed) - { - Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})"); - return Error($"Command denied by exec policy: {approval.Reason}"); - } - } + if (_approvalsStore == null) + return Error("No exec approvals store configured"); - var outerApprovalCoversNestedTargets = - approvalCheck.PromptDecisionKind != null || - IsExactAllowRuleForCommand(approval, fullCommand); - - // Gateway execution wraps Windows commands in cmd.exe. Only an - // unmatched default deny may defer to exact parsed-payload rules; - // explicit/default prompts still show and persist the full wrapper. - foreach (var target in parseResult.Targets) + try { - var innerApproval = _approvalPolicy.Evaluate(target.Command, target.Shell); - if (outerApprovalCoversNestedTargets && !IsExplicitDeny(innerApproval)) + if (!TryGetBaseHash(request.Args, out var baseHash)) { - if (!innerApproval.Allowed) - { - Logger.Info( - $"system.run nested approval covered by approved wrapper: {target.Command} ({innerApproval.Reason})"); - } - continue; + Logger.Warn("execApprovals.set denied: baseHash is required"); + return Error("baseHash is required for exec approvals updates; reload and retry"); } - var innerApprovalCheck = await EnsureApprovedAsync( - target.Command, - target.Shell, - innerApproval, - sessionKey, - correlationId); - if (!innerApprovalCheck.Allowed) + if (request.Args.ValueKind != System.Text.Json.JsonValueKind.Object + || !request.Args.TryGetProperty("file", out var fileElement) + || fileElement.ValueKind != System.Text.Json.JsonValueKind.Object) { - Logger.Warn($"system.run DENIED: {target.Command} ({innerApproval.Reason})"); - return Error($"Command denied by exec policy: {innerApproval.Reason}"); + return Error("exec approvals file required"); } - if (!evaluateOuter && - innerApprovalCheck.PromptDecisionKind == null && - !IsExactAllowRuleForCommand(innerApproval, target.Command)) + var file = System.Text.Json.JsonSerializer.Deserialize( + fileElement.GetRawText(), + ExecApprovalsStore.JsonOptions); + if (file is null || file.Version != 1) + return Error("exec approvals file version 1 required"); + + var snapshot = await _approvalsStore.ReplaceAsync( + baseHash, + file, + ValidateExecApprovalsDelta).ConfigureAwait(false); + if (snapshot is null) { - const string reason = "Wrapped commands require an exact allow rule"; - Logger.Warn($"system.run DENIED: {target.Command} ({reason})"); - return Error($"Command denied by exec policy: {reason}"); + Logger.Warn("execApprovals.set denied: stale baseHash"); + return Error("exec approvals changed; reload and retry"); } - } - - return null; - } - private static bool CanPersistExactAllowRule(string command) => - !string.IsNullOrWhiteSpace(command) && - command.IndexOfAny(['*', '?']) < 0; - - private static bool IsExactAllowRuleForCommand(ExecApprovalResult approval, string command) => - approval.Allowed && - approval.Action == ExecApprovalAction.Allow && - !string.IsNullOrWhiteSpace(approval.MatchedPattern) && - approval.MatchedPattern.IndexOfAny(['*', '?']) < 0 && - string.Equals(approval.MatchedPattern, command, StringComparison.OrdinalIgnoreCase); - - private static bool IsExplicitDeny(ExecApprovalResult approval) => - !approval.Allowed && - approval.Action == ExecApprovalAction.Deny && - !string.IsNullOrWhiteSpace(approval.MatchedPattern); - - private readonly record struct ExecApprovalCheckResult( - bool Allowed, - ExecApprovalPromptDecisionKind? PromptDecisionKind); - - private void RaisePolicyAutoDenied(string command, string? shell, ExecApprovalResult approval) - { - var handler = PolicyAutoDecided; - if (handler == null) return; - try - { - var request = new ExecApprovalPromptRequest - { - Command = command, - Shell = shell, - MatchedPattern = approval.MatchedPattern, - Reason = approval.Reason ?? "Command denied by policy" - }; - var decision = ExecApprovalPromptDecision.Deny(approval.Reason ?? "Command denied by policy"); - handler(this, new ExecApprovalPromptDecidedEventArgs( - request, - decision, - ExecApprovalPromptDecisionSource.PolicyAutoDeny)); + Logger.Info($"Exec approvals updated: {snapshot.File.Agents?.Count ?? 0} agents"); + return Success(ToExecApprovalsPayload(snapshot)); } - catch (Exception ex) + catch (System.Text.Json.JsonException ex) { - Logger.Warn($"PolicyAutoDecided handler threw: {ex.Message}"); + Logger.Warn($"execApprovals.set denied: invalid file ({ex.Message})"); + return Error("Invalid exec approvals file"); } - } - - private NodeInvokeResponse HandleExecApprovalsGet() - { - if (_approvalPolicy == null) + catch (ExecApprovalsValidationException ex) { - return Success(new { enabled = false, message = "No exec policy configured" }); + Logger.Warn($"execApprovals.set denied: {ex.Message}"); + return Error(ex.Message); } - - var data = _approvalPolicy.GetPolicyData(); - var policyHash = _approvalPolicy.GetPolicyHash(); - var rules = data.Rules; - var rulesSummary = new object[rules.Count]; - for (var i = 0; i < rules.Count; i++) + catch (Exception ex) { - var r = rules[i]; - rulesSummary[i] = new - { - pattern = r.Pattern, - action = r.Action.ToString().ToLowerInvariant(), - shells = r.Shells, - description = r.Description, - enabled = r.Enabled - }; + Logger.Error("execApprovals.set failed", ex); + return Error("Failed to update exec approvals"); } + } - return Success(new - { - enabled = true, - hash = policyHash, - baseHash = policyHash, - defaultAction = data.DefaultAction.ToString().ToLowerInvariant(), - constraints = new - { - baseHashRequired = true, - defaultAllowAllowed = false, - broadAllowRulesAllowed = false, - dangerousAllowRulesAllowed = false - }, - rules = rulesSummary - }); + private static object ToExecApprovalsPayload(ExecApprovalsSnapshot snapshot) + { + var file = snapshot.File; + var socketPath = file.Socket?.Path?.Trim(); + var redactedFile = new ExecApprovalsFile + { + Version = file.Version, + Socket = string.IsNullOrWhiteSpace(socketPath) + ? null + : new ExecApprovalsSocketConfig { Path = socketPath }, + Defaults = file.Defaults, + Agents = file.Agents, + }; + return new + { + path = snapshot.Path, + exists = snapshot.Exists, + hash = snapshot.Hash, + baseHash = snapshot.Hash, + file = System.Text.Json.JsonSerializer.SerializeToElement( + redactedFile, + ExecApprovalsStore.JsonOptions), + }; } - - private NodeInvokeResponse HandleExecApprovalsSet(NodeInvokeRequest request) + + private static string? ValidateExecApprovalsDelta( + ExecApprovalsFile current, + ExecApprovalsFile desired) { - if (_approvalPolicy == null) + foreach (var (agentId, agent) in desired.Agents ?? []) { - return Error("No exec policy configured"); + if (string.IsNullOrWhiteSpace(agentId)) + return "Exec approval agent ids cannot be empty."; + if (agent is null) + return $"Exec approval agent '{agentId}' is invalid."; } - - try + + var policyError = ValidateRemotePolicyMonotonicity(current, desired); + if (policyError is not null) + return policyError; + + foreach (var (agentId, agent) in desired.Agents ?? []) { - var currentHash = _approvalPolicy.GetPolicyHash(); - if (!TryGetBaseHash(request.Args, out var baseHash)) - { - Logger.Warn("execApprovals.set denied: baseHash is required"); - return Error("baseHash is required for exec approval policy updates. Refresh policy and retry."); - } - if (!HashesMatch(baseHash, currentHash)) - { - Logger.Warn("execApprovals.set denied: stale baseHash"); - return Error("Exec approval policy changed since it was loaded. Refresh policy and retry."); - } + ExecApprovalsAgent? currentAgent = null; + current.Agents?.TryGetValue(agentId, out currentAgent); + var currentPatterns = new HashSet( + (currentAgent?.Allowlist ?? []) + .Select(entry => entry.Pattern?.Trim()) + .Where(pattern => !string.IsNullOrWhiteSpace(pattern))!, + StringComparer.OrdinalIgnoreCase); - // Parse rules from args - var rules = new List(); - - if (request.Args.ValueKind != System.Text.Json.JsonValueKind.Undefined && - request.Args.TryGetProperty("rules", out var rulesEl) && - rulesEl.ValueKind == System.Text.Json.JsonValueKind.Array) + foreach (var entry in agent.Allowlist ?? []) { - foreach (var ruleEl in rulesEl.EnumerateArray()) + var pattern = entry.Pattern?.Trim(); + if (string.IsNullOrWhiteSpace(pattern)) + return "Empty allowlist patterns are not permitted."; + if (!currentPatterns.Contains(pattern)) { - var rule = new ExecApprovalRule(); - - if (ruleEl.TryGetProperty("pattern", out var patEl) && patEl.ValueKind == System.Text.Json.JsonValueKind.String) - rule.Pattern = patEl.GetString() ?? "*"; - - if (ruleEl.TryGetProperty("action", out var actEl) && actEl.ValueKind == System.Text.Json.JsonValueKind.String) - { - var actStr = actEl.GetString() ?? "deny"; - rule.Action = actStr.ToLowerInvariant() switch - { - "allow" => ExecApprovalAction.Allow, - "prompt" or "ask" => ExecApprovalAction.Prompt, - _ => ExecApprovalAction.Deny - }; - } - - if (ruleEl.TryGetProperty("description", out var descEl) && descEl.ValueKind == System.Text.Json.JsonValueKind.String) - rule.Description = descEl.GetString(); - - if (ruleEl.TryGetProperty("enabled", out var enEl) && (enEl.ValueKind == System.Text.Json.JsonValueKind.True || enEl.ValueKind == System.Text.Json.JsonValueKind.False)) - rule.Enabled = enEl.GetBoolean(); - - if (ruleEl.TryGetProperty("shells", out var shellsEl) && shellsEl.ValueKind == System.Text.Json.JsonValueKind.Array) - { - var shellsList = new List(shellsEl.GetArrayLength()); - foreach (var s in shellsEl.EnumerateArray()) - { - if (s.ValueKind == System.Text.Json.JsonValueKind.String) - shellsList.Add(s.GetString() ?? ""); - } - rule.Shells = shellsList.ToArray(); - } - - rules.Add(rule); + return + $"Remote exec approval updates cannot add or change allowlist entries for agent '{agentId}'."; } } - - // Parse default action - ExecApprovalAction? defaultAction = null; - if (request.Args.TryGetProperty("defaultAction", out var defEl) && defEl.ValueKind == System.Text.Json.JsonValueKind.String) - { - var defStr = defEl.GetString() ?? "deny"; - defaultAction = defStr.ToLowerInvariant() switch - { - "allow" => ExecApprovalAction.Allow, - "prompt" or "ask" => ExecApprovalAction.Prompt, - _ => ExecApprovalAction.Deny - }; - } - - if (defaultAction == ExecApprovalAction.Allow) - { - Logger.Warn("execApprovals.set denied: default allow is not permitted"); - return Error("Default allow is not permitted for remote exec approval policy updates."); - } - - var validationError = ValidateExecApprovalRules(rules); - if (validationError != null) - { - Logger.Warn($"execApprovals.set denied: {validationError}"); - return Error(validationError); - } - - _approvalPolicy.SetRules(rules, defaultAction); - var newHash = _approvalPolicy.GetPolicyHash(); - Logger.Info($"Exec approval policy updated: {rules.Count} rules"); - - return Success(new { updated = true, ruleCount = rules.Count, hash = newHash, baseHash = newHash }); - } - catch (Exception ex) - { - Logger.Error("execApprovals.set failed", ex); - return Error("Failed to update policy"); } + + return null; } - private static string? ValidateExecApprovalRules(IEnumerable rules) + private static string? ValidateRemotePolicyMonotonicity( + ExecApprovalsFile current, + ExecApprovalsFile desired) { - foreach (var rule in rules) - { - if (rule.Action != ExecApprovalAction.Allow) - continue; - - var pattern = rule.Pattern.Trim(); - if (string.IsNullOrWhiteSpace(pattern)) - return "Empty allow rule patterns are not permitted."; - - // Normalize to lowercase and collapse all whitespace runs (including tabs, - // non-breaking spaces) to a single ASCII space so fragment checks cannot be - // bypassed with alternate whitespace characters. - var normalized = System.Text.RegularExpressions.Regex.Replace( - pattern.ToLowerInvariant(), @"\s+", " "); - - // Catch all-wildcard patterns (e.g. *, **, ?*, * ?) that match any command. - // Strip every wildcard character and whitespace; if nothing remains the pattern - // is effectively "match everything" and must be blocked regardless of spelling. - var nonWildcardContent = normalized.Replace("*", "").Replace("?", "").Trim(); - if (string.IsNullOrEmpty(nonWildcardContent)) - return $"Broad allow rule is not permitted: {pattern}"; - - // Catch shell-prefixed blanket patterns that match all commands in a given shell - // (e.g. "powershell *" allows every PowerShell command). - if (normalized is "powershell *" or "pwsh *" or "cmd *" or "cmd.exe *") - return $"Broad allow rule is not permitted: {pattern}"; + var error = ComparePolicies( + ResolvePolicy(current, agentId: null, includeWildcard: false), + ResolvePolicy(desired, agentId: null, includeWildcard: false), + "defaults"); + if (error is not null) + return error; - // Reject Allow rules whose pattern looks like an absolute file path. - // A remote .set call should never be able to whitelist a specific binary - // by path โ€” that would be a two-step EoP (compromise MCP token โ†’ whitelist - // attacker binary โ†’ invoke it). Legitimate rules name commands, not paths. - // Parse the executable token first so quoted paths remain one token even - // when the rule includes arguments. - // Covers: drive-rooted paths (C:\, C:/), UNC/long-path (\\, //), and - // forward-slash UNC namespace forms (//server/share, //?/C:/evil.exe). - var executableToken = ExecCommandToken.ParseFirstToken(normalized); - if (executableToken is null) - return $"Allow rule must begin with a valid executable token: {pattern}"; + error = ComparePolicies( + ResolvePolicy(current, agentId: null, includeWildcard: true), + ResolvePolicy(desired, agentId: null, includeWildcard: true), + "agent '*'"); + if (error is not null) + return error; - var pathToken = executableToken; - if (pathToken.Length >= 3 && - ((char.IsLetter(pathToken[0]) && pathToken[1] == ':' && (pathToken[2] == '\\' || pathToken[2] == '/')) || - pathToken.StartsWith(@"\\", StringComparison.Ordinal) || - pathToken.StartsWith("//", StringComparison.Ordinal))) - return $"Absolute path allow rule is not permitted: {pattern}"; - - if (ExecCommandToken.IsIndirectCommandHost(executableToken)) - return $"Allow rules cannot target shell interpreters or command hosts: {pattern}"; - - foreach (var dangerous in DangerousAllowPatternFragments) - { - if (normalized.Contains(dangerous, StringComparison.Ordinal)) - return $"Dangerous allow rule is not permitted: {pattern}"; - - // Also block stem+wildcard (e.g. "rm*" bypasses "rm " because the - // fragment has a trailing space that the wildcard replaces). - var stem = dangerous.TrimEnd(); - if (stem.Length < dangerous.Length && - (normalized.Contains(stem + "*", StringComparison.Ordinal) || - normalized.Contains(stem + "?", StringComparison.Ordinal))) - { - return $"Dangerous allow rule is not permitted: {pattern}"; - } - } + var agentIds = new HashSet(StringComparer.Ordinal); + foreach (var id in current.Agents?.Keys ?? (IEnumerable)Array.Empty()) + { + if (id != "*") + agentIds.Add(id); + } + foreach (var id in desired.Agents?.Keys ?? (IEnumerable)Array.Empty()) + { + if (id != "*") + agentIds.Add(id); + } + agentIds.Add("main"); - // Finally: the executable (first whitespace-delimited token) must be a concrete literal. A - // wildcard there lets a NON-dangerous pattern match ANY command (MatchesPattern globs - // * -> .* over the whole command line), e.g. "*.*", "*e*", "*.exe", "c*" โ€” the broad-allow - // class the earlier shape checks miss. Runs after the dangerous-fragment check so a - // dangerous stem keeps its specific message. Legit rules pin the command ("git *"). - if (executableToken.Contains('*') || executableToken.Contains('?')) - return $"Allow rule must name a concrete command (no wildcard in the executable): {pattern}"; + foreach (var agentId in agentIds) + { + error = ComparePolicies( + ResolvePolicy(current, agentId, includeWildcard: true), + ResolvePolicy(desired, agentId, includeWildcard: true), + $"agent '{agentId}'"); + if (error is not null) + return error; } return null; } + private static string? ComparePolicies( + RemoteEffectivePolicy current, + RemoteEffectivePolicy desired, + string scope) + { + if (desired.Security > current.Security) + return $"Remote exec approval updates cannot make security less restrictive for {scope}."; + if (desired.Ask < current.Ask) + return $"Remote exec approval updates cannot make ask less restrictive for {scope}."; + if (desired.AskFallback > current.AskFallback) + return $"Remote exec approval updates cannot make askFallback less restrictive for {scope}."; + if (!current.AutoAllowSkills && desired.AutoAllowSkills) + return $"Remote exec approval updates cannot make policy less restrictive by enabling autoAllowSkills for {scope}."; + return null; + } + + private static RemoteEffectivePolicy ResolvePolicy( + ExecApprovalsFile file, + string? agentId, + bool includeWildcard) + { + ExecApprovalsAgent? wildcard = null; + ExecApprovalsAgent? agent = null; + if (includeWildcard) + file.Agents?.TryGetValue("*", out wildcard); + if (agentId is not null) + file.Agents?.TryGetValue(agentId, out agent); + + return new RemoteEffectivePolicy( + agent?.Security + ?? wildcard?.Security + ?? file.Defaults?.Security + ?? ExecSecurity.Allowlist, + agent?.Ask + ?? wildcard?.Ask + ?? file.Defaults?.Ask + ?? ExecAsk.OnMiss, + agent?.AskFallback + ?? wildcard?.AskFallback + ?? file.Defaults?.AskFallback + ?? ExecSecurity.Deny, + agent?.AutoAllowSkills + ?? wildcard?.AutoAllowSkills + ?? file.Defaults?.AutoAllowSkills + ?? false); + } + + private readonly record struct RemoteEffectivePolicy( + ExecSecurity Security, + ExecAsk Ask, + ExecSecurity AskFallback, + bool AutoAllowSkills); + private static bool TryGetBaseHash(System.Text.Json.JsonElement args, out string baseHash) { baseHash = ""; @@ -1158,28 +680,6 @@ private static bool TryGetBaseHash(System.Text.Json.JsonElement args, out string return !string.IsNullOrWhiteSpace(baseHash); } - if (args.TryGetProperty("base_hash", out var baseHashSnakeEl) && - baseHashSnakeEl.ValueKind == System.Text.Json.JsonValueKind.String) - { - baseHash = baseHashSnakeEl.GetString() ?? ""; - return !string.IsNullOrWhiteSpace(baseHash); - } - - return false; - } - - private static bool HashesMatch(string candidate, string currentHash) - { - if (string.Equals(candidate, currentHash, StringComparison.OrdinalIgnoreCase)) - return true; - - const string prefix = "sha256:"; - if (currentHash.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && - string.Equals(candidate, currentHash[prefix.Length..], StringComparison.OrdinalIgnoreCase)) - { - return true; - } - return false; } } diff --git a/src/OpenClaw.Shared/ExecApprovalPolicy.cs b/src/OpenClaw.Shared/ExecApprovalPolicy.cs deleted file mode 100644 index cbcbcf5f0..000000000 --- a/src/OpenClaw.Shared/ExecApprovalPolicy.cs +++ /dev/null @@ -1,553 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using System.Threading; - -namespace OpenClaw.Shared; - -/// -/// A single rule in the exec approval policy. -/// Rules are evaluated top-to-bottom; first match wins. -/// -public class ExecApprovalRule -{ - /// Pattern to match against the command string (glob-style: * = any chars) - public string Pattern { get; set; } = "*"; - - /// Whether matching commands are allowed or denied - public ExecApprovalAction Action { get; set; } = ExecApprovalAction.Deny; - - /// Optional: restrict to specific shells (null = all shells) - public string[]? Shells { get; set; } - - /// Optional description for display - public string? Description { get; set; } - - /// Whether this rule is enabled - public bool Enabled { get; set; } = true; -} - -public enum ExecApprovalAction -{ - Allow, - Deny, - Prompt -} - -/// -/// JsonConverter for that emits/accepts the canonical -/// camelCase values ("allow", "deny", "prompt") but also accepts legacy values written -/// by older builds: the "ask" alias and numeric enum values. -/// -internal sealed class ExecApprovalActionConverter : JsonConverter -{ - public override ExecApprovalAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Number) - { - if (!reader.TryGetInt32(out var numericValue)) - throw new JsonException("Expected integer value for ExecApprovalAction"); - - return numericValue switch - { - (int)ExecApprovalAction.Allow => ExecApprovalAction.Allow, - (int)ExecApprovalAction.Deny => ExecApprovalAction.Deny, - (int)ExecApprovalAction.Prompt => ExecApprovalAction.Prompt, - _ => throw new JsonException($"Unknown ExecApprovalAction numeric value '{numericValue}'") - }; - } - if (reader.TokenType != JsonTokenType.String) - throw new JsonException($"Expected string or number for ExecApprovalAction, got {reader.TokenType}"); - - var value = reader.GetString(); - return value?.ToLowerInvariant() switch - { - "allow" => ExecApprovalAction.Allow, - "deny" => ExecApprovalAction.Deny, - "prompt" or "ask" => ExecApprovalAction.Prompt, - _ => throw new JsonException($"Unknown ExecApprovalAction value '{value}'") - }; - } - - public override void Write(Utf8JsonWriter writer, ExecApprovalAction value, JsonSerializerOptions options) - { - writer.WriteStringValue(value switch - { - ExecApprovalAction.Allow => "allow", - ExecApprovalAction.Deny => "deny", - ExecApprovalAction.Prompt => "prompt", - _ => throw new JsonException($"Unknown ExecApprovalAction enum {value}") - }); - } -} - -/// -/// Result of evaluating a command against the policy. -/// -public class ExecApprovalResult -{ - public bool Allowed { get; set; } - public ExecApprovalAction Action { get; set; } - public string? MatchedPattern { get; set; } - public string? Reason { get; set; } -} - -/// -/// Manages execution approval rules for system.run commands. -/// Rules are persisted to a JSON file and evaluated top-to-bottom (first match wins). -/// If no rules match, the default action applies (configurable, defaults to Deny). -/// -public class ExecApprovalPolicy -{ - private readonly IOpenClawLogger _logger; - private readonly string _policyFilePath; - private List _rules = new(); - private ExecApprovalAction _defaultAction = ExecApprovalAction.Deny; - - // Protects _rules, _defaultAction, and the file-signature fields below. - // Evaluate() takes the lock just long enough to hot-reload (if needed) and - // snapshot state into locals; pattern matching runs outside the lock. - private readonly object _stateLock = new(); - - // File-signature cache for non-destructive hot-reload. When the on-disk - // file's (mtime, length) differs from the cached pair, Evaluate() reparses - // the file into local variables and swaps them in only on success โ€” never - // calling the bootstrap fallback that would wipe user rules on a torn write. - private DateTime _lastFileMtimeUtc; - private long _lastFileLength = -1; - - // Compiled regex cache โ€” ConcurrentDictionary for thread safety. - // Pattern โ†’ compiled Regex mapping never changes for a given pattern string - // (glob-to-regex conversion is deterministic), so no cache invalidation is needed. - private static readonly ConcurrentDictionary _regexCache = new(StringComparer.Ordinal); - - /// Current rules (read-only snapshot) - public IReadOnlyList Rules - { - get { lock (_stateLock) return _rules.ToList().AsReadOnly(); } - } - - /// Action when no rules match - public ExecApprovalAction DefaultAction - { - get { lock (_stateLock) return _defaultAction; } - set { lock (_stateLock) _defaultAction = value; } - } - - public ExecApprovalPolicy(string dataPath, IOpenClawLogger logger) - { - _logger = logger; - _policyFilePath = Path.Combine(dataPath, "exec-policy.json"); - Load(); - } - - /// - /// Evaluate whether a command is allowed to execute. - /// - public ExecApprovalResult Evaluate(string command, string? shell = null) - { - if (string.IsNullOrWhiteSpace(command)) - { - return new ExecApprovalResult - { - Allowed = false, - Action = ExecApprovalAction.Deny, - Reason = "Empty command" - }; - } - - // Snapshot policy state under lock (and hot-reload if the on-disk file - // changed externally โ€” e.g. the Permissions UI saved a new default). - // Pattern matching runs outside the lock against the local snapshot. - List rulesSnapshot; - ExecApprovalAction defaultActionSnapshot; - lock (_stateLock) - { - TryHotReloadLocked(); - rulesSnapshot = _rules.ToList(); - defaultActionSnapshot = _defaultAction; - } - - var shellSpan = (shell ?? "powershell").AsSpan(); - - foreach (var rule in rulesSnapshot) - { - if (!rule.Enabled) continue; - - // Check shell filter - if (rule.Shells is { Length: > 0 }) - { - var shellMatched = false; - foreach (var s in rule.Shells) - { - if (s.AsSpan().Equals(shellSpan, StringComparison.OrdinalIgnoreCase)) - { - shellMatched = true; - break; - } - } - if (!shellMatched) continue; - } - - // Check pattern match - if (MatchesPattern(command, rule.Pattern)) - { - var allowed = rule.Action == ExecApprovalAction.Allow; - _logger.Info($"[EXEC-POLICY] {(allowed ? "ALLOW" : "DENY")}: '{command}' matched rule '{rule.Pattern}'"); - - return new ExecApprovalResult - { - Allowed = allowed, - Action = rule.Action, - MatchedPattern = rule.Pattern, - Reason = rule.Description ?? $"Matched rule: {rule.Pattern}" - }; - } - } - - // No rule matched - use default - var defaultAllowed = defaultActionSnapshot == ExecApprovalAction.Allow; - _logger.Info($"[EXEC-POLICY] DEFAULT {(defaultActionSnapshot)}: '{command}' (no rule matched)"); - - return new ExecApprovalResult - { - Allowed = defaultAllowed, - Action = defaultActionSnapshot, - Reason = "No matching rule; default policy applied" - }; - } - - /// - /// Non-destructive hot-reload: if the on-disk policy file's signature - /// (mtime, length) differs from the cached pair, attempt to reparse it. - /// On success, atomically swap rules + default action. On failure (file - /// missing, partially written, corrupt JSON), log and keep the existing - /// in-memory state โ€” NEVER fall back to default rules, which would - /// silently destroy the user's policy during a torn write. - /// Caller must hold . - /// - private void TryHotReloadLocked() - { - FileInfo info; - try - { - info = new FileInfo(_policyFilePath); - if (!info.Exists) return; - } - catch - { - return; - } - - var mtime = info.LastWriteTimeUtc; - var length = info.Length; - if (mtime == _lastFileMtimeUtc && length == _lastFileLength) return; - - try - { - var json = File.ReadAllText(_policyFilePath); - var data = JsonSerializer.Deserialize(json, _jsonOptions); - if (data == null) return; // keep current state - - _rules = data.Rules ?? new List(); - _defaultAction = data.DefaultAction; - _lastFileMtimeUtc = mtime; - _lastFileLength = length; - _logger.Info($"[EXEC-POLICY] Hot-reloaded {_rules.Count} rules from {_policyFilePath} (defaultAction={_defaultAction})"); - } - catch (Exception ex) - { - // Keep current in-memory policy. Do not bump the signature cache - // so the next Evaluate() will retry once the writer finishes. - _logger.Warn($"[EXEC-POLICY] Hot-reload skipped (file may be mid-write): {ex.Message}"); - } - } - - /// - /// Add a rule to the policy. Persists to disk. - /// - public void AddRule(ExecApprovalRule rule) - { - lock (_stateLock) _rules.Add(rule); - Save(); - } - - /// - /// Insert a rule at a specific index. Persists to disk. - /// - public void InsertRule(int index, ExecApprovalRule rule) - { - lock (_stateLock) - { - index = Math.Clamp(index, 0, _rules.Count); - _rules.Insert(index, rule); - } - Save(); - } - - /// - /// Remove a rule by index. Persists to disk. - /// - public bool RemoveRule(int index) - { - lock (_stateLock) - { - if (index < 0 || index >= _rules.Count) return false; - _rules.RemoveAt(index); - } - Save(); - return true; - } - - /// - /// Replace all rules. Persists to disk. - /// - public void SetRules(IEnumerable rules, ExecApprovalAction? defaultAction = null) - { - lock (_stateLock) - { - _rules = new List(rules); - if (defaultAction.HasValue) _defaultAction = defaultAction.Value; - } - Save(); - } - - /// - /// Get a serializable snapshot of the policy. - /// - public ExecPolicyData GetPolicyData() - { - lock (_stateLock) return GetPolicyDataLocked(); - } - - public string GetPolicyHash() - { - var json = JsonSerializer.Serialize(GetPolicyData(), _jsonOptions); - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json)); - return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}"; - } - - /// - /// Load policy from disk. Creates default policy if file doesn't exist. - /// - public void Load() - { - try - { - if (File.Exists(_policyFilePath)) - { - var json = File.ReadAllText(_policyFilePath); - var data = JsonSerializer.Deserialize(json, _jsonOptions); - if (data != null) - { - lock (_stateLock) - { - _rules = data.Rules ?? new List(); - _defaultAction = data.DefaultAction; - UpdateFileSignatureLocked(); - } - _logger.Info($"[EXEC-POLICY] Loaded {_rules.Count} rules from {_policyFilePath}"); - return; - } - } - } - catch (Exception ex) - { - _logger.Warn($"[EXEC-POLICY] Failed to load policy: {ex.Message}"); - } - - // Default policy: allow safe read-only commands, deny everything else - lock (_stateLock) - { - _rules = CreateDefaultRules(); - _defaultAction = ExecApprovalAction.Deny; - } - _logger.Info("[EXEC-POLICY] Using default policy"); - Save(); - } - - /// - /// Save current policy to disk atomically (write to .tmp, then replace). - /// Updates the file-signature cache so the engine doesn't spuriously - /// hot-reload its own writes. - /// - public void Save() - { - string? tmpPath = null; - try - { - var dir = Path.GetDirectoryName(_policyFilePath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - ExecPolicyData snapshot; - lock (_stateLock) - { - snapshot = GetPolicyDataLocked(); - } - var json = JsonSerializer.Serialize(snapshot, _jsonOptions); - - // Atomic write: serialize to a sibling .tmp first, then replace the - // target in one move. Guards against torn writes that would let a - // concurrent reader (or our own hot-reload) see partial JSON. - tmpPath = $"{_policyFilePath}.{Guid.NewGuid():N}.tmp"; - File.WriteAllText(tmpPath, json); - MoveFileWithRetry(tmpPath, _policyFilePath); - tmpPath = null; - - lock (_stateLock) - { - UpdateFileSignatureLocked(); - } - } - catch (Exception ex) - { - TryDeleteTempFile(tmpPath); - _logger.Error($"[EXEC-POLICY] Failed to save: {ex.Message}"); - } - } - - private static void MoveFileWithRetry(string sourcePath, string destinationPath) - { - for (var attempt = 0; ; attempt++) - { - try - { - File.Move(sourcePath, destinationPath, overwrite: true); - return; - } - catch (Exception ex) when (IsTransientReplaceException(ex) && attempt < 20) - { - Thread.Sleep(5); - } - } - } - - private static bool IsTransientReplaceException(Exception ex) => - ex is IOException or UnauthorizedAccessException; - - private static void TryDeleteTempFile(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - return; - - try - { - if (File.Exists(path)) - File.Delete(path); - } - catch - { - // Best-effort cleanup; the original save failure is reported by the caller. - } - } - - private void UpdateFileSignatureLocked() - { - try - { - var info = new FileInfo(_policyFilePath); - if (info.Exists) - { - _lastFileMtimeUtc = info.LastWriteTimeUtc; - _lastFileLength = info.Length; - } - } - catch - { - // Best-effort. If we can't stat, leave cache as-is; next Evaluate - // will attempt hot-reload (and either succeed or be a no-op). - } - } - - private ExecPolicyData GetPolicyDataLocked() - { - return new ExecPolicyData - { - DefaultAction = _defaultAction, - Rules = _rules.ToList() - }; - } - - private static List CreateDefaultRules() - { - return new List - { - // Allow common read-only / diagnostic commands - new() { Pattern = "echo *", Action = ExecApprovalAction.Allow, Description = "Echo commands" }, - new() { Pattern = "Get-*", Action = ExecApprovalAction.Allow, Shells = new[] { "powershell", "pwsh" }, Description = "PowerShell Get- cmdlets (read-only)" }, - new() { Pattern = "dir *", Action = ExecApprovalAction.Allow, Description = "Directory listing" }, - new() { Pattern = "hostname", Action = ExecApprovalAction.Allow, Description = "Hostname query" }, - new() { Pattern = "whoami", Action = ExecApprovalAction.Allow, Description = "Current user" }, - new() { Pattern = "systeminfo", Action = ExecApprovalAction.Allow, Description = "System info" }, - new() { Pattern = "ipconfig *", Action = ExecApprovalAction.Allow, Description = "Network config" }, - new() { Pattern = "ping *", Action = ExecApprovalAction.Allow, Description = "Ping" }, - new() { Pattern = "type *", Action = ExecApprovalAction.Allow, Shells = new[] { "cmd" }, Description = "Read file (cmd)" }, - new() { Pattern = "cat *", Action = ExecApprovalAction.Allow, Description = "Read file" }, - - // Deny dangerous patterns explicitly - new() { Pattern = "Remove-Item *", Action = ExecApprovalAction.Deny, Description = "Block file deletion" }, - new() { Pattern = "rm *", Action = ExecApprovalAction.Deny, Description = "Block rm" }, - new() { Pattern = "del *", Action = ExecApprovalAction.Deny, Description = "Block del" }, - new() { Pattern = "Format-*", Action = ExecApprovalAction.Deny, Description = "Block format commands" }, - new() { Pattern = "Stop-Computer*", Action = ExecApprovalAction.Deny, Description = "Block shutdown" }, - new() { Pattern = "Restart-Computer*", Action = ExecApprovalAction.Deny, Description = "Block restart" }, - new() { Pattern = "*Invoke-WebRequest*", Action = ExecApprovalAction.Deny, Description = "Block web downloads" }, - new() { Pattern = "*Start-Process*", Action = ExecApprovalAction.Deny, Description = "Block process launch" }, - new() { Pattern = "*reg *", Action = ExecApprovalAction.Deny, Description = "Block registry edits" }, - new() { Pattern = "shutdown*", Action = ExecApprovalAction.Deny, Description = "Block shutdown" }, - new() { Pattern = "net *", Action = ExecApprovalAction.Deny, Description = "Block net commands" }, - }; - } - - /// - /// Glob-style pattern matching: * matches any chars, ? matches single char. - /// Case-insensitive. Returns false on regex timeout (guards against ReDoS in - /// user-supplied policy files) and denies the command as the safe default. - /// - internal bool MatchesPattern(string command, string pattern) - { - if (pattern == "*") return true; - - var regex = _regexCache.GetOrAdd(pattern, static p => - { - var regexPattern = "^" + Regex.Escape(p) - .Replace("\\*", ".*") - .Replace("\\?", ".") + "$"; - return new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); - }); - - try - { - return regex.IsMatch(command); - } - catch (RegexMatchTimeoutException) - { - _logger.Warn($"[EXEC-POLICY] Pattern match timed out for '{pattern}'; denying as safe default"); - return false; - } - } - - private static readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = true, - Converters = { new ExecApprovalActionConverter() }, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; -} - -/// -/// Serializable policy data for persistence. -/// -public class ExecPolicyData -{ - public ExecApprovalAction DefaultAction { get; set; } = ExecApprovalAction.Deny; - public List Rules { get; set; } = new(); -} diff --git a/src/OpenClaw.Shared/ExecApprovalPrompt.cs b/src/OpenClaw.Shared/ExecApprovalPrompt.cs deleted file mode 100644 index 067312382..000000000 --- a/src/OpenClaw.Shared/ExecApprovalPrompt.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace OpenClaw.Shared; - -public enum ExecApprovalPromptDecisionKind -{ - Deny, - AllowOnce, - AlwaysAllow -} - -public sealed class ExecApprovalPromptRequest -{ - public string Command { get; init; } = ""; - public string? Shell { get; init; } - public string? MatchedPattern { get; init; } - public string Reason { get; init; } = ""; - public string? SessionKey { get; init; } - public string? CorrelationId { get; init; } -} - -public sealed class ExecApprovalPromptDecision -{ - public const string TimedOutReason = "Approval prompt timed out"; - - private ExecApprovalPromptDecision(ExecApprovalPromptDecisionKind kind, string reason) - { - Kind = kind; - Reason = reason; - } - - public ExecApprovalPromptDecisionKind Kind { get; } - public string Reason { get; } - - public static ExecApprovalPromptDecision Deny(string reason = "Denied by user") => new(ExecApprovalPromptDecisionKind.Deny, reason); - public static ExecApprovalPromptDecision AllowOnce(string reason = "Allowed once by user") => new(ExecApprovalPromptDecisionKind.AllowOnce, reason); - public static ExecApprovalPromptDecision AlwaysAllow(string reason = "Always allowed by user") => new(ExecApprovalPromptDecisionKind.AlwaysAllow, reason); - public static ExecApprovalPromptDecision TimedOut() => Deny(TimedOutReason); -} - -public interface IExecApprovalPromptHandler -{ - Task RequestAsync(ExecApprovalPromptRequest request, CancellationToken cancellationToken = default); -} - -/// -/// Why the prompt resolved. Distinguishes a user's explicit click from -/// non-interactive terminations (token cancellation, prompt failure) -/// โ€” all of which collapse to a Deny -/// for safety but mean very different things to UI surfaces. -/// -public enum ExecApprovalPromptDecisionSource -{ - UserDeny, - UserAllowOnce, - UserAlwaysAllow, - Cancelled, - TimedOut, - Failed, - /// - /// Policy denied the command non-interactively (e.g. default action is - /// Deny and no allow rule matched). No native prompt was ever shown. - /// Surfaces in chat so users see why their request didn't execute. - /// - PolicyAutoDeny -} - -public sealed class ExecApprovalPromptDecidedEventArgs : EventArgs -{ - public ExecApprovalPromptDecidedEventArgs( - ExecApprovalPromptRequest request, - ExecApprovalPromptDecision decision, - ExecApprovalPromptDecisionSource source) - { - Request = request; - Decision = decision; - Source = source; - } - - public ExecApprovalPromptRequest Request { get; } - public ExecApprovalPromptDecision Decision { get; } - public ExecApprovalPromptDecisionSource Source { get; } -} - -public sealed class ExecApprovalPromptRequestedEventArgs : EventArgs -{ - public ExecApprovalPromptRequestedEventArgs(ExecApprovalPromptRequest request) - { - Request = request; - } - - public ExecApprovalPromptRequest Request { get; } -} diff --git a/src/OpenClaw.Shared/ExecApprovals/DesktopCanPresentEvaluator.cs b/src/OpenClaw.Shared/ExecApprovals/DesktopCanPresentEvaluator.cs new file mode 100644 index 000000000..251e01475 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/DesktopCanPresentEvaluator.cs @@ -0,0 +1,63 @@ +using System; +using System.Runtime.InteropServices; + +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Presents the approval dialog only when there is an interactive input desktop the node owner +/// can actually see and answer on. When the workstation is locked, on the secure desktop, or the +/// process has no attended session, OpenInputDesktop fails and this reports "cannot +/// present" so the coordinator fails closed to the ask fallback instead of posting a dialog +/// nobody can respond to. Replaces in production. +/// +public sealed class DesktopCanPresentEvaluator : ICanPresentEvaluator +{ + private readonly Func _isDesktopInteractive; + + public DesktopCanPresentEvaluator(Func? isDesktopInteractive = null) + => _isDesktopInteractive = isDesktopInteractive ?? IsInputDesktopInteractive; + + public bool CanPresent(string? requestSessionKey) + { + try + { + return _isDesktopInteractive(); + } + catch + { + // Fail closed: without a reliable "is a user watching" signal, do not present. + return false; + } + } + + private static bool IsInputDesktopInteractive() + { + if (!OperatingSystem.IsWindows()) + return false; + + // OpenInputDesktop opens the desktop currently receiving user input. On the locked/ + // secure (Winlogon) desktop it fails for a normal-rights process, which is exactly when + // there is no attended user desktop to present on. + var desktop = OpenInputDesktop(0, false, DESKTOP_READOBJECTS); + if (desktop == IntPtr.Zero) + return false; + + try + { + return true; + } + finally + { + CloseDesktop(desktop); + } + } + + private const uint DESKTOP_READOBJECTS = 0x0001; + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr OpenInputDesktop(uint dwFlags, bool fInherit, uint dwDesiredAccess); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseDesktop(IntPtr hDesktop); +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalAuditSuppressionGate.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalAuditSuppressionGate.cs new file mode 100644 index 000000000..91060d89c --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalAuditSuppressionGate.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Forces an explicit approval for commands that change the security-audit suppression list, so +/// an agent cannot silently disable security findings via system.run. Mirrors the macOS +/// commandRequiresSecurityAuditSuppressionApproval gate: a command that references +/// security.audit.suppressions (exact or obfuscated) requires approval unless it is a +/// read-only inspection (openclaw config get|schema|validate). +/// +internal static class ExecApprovalAuditSuppressionGate +{ + // Obfuscated forms: quotes/separators spliced between the three segments (bounded to avoid + // catastrophic backtracking). Matches the macOS fuzzy detector. + private static readonly Regex FuzzyReference = new( + "[\"']?security[\"']?[\\s\\S]{0,200}[\"']?audit[\"']?[\\s\\S]{0,200}[\"']?suppressions[\"']?", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); + + public static bool RequiresExtraApproval(IReadOnlyList? argv, string? displayCommand) + { + var normalized = Normalize(argv, displayCommand); + if (normalized.Length == 0) + return false; + + var references = + normalized.Contains("security.audit.suppressions", StringComparison.OrdinalIgnoreCase) + || FuzzyReference.IsMatch(normalized); + if (!references) + return false; + + // Read-only inspections are exempt (direct-argv forms). Shell-wrapped reads fall through + // to requiring approval, which is safe: it only ever prompts more, never less. + return !IsReadOnlyInspection(argv); + } + + private static string Normalize(IReadOnlyList? argv, string? displayCommand) + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(displayCommand)) + sb.Append(displayCommand).Append(' '); + if (argv is not null) + { + foreach (var token in argv) + sb.Append(token).Append(' '); + } + return sb.ToString(); + } + + private static readonly HashSet FlagGlobalOptions = + new(StringComparer.OrdinalIgnoreCase) { "--dev", "--no-color" }; + + private static readonly HashSet ValueGlobalOptions = + new(StringComparer.OrdinalIgnoreCase) { "--profile", "--container", "--log-level" }; + + private static bool IsReadOnlyInspection(IReadOnlyList? argv) + { + if (argv is null || argv.Count == 0) + return false; + + var i = 0; + if (Basename(argv[i]) == "pnpm") + i++; + if (argv.Count <= i || Basename(argv[i]) != "openclaw") + return false; + i++; + + i = SkipGlobalOptions(argv, i); + if (argv.Count <= i || !argv[i].Equals("config", StringComparison.OrdinalIgnoreCase)) + return false; + i++; + + i = SkipGlobalOptions(argv, i); + if (argv.Count <= i) + return false; + + return argv[i].ToLowerInvariant() is "get" or "schema" or "validate"; + } + + // Skips known global CLI options (and their values) so a read-only inspection is still + // recognized when flags precede the subcommand. Stops at the first non-option token or an + // unknown option, which then fails the verb check (safe: it only ever prompts more). + private static int SkipGlobalOptions(IReadOnlyList argv, int i) + { + while (i < argv.Count) + { + var token = argv[i]; + if (FlagGlobalOptions.Contains(token)) + { + i++; + continue; + } + if (ValueGlobalOptions.Contains(token)) + { + i += 2; // option plus its value + continue; + } + // "--option=value" form carries its own value. + if (token.StartsWith("--", StringComparison.Ordinal) && token.Contains('=')) + { + i++; + continue; + } + break; + } + return i; + } + + private static string Basename(string token) + { + var normalized = token.Replace('\\', '/'); + var slash = normalized.LastIndexOf('/'); + var name = slash >= 0 ? normalized[(slash + 1)..] : normalized; + foreach (var ext in new[] { ".exe", ".cmd", ".bat", ".com" }) + { + if (name.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) + { + name = name[..^ext.Length]; + break; + } + } + return name.ToLowerInvariant(); + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalCommandDisplaySanitizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalCommandDisplaySanitizer.cs index a6f4e6efd..fbe17c0d5 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalCommandDisplaySanitizer.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalCommandDisplaySanitizer.cs @@ -11,41 +11,253 @@ namespace OpenClaw.Shared.ExecApprovals; /// public static class ExecApprovalCommandDisplaySanitizer { + private const int MaxInput = 256 * 1024; + private const int MaxOutput = 16 * 1024; + private const string TruncationMarker = "โ€ฆ[truncated]"; + private const string OversizedMarker = "[exec approval command exceeds display size limit; full text suppressed]"; + private const string WarningOversizedMarker = "[exec approval warning exceeds display size limit; full text suppressed]"; + private const string BypassMask = "***"; + public static string Sanitize(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + return SanitizeInternal(text).Text; + } + + public static (string Text, bool Truncated, bool Oversized) SanitizeWithStatus(string text) + { + var result = SanitizeInternal(text); + return (result.Text, result.Truncated, result.Oversized); + } + + public static string SanitizeWarningText(string text) + { + return SanitizeInternal( + NormalizeDisplayLineBreaks(text), + preserveLineBreaks: true, + oversizedMarker: WarningOversizedMarker).Text; + } + + private static SanitizedDisplayText SanitizeInternal( + string text, + bool preserveLineBreaks = false, + string? oversizedMarker = null) + { + if (text.Length > MaxInput) + { + return new SanitizedDisplayText( + oversizedMarker ?? OversizedMarker, + Truncated: false, + Oversized: true); + } + + var rawRedacted = ExecApprovalSecretRedactor.Redact(text); + var strippedView = BuildStrippedView(text); + var strippedRedacted = ExecApprovalSecretRedactor.Redact(strippedView.Text); + + if (strippedRedacted == strippedView.Text) + return TruncateForDisplay(EscapeInvisibles(rawRedacted, preserveLineBreaks)); + + var rawMask = ExecApprovalSecretRedactor.ComputeRedactionBitmap(text); + var strippedMask = ExecApprovalSecretRedactor.ComputeRedactionBitmap(strippedView.Text); + var bypassDetected = false; + for (var i = 0; i < strippedMask.Length; i++) + { + if (strippedMask[i] && !rawMask[strippedView.StrippedToOriginal[i]]) + { + bypassDetected = true; + break; + } + } + + if (!bypassDetected) + return TruncateForDisplay(EscapeInvisibles(rawRedacted, preserveLineBreaks)); + + var unionMask = (bool[])rawMask.Clone(); + for (var i = 0; i < strippedMask.Length; i++) + { + if (strippedMask[i]) + unionMask[strippedView.StrippedToOriginal[i]] = true; + } + + return TruncateForDisplay(RenderUnionMask(text, unionMask, preserveLineBreaks)); + } + + private static string NormalizeDisplayLineBreaks(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + return text + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal) + .Replace("\u2028", "\n", StringComparison.Ordinal) + .Replace("\u2029", "\n", StringComparison.Ordinal); + } + + private static string EscapeInvisibles(string text, bool preserveLineBreaks) { if (string.IsNullOrEmpty(text)) return text; var sanitized = new StringBuilder(text.Length); - // Iterate by runes, not chars: Format-category code points exist outside the - // BMP and a per-char loop would misclassify surrogate pairs. - foreach (var rune in text.EnumerateRunes()) + for (var i = 0; i < text.Length;) { - if (ShouldEscape(rune)) - sanitized.Append("\\u{").Append(rune.Value.ToString("X")).Append('}'); + var codePoint = ReadCodePoint(text, i, out var codeUnitLength); + if (preserveLineBreaks && codePoint == '\n') + { + sanitized.Append('\n'); + } + else if (IsInvisible(codePoint)) + { + AppendCodePointEscape(sanitized, codePoint); + } else - sanitized.Append(rune.ToString()); + { + sanitized.Append(text, i, codeUnitLength); + } + + i += codeUnitLength; } + return sanitized.ToString(); } - private static bool ShouldEscape(Rune rune) + private static StrippedView BuildStrippedView(string original) + { + var stripped = new StringBuilder(original.Length); + var strippedToOriginal = new List(original.Length); + + for (var i = 0; i < original.Length;) + { + var codePoint = ReadCodePoint(original, i, out var codeUnitLength); + if (!IsInvisible(codePoint)) + { + stripped.Append(original, i, codeUnitLength); + for (var k = 0; k < codeUnitLength; k++) + strippedToOriginal.Add(i + k); + } + + i += codeUnitLength; + } + + return new StrippedView(stripped.ToString(), strippedToOriginal.ToArray()); + } + + private static string RenderUnionMask(string text, bool[] unionMask, bool preserveLineBreaks) + { + var rendered = new StringBuilder(text.Length); + for (var i = 0; i < text.Length;) + { + if (unionMask[i]) + { + var j = i; + while (j < text.Length && unionMask[j]) + j++; + + rendered.Append(BypassMask); + i = j; + continue; + } + + var codePoint = ReadCodePoint(text, i, out var codeUnitLength); + if (preserveLineBreaks && codePoint == '\n') + { + rendered.Append('\n'); + } + else if (IsInvisible(codePoint)) + { + AppendCodePointEscape(rendered, codePoint); + } + else + { + rendered.Append(text, i, codeUnitLength); + } + + i += codeUnitLength; + } + + return rendered.ToString(); + } + + private static SanitizedDisplayText TruncateForDisplay(string text) + { + if (text.Length <= MaxOutput) + return new SanitizedDisplayText(text, Truncated: false, Oversized: false); + + return new SanitizedDisplayText( + TruncateUtf16Safe(text, MaxOutput) + TruncationMarker, + Truncated: true, + Oversized: false); + } + + private static string TruncateUtf16Safe(string input, int maxLength) + { + var limit = Math.Max(0, maxLength); + if (input.Length <= limit) + return input; + + var end = limit; + if (end > 0 + && end < input.Length + && char.IsHighSurrogate(input[end - 1]) + && char.IsLowSurrogate(input[end])) + { + end--; + } + + return input[..end]; + } + + private static bool IsInvisible(int codePoint) { - var category = Rune.GetUnicodeCategory(rune); + var category = GetUnicodeCategory(codePoint); if (category is UnicodeCategory.Control or UnicodeCategory.Format + or UnicodeCategory.Surrogate or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) { return true; } - // Non-ASCII space separators (NBSP, narrow NBSP, ideographic space, โ€ฆ) render - // like a plain space but are handled differently by shells/parsers. - if (category == UnicodeCategory.SpaceSeparator && rune.Value != 0x20) + if (category == UnicodeCategory.SpaceSeparator && codePoint != 0x20) return true; - // Hangul filler characters render as blank but are not classified as spaces. - return rune.Value is 0x115F or 0x1160 or 0x3164 or 0xFFA0; + return codePoint is 0x115F or 0x1160 or 0x3164 or 0xFFA0; + } + + private static UnicodeCategory GetUnicodeCategory(int codePoint) + { + if (codePoint is >= 0xD800 and <= 0xDFFF) + return UnicodeCategory.Surrogate; + + return Rune.GetUnicodeCategory(new Rune(codePoint)); + } + + private static int ReadCodePoint(string text, int index, out int codeUnitLength) + { + var current = text[index]; + if (char.IsHighSurrogate(current) + && index + 1 < text.Length + && char.IsLowSurrogate(text[index + 1])) + { + codeUnitLength = 2; + return char.ConvertToUtf32(current, text[index + 1]); + } + + codeUnitLength = 1; + return current; } + + private static void AppendCodePointEscape(StringBuilder builder, int codePoint) + { + builder.Append("\\u{").Append(codePoint.ToString("X")).Append('}'); + } + + private sealed record SanitizedDisplayText(string Text, bool Truncated, bool Oversized); + + private sealed record StrippedView(string Text, int[] StrippedToOriginal); } diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalPathDisplay.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalPathDisplay.cs new file mode 100644 index 000000000..1408e41e2 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalPathDisplay.cs @@ -0,0 +1,49 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Expands Windows 8.3 short path components (for example PROGRA~1) to their long form +/// for display in the approval prompt, so the executable path the owner reviews reads the way +/// it looks on disk. Display-only: the resolved binary is unchanged (8.3 and long names refer +/// to the same file). No-op off Windows, on empty input, or when the path cannot be expanded. +/// +internal static class ExecApprovalPathDisplay +{ + public static string? ExpandShortPath(string? path) + { + if (string.IsNullOrEmpty(path) || !OperatingSystem.IsWindows()) + return path; + + // Fast path: a path with no "~" cannot contain an 8.3 component. + if (!path.Contains('~')) + return path; + + try + { + var buffer = new StringBuilder(1024); + var length = GetLongPathNameW(path, buffer, (uint)buffer.Capacity); + if (length == 0) + return path; // path does not exist or has no long form; leave as-is + + if (length > buffer.Capacity) + { + buffer = new StringBuilder((int)length); + length = GetLongPathNameW(path, buffer, (uint)buffer.Capacity); + if (length == 0) + return path; + } + + return buffer.ToString(0, (int)Math.Min(length, buffer.Length)); + } + catch + { + return path; + } + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetLongPathNameW(string lpszShortPath, StringBuilder lpszLongPath, uint cchBuffer); +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalSecretRedactor.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalSecretRedactor.cs new file mode 100644 index 000000000..114159eb9 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalSecretRedactor.cs @@ -0,0 +1,1158 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenClaw.Shared.ExecApprovals; + +public static class ExecApprovalSecretRedactor +{ + private const int DefaultMinLength = 18; + private const int DefaultKeepStart = 6; + private const int DefaultKeepEnd = 4; + private const string Mask = "***"; + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + private static readonly RegexOptions DefaultRegexOptions = RegexOptions.CultureInvariant; + private static readonly RegexOptions IgnoreCaseRegexOptions = DefaultRegexOptions | RegexOptions.IgnoreCase; + + private const string PaymentCredentialEnvKeys = @"CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN"; + private const string PaymentCredentialQueryKeys = @"card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token"; + private const string AuthQueryKeys = @"access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|id[-_]?token|api[-_]?key|apikey|client[-_]?secret|app[-_]?secret|private[-_]?key|credential|authorization|token|key|secret|password|pass|passwd|auth|jwt|session|code|signature|x[-_]?amz[-_]?(?:signature|security[-_]?token)"; + private const string FormBodyFirstPairKeys = AuthQueryKeys + @"|app[-_]?secret|credential|" + PaymentCredentialQueryKeys; + private const string StandaloneAssignmentSecretKeys = @"access_token|refresh_token|id_token|auth[-_]?token|hook[-_]?token|api[-_]?key|client[-_]?secret|app[-_]?secret|private[-_]?key|authorization|jwt|token|secret|password|pass|passwd|credential|" + PaymentCredentialQueryKeys; + private const string FormBodyKeyInvisibleChars = @"\p{Cc}\p{Cf}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0"; + private const string FormBodyKey = @"[" + FormBodyKeyInvisibleChars + @"+]*(?:[A-Za-z_]|%[0-9A-Fa-f]{2})(?:[A-Za-z0-9_.-]|%[0-9A-Fa-f]{2}|[" + FormBodyKeyInvisibleChars + @"+])*"; + private const string FormBodyValue = @"[^&\s<>]*"; + private const string UrlQueryValue = @"[^&#\s<>]*"; + private const string FormBodyPair = FormBodyKey + "=" + FormBodyValue; + private const string PaymentCredentialJsonKeys = @"cardNumber|card_number|cardCvc|card_cvc|cardCvv|card_cvv|cvc|cvv|securityCode|security_code|paymentCredential|payment_credential|sharedPaymentToken|shared_payment_token"; + private const string Base64SafeTokenBoundary = @"(^|[^A-Za-z0-9])"; + private const string IdentifierSafeTokenBoundary = @"(^|[^A-Za-z0-9_])"; + + private static readonly HashSet BodySecretKeys = new(StringComparer.Ordinal) + { + "access_token", "auth_token", "hook_token", "refresh_token", "id_token", "token", "api_key", + "apikey", "client_secret", "app_secret", "password", "pass", "passwd", "auth", "jwt", + "session", "code", "signature", "x_amz_signature", "x_amz_security_token", "secret", + "credential", "private_key", "authorization", "key", "card_number", "card_cvc", "card_cvv", + "cvc", "cvv", "security_code", "payment_credential", "shared_payment_token", + }; + + private static readonly HashSet SecretValueQuoteChars = new() { '"', '\'', '`' }; + + private static readonly Regex FormBodyKeyObfuscationRe = CreateRegex(@"[" + FormBodyKeyInvisibleChars + @"+]", DefaultRegexOptions); + private static readonly Regex FormBodyKeySeparatorRe = CreateRegex(@"[\p{Cc}\p{Cf}\p{Z}\u115F\u1160\u3164\uFFA0+]", DefaultRegexOptions); + private static readonly Regex FormBodyPercentEscapeRe = CreateRegex(@"%[0-9A-Fa-f]{2}", DefaultRegexOptions); + private static readonly Regex FormBodyRe = CreateRegex(@"^" + FormBodyPair + @"(?:&" + FormBodyPair + @")+$", DefaultRegexOptions); + private static readonly Regex FormBodySubstringRe = CreateRegex(@"(^|[\s:({\[,=""'`])(" + FormBodyPair + @"(?:&" + FormBodyPair + @")+)", DefaultRegexOptions); + private static readonly Regex EncodedFormPairRe = CreateRegex(@"(^|[\s:({\[,=""'`&])(" + FormBodyKey + @")=(" + FormBodyValue + ")", DefaultRegexOptions); + private static readonly Regex FormBodyContextSinglePairRe = CreateRegex(@"(\b(?:body|form(?:[-_\s]?body)?)\s*[:=]\s*([""'\x60]?))(" + FormBodyKey + @")=(" + FormBodyValue + @")([""'\x60]?)", IgnoreCaseRegexOptions); + private static readonly Regex UrlQueryPairRe = CreateRegex(@"([?&])(" + FormBodyKey + @")=(" + UrlQueryValue + ")", DefaultRegexOptions); + private static readonly Regex SecretValueTrailingDelimiterRe = CreateRegex(@"([""'`,;)}\]]+)$", DefaultRegexOptions); + private static readonly Regex SecretValueSuffixRe = CreateRegex(@"^[""'`,;)}\]]*$", DefaultRegexOptions); + private static readonly Regex FormBodyLineBreakSplitRe = CreateRegex(@"(\r\n|\r|\n)", DefaultRegexOptions); + private static readonly Regex FormBodyLineBreakSegmentRe = CreateRegex(@"^(?:\r\n|\r|\n)$", DefaultRegexOptions); + private static readonly Regex ShellReferenceBareRe = CreateRegex(@"^\$([A-Z_][A-Z0-9_]*)$", DefaultRegexOptions); + private static readonly Regex ShellReferenceBracedRe = CreateRegex(@"^\$\{([A-Z_][A-Z0-9_]*)(?::[-=?+])?\}$", DefaultRegexOptions); + private static readonly Regex EnvAssignmentKeyRe = CreateRegex(@"\b([A-Z_][A-Z0-9_]*)\b\s*[=:]", DefaultRegexOptions); + private static readonly Regex EmptyShellParameterExpansionTailRe = CreateRegex(@"^[-=?+]\}$", DefaultRegexOptions); + private static readonly Regex AuthHeaderStartRe = CreateRegex(@"(?:Authorization|Proxy-Authorization)(?:\\+[""'])?\s*[:=]\s*(?:\\+[""'])?", IgnoreCaseRegexOptions); + private static readonly Regex StructuredAuthHeaderSerializedKeyRe = CreateRegex(@"[""'](?:Authorization|Proxy-Authorization)[""']\s*[:=]\s*[""']", IgnoreCaseRegexOptions); + + internal static IReadOnlyCollection RegisteredSecretValues { get; set; } = Array.Empty(); + + private static readonly ReadOnlyCollection DefaultPatterns = BuildDefaultPatterns().AsReadOnly(); + + public static string Redact(string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + var next = RedactRegisteredSecretValues(text); + next = RedactStructuredAuthHeaders(next); + next = RedactUrlQueryPairs(next); + next = RedactFormBody(next); + + foreach (var pattern in DefaultPatterns) + { + next = ReplacePattern(next, pattern); + } + + return next; + } + + public static bool[] ComputeRedactionBitmap(string text) + { + if (string.IsNullOrEmpty(text)) + { + return Array.Empty(); + } + + var bitmap = new bool[text.Length]; + MarkStructuredAuthHeaderRedactions(text, bitmap); + MarkUrlQueryPairRedactions(text, bitmap); + MarkFormBodyRedactions(text, bitmap); + foreach (var pattern in DefaultPatterns) + { + MarkPatternRedactions(text, bitmap, pattern); + } + + return bitmap; + } + + internal static string MaskToken(string token) + { + if (token == Mask) + { + return token; + } + + if (token.Length < DefaultMinLength) + { + return Mask; + } + + return SliceUtf16Safe(token, 0, DefaultKeepStart) + "โ€ฆ" + SliceUtf16Safe(token, -DefaultKeepEnd); + } + + private static string RedactRegisteredSecretValues(string text) + { + if (RegisteredSecretValues.Count == 0) + { + return text; + } + + var values = RegisteredSecretValues.Where(value => !string.IsNullOrEmpty(value)).OrderByDescending(value => value.Length).ToArray(); + if (values.Length == 0 || !values.Select(value => value[0]).Distinct().Any(text.Contains)) + { + return text; + } + + var matcher = CreateRegex(string.Join("|", values.Select(Regex.Escape)), DefaultRegexOptions); + return matcher.Replace(text, match => MaskToken(match.Value)); + } + + private static string SliceUtf16Safe(string value, int start, int? end = null) + { + var actualStart = start < 0 ? Math.Max(value.Length + start, 0) : Math.Min(start, value.Length); + var actualEnd = end is null ? value.Length : end.Value < 0 ? Math.Max(value.Length + end.Value, 0) : Math.Min(end.Value, value.Length); + if (actualEnd < actualStart) + { + actualEnd = actualStart; + } + + if (actualEnd > actualStart && actualEnd < value.Length && char.IsHighSurrogate(value[actualEnd - 1]) && char.IsLowSurrogate(value[actualEnd])) + { + actualEnd--; + } + + if (actualStart > 0 && actualStart < value.Length && char.IsLowSurrogate(value[actualStart]) && char.IsHighSurrogate(value[actualStart - 1])) + { + actualStart++; + } + + return value.Substring(actualStart, actualEnd - actualStart); + } + + private static string ReplacePattern(string text, RedactRegex pattern) + { + try + { + return pattern.Regex.Replace(text, match => RedactMatch(match, pattern, text)); + } + catch (RegexMatchTimeoutException) + { + // Fail closed: a timed-out redaction pass must never return the unredacted text + // (which could leak a secret the pattern would have masked). Propagate so the display + // sanitizer/prompt handler fails closed (denies) rather than showing raw content. + throw; + } + } + + private static string RedactMatch(Match match, RedactRegex pattern, string input) + { + if (match.Value.Contains("PRIVATE KEY-----", StringComparison.Ordinal)) + { + return RedactPemBlock(match.Value); + } + + var selected = SelectSecretCapture(match); + var token = selected.Value; + if (SplitSecretValueForMask(token).Maskable == Mask) + { + return match.Value; + } + + if (pattern.Base64Boundary && IsInsideBase64Payload(input, match.Index + selected.Start)) + { + return match.Value; + } + + if (pattern.ShellReferencePreserving && (ShouldPreserveShellReferenceMatch(match.Value, token) || EmptyShellParameterExpansionTailRe.IsMatch(token))) + { + return match.Value; + } + + var masked = pattern.ShellReferencePreserving ? MaskToken(token) : MaskSecretValue(token, hinted: true); + if (selected.Start < 0) + { + return match.Value; + } + + return match.Value[..selected.Start] + masked + match.Value[(selected.Start + token.Length)..]; + } + + private static bool IsInsideBase64Payload(string input, int tokenStart) + { + if (tokenStart <= 0) + { + return false; + } + + var left = input[..tokenStart]; + var marker = left.LastIndexOf(";base64,", StringComparison.OrdinalIgnoreCase); + if (marker < 0) + { + return false; + } + + for (var i = marker + ";base64,".Length; i < left.Length; i++) + { + var c = left[i]; + if (!(char.IsAsciiLetterOrDigit(c) || c is '+' or '/' or '=')) + { + return false; + } + } + + return true; + } + + private static SecretCaptureSelection SelectSecretCapture(Match match) + { + var tokens = new List(); + for (var index = 1; index < match.Groups.Count; index++) + { + var group = match.Groups[index]; + if (group.Success && group.Length > 0) + { + tokens.Add(new SecretCaptureSelection(index - 1, group.Value, group.Index - match.Index)); + } + } + + return tokens.Count switch + { + 0 => new SecretCaptureSelection(-1, match.Value, 0), + 1 => tokens[0], + _ => tokens[^1], + }; + } + + private static SecretValueParts SplitSecretValueForMask(string token) + { + var openingQuote = token.Length > 0 ? token[0] : '\0'; + if (SecretValueQuoteChars.Contains(openingQuote)) + { + var closingQuoteIndex = token.LastIndexOf(openingQuote); + if (closingQuoteIndex > 0) + { + var quotedSuffix = token[(closingQuoteIndex + 1)..]; + if (SecretValueSuffixRe.IsMatch(quotedSuffix)) + { + return new SecretValueParts(token[1..closingQuoteIndex], quotedSuffix, 0, closingQuoteIndex + 1); + } + } + + var withoutLeadingQuote = token[1..]; + var trailingDelimiter = SecretValueTrailingDelimiterRe.Match(withoutLeadingQuote); + var delimiter = trailingDelimiter.Success ? trailingDelimiter.Groups[1].Value : string.Empty; + var hasDelimiter = delimiter.Length > 0 && delimiter.Length < withoutLeadingQuote.Length; + var maskable = hasDelimiter ? withoutLeadingQuote[..^delimiter.Length] : withoutLeadingQuote; + return new SecretValueParts(maskable, hasDelimiter ? delimiter : string.Empty, 0, 1 + maskable.Length); + } + + var trailing = SecretValueTrailingDelimiterRe.Match(token); + var suffix = trailing.Success ? trailing.Groups[1].Value : string.Empty; + var hasSuffix = suffix.Length > 0 && suffix.Length < token.Length; + var bareMaskable = hasSuffix ? token[..^suffix.Length] : token; + return new SecretValueParts(bareMaskable, hasSuffix ? suffix : string.Empty, 0, bareMaskable.Length); + } + + private static string MaskSecretValue(string token, bool hinted = false) + { + var parts = SplitSecretValueForMask(token); + return (hinted ? MaskToken(parts.Maskable) : Mask) + parts.Suffix; + } + + private static string NormalizeSensitiveKeyName(string value) + { + var stripped = FormBodyKeySeparatorRe.Replace(value, string.Empty); + try + { + return FormBodyKeySeparatorRe.Replace(Uri.UnescapeDataString(stripped), string.Empty) + .ToLowerInvariant() + .Replace("-", "_", StringComparison.Ordinal); + } + catch (UriFormatException) + { + return stripped.ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + } + } + + private static bool IsSensitiveBodyKey(string key) => BodySecretKeys.Contains(NormalizeSensitiveKeyName(key)); + + private static bool HasEncodedOrInvisibleFormKey(string key) => + FormBodyPercentEscapeRe.IsMatch(key) || FormBodyKeyObfuscationRe.Replace(key, string.Empty) != key; + + private static string RedactFormEncodedPairs(string value, bool maskValuesHinted = false, bool onlyEncodedOrInvisibleKeys = false) + { + return string.Join("&", value.Split('&').Select(pair => + { + var equalsIndex = pair.IndexOf('=', StringComparison.Ordinal); + if (equalsIndex < 0) + { + return pair; + } + + var key = pair[..equalsIndex]; + if (onlyEncodedOrInvisibleKeys && !HasEncodedOrInvisibleFormKey(key)) + { + return pair; + } + + if (!IsSensitiveBodyKey(key)) + { + return pair; + } + + var token = pair[(equalsIndex + 1)..]; + return key + "=" + MaskSecretValue(token, maskValuesHinted); + })); + } + + private static string RedactUrlQueryPairs(string text) + { + if (string.IsNullOrEmpty(text) || !text.Contains('?', StringComparison.Ordinal)) + { + return text; + } + + return UrlQueryPairRe.Replace(text, match => + { + var prefix = match.Groups[1].Value; + var key = match.Groups[2].Value; + var token = match.Groups[3].Value; + if (!HasEncodedOrInvisibleFormKey(key) || !IsSensitiveBodyKey(key)) + { + return match.Value; + } + + return prefix + key + "=" + MaskSecretValue(token, hinted: true); + }); + } + + private static string RedactEncodedFormPairs(string text) + { + if (string.IsNullOrEmpty(text) || (!text.Contains('%', StringComparison.Ordinal) && FormBodyKeyObfuscationRe.Replace(text, string.Empty) == text)) + { + return text; + } + + return EncodedFormPairRe.Replace(text, match => + { + var prefix = match.Groups[1].Value; + var key = match.Groups[2].Value; + var token = match.Groups[3].Value; + if (!HasEncodedOrInvisibleFormKey(key) || !IsSensitiveBodyKey(key)) + { + return match.Value; + } + + return prefix + key + "=" + MaskSecretValue(token); + }); + } + + private static string RedactFormBodyContextSinglePairs(string text) + { + if (string.IsNullOrEmpty(text) || !(text.Contains('=', StringComparison.Ordinal) || text.Contains(':', StringComparison.Ordinal))) + { + return text; + } + + return FormBodyContextSinglePairRe.Replace(text, match => + { + var prefix = match.Groups[1].Value; + var key = match.Groups[3].Value; + var token = match.Groups[4].Value; + var suffix = match.Groups[5].Value; + return IsSensitiveBodyKey(key) ? prefix + key + "=" + MaskSecretValue(token) + suffix : match.Value; + }); + } + + private static string RedactFormBodyLine(string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + var contextRedacted = RedactFormBodyContextSinglePairs(RedactEncodedFormPairs(text)); + if (!contextRedacted.Contains('&', StringComparison.Ordinal)) + { + return contextRedacted; + } + + if (FormBodyRe.IsMatch(contextRedacted)) + { + return RedactFormEncodedPairs(contextRedacted); + } + + var redacted = FormBodySubstringRe.Replace(contextRedacted, match => + { + var prefix = match.Groups[1].Value; + var body = match.Groups[2].Value; + var redactedBody = RedactFormEncodedPairs(body); + return redactedBody == body ? match.Value : prefix + redactedBody; + }); + + return RedactFormBodyContextSinglePairs(RedactEncodedFormPairs(redacted)); + } + + private static string RedactFormBody(string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + if (!FormBodyLineBreakSplitRe.IsMatch(text)) + { + return RedactFormBodyLine(text); + } + + return string.Concat(FormBodyLineBreakSplitRe.Split(text).Select(segment => + FormBodyLineBreakSegmentRe.IsMatch(segment) ? segment : RedactFormBodyLine(segment))); + } + + private static string RedactPemBlock(string block) + { + var lines = Regex.Split(block, @"\r?\n").Where(line => line.Length > 0).ToArray(); + return lines.Length < 2 ? Mask : lines[0] + "\nโ€ฆredactedโ€ฆ\n" + lines[^1]; + } + + private static bool IsShellReferenceToKey(string key, string value) + { + if (!Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", DefaultRegexOptions, RegexTimeout)) + { + return false; + } + + var bare = ShellReferenceBareRe.Match(value); + if (bare.Success) + { + return string.Equals(bare.Groups[1].Value, key, StringComparison.Ordinal); + } + + var braced = ShellReferenceBracedRe.Match(value); + return braced.Success && string.Equals(braced.Groups[1].Value, key, StringComparison.Ordinal); + } + + private static string? ReadEnvAssignmentKey(string match) + { + var key = EnvAssignmentKeyRe.Match(match); + return key.Success ? key.Groups[1].Value : null; + } + + private static bool ShouldPreserveShellReferenceMatch(string match, string token) + { + var key = ReadEnvAssignmentKey(match); + return key is not null && IsShellReferenceToKey(key, token); + } + + private static string RedactStructuredAuthHeaders(string text) + { + if (!text.Contains("uthorization", StringComparison.OrdinalIgnoreCase)) + { + return text; + } + + text = RedactSerializedAuthHeaderFields(text); + var builder = new StringBuilder(text.Length); + var cursor = 0; + foreach (Match match in AuthHeaderStartRe.Matches(text)) + { + if (match.Index < cursor) + { + continue; + } + + var valueStart = match.Index + match.Length; + var replacement = TryRedactStructuredAuthHeader(text, match.Index, valueStart, out var end); + if (replacement is null) + { + continue; + } + + builder.Append(text, cursor, valueStart - cursor); + builder.Append(replacement); + cursor = end; + } + + if (cursor == 0) + { + return text; + } + + builder.Append(text, cursor, text.Length - cursor); + return builder.ToString(); + } + + private static string RedactSerializedAuthHeaderFields(string text) + { + var prefixRe = CreateRegex(@"(?(?:\\+)?[""'](?:Authorization|Proxy-Authorization)(?:\\+)?[""']\s*:\s*(?:\\+)?[""'])", IgnoreCaseRegexOptions); + var builder = new StringBuilder(text.Length); + var cursor = 0; + foreach (Match match in prefixRe.Matches(text)) + { + if (match.Index < cursor) + { + continue; + } + + var valueStart = match.Index + match.Length; + var escaped = match.Value.Contains('\\', StringComparison.Ordinal); + var valueEnd = FindSerializedHeaderValueEnd(text, valueStart, escaped); + if (valueEnd < valueStart) + { + continue; + } + + var value = text[valueStart..valueEnd]; + var redacted = RedactSerializedAuthHeaderValue(value, plainJson: !escaped); + if (redacted == value) + { + continue; + } + + builder.Append(text, cursor, valueStart - cursor); + builder.Append(redacted); + cursor = valueEnd; + } + + if (cursor == 0) + { + return text; + } + + builder.Append(text, cursor, text.Length - cursor); + return builder.ToString(); + } + + private static int FindSerializedHeaderValueEnd(string text, int start, bool escaped) + { + for (var index = start; index < text.Length; index++) + { + if (text[index] != '"' && text[index] != '\'') + { + continue; + } + + var slashCount = 0; + for (var back = index - 1; back >= 0 && text[back] == '\\'; back--) + { + slashCount++; + } + + if (!escaped) + { + if (slashCount % 2 == 0) + { + return index; + } + + continue; + } + + if (slashCount == 0) + { + continue; + } + + var next = index + 1; + if (next >= text.Length || text[next] == '}') + { + return index - slashCount; + } + + if (text[next] == ',') + { + var afterComma = next + 1; + while (afterComma < text.Length && char.IsWhiteSpace(text[afterComma])) + { + afterComma++; + } + + if (afterComma < text.Length && text[afterComma] == '\\') + { + return index - slashCount; + } + } + } + + return -1; + } + + private static string RedactSerializedAuthHeaderValue(string value, bool plainJson) + { + var split = value.IndexOf(' '); + if (split < 0) + { + return MaskSecretValue(value, hinted: true); + } + + var scheme = value[..split]; + var rest = value[(split + 1)..]; + if (scheme.Equals("Basic", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("Bot", StringComparison.OrdinalIgnoreCase)) + { + return scheme + " " + MaskSecretValue(rest, hinted: true); + } + + if (rest.Contains('=', StringComparison.Ordinal) || rest.Contains(',', StringComparison.Ordinal)) + { + return plainJson ? Mask : scheme + " " + Mask; + } + + return scheme + " " + MaskSecretValue(rest, hinted: true); + } + + private static string? TryRedactStructuredAuthHeader(string text, int headerStart, int valueStart, out int end) + { + end = valueStart; + var scan = valueStart; + while (scan < text.Length && IsAuthWhitespaceAt(text, scan, out var whitespaceLength)) + { + scan += whitespaceLength; + } + + var schemeStart = scan; + while (scan < text.Length && IsSchemeChar(text[scan])) + { + scan++; + } + + var scheme = text[schemeStart..scan]; + if (scheme.Length == 0) + { + end = FindOpaqueAuthEnd(text, schemeStart); + return end > schemeStart ? MaskSecretValue(text[schemeStart..end], hinted: true) : null; + } + + var afterScheme = scan; + while (scan < text.Length && IsAuthWhitespaceAt(text, scan, out var ws)) + { + scan += ws; + } + + if (scan == afterScheme) + { + return null; + } + + var valueEnd = FindStructuredAuthValueEnd(text, scan, headerStart); + if (valueEnd <= scan) + { + return null; + } + + var rest = text[scan..valueEnd]; + if (!LooksLikeAuthSecretRest(scheme, rest)) + { + return null; + } + + var serializedField = headerStart > 0 && text[headerStart - 1] == '"' && StructuredAuthHeaderSerializedKeyRe.IsMatch(text[Math.Max(0, headerStart - 1)..Math.Min(text.Length, valueStart)]); + end = valueEnd; + if (serializedField && (headerStart < 2 || text[headerStart - 2] != '\\')) + { + return Mask; + } + + var suffixStart = FindStructuredAuthDiagnosticSuffix(text, scan, valueEnd); + if (suffixStart >= 0) + { + var secret = text[scan..suffixStart]; + if (!secret.Contains('=', StringComparison.Ordinal) && !secret.Contains(',', StringComparison.Ordinal)) + { + return null; + } + + end = suffixStart; + return scheme + text[afterScheme..scan] + Mask; + } + + var finalReplacement = rest.Contains('=', StringComparison.Ordinal) || rest.Contains(',', StringComparison.Ordinal) + ? Mask + : MaskSecretValue(rest, hinted: true); + return scheme + text[afterScheme..scan] + finalReplacement; + } + + private static bool LooksLikeAuthSecretRest(string scheme, string rest) + { + if (rest.Length == 0) + { + return false; + } + + if (scheme.Equals("Basic", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("Bot", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return rest.Contains('=', StringComparison.Ordinal) || + rest.Contains(',', StringComparison.Ordinal) || + rest.Length >= 18 || + scheme.Contains('+', StringComparison.Ordinal); + } + + private static int FindStructuredAuthDiagnosticSuffix(string text, int start, int end) + { + var candidates = new[] { "; status=", "; request_id=", ", status=", ";status=", ";request_id=", ",status=" }; + var best = -1; + foreach (var candidate in candidates) + { + var found = text.IndexOf(candidate, start, end - start, StringComparison.OrdinalIgnoreCase); + if (found >= 0 && (best < 0 || found < best)) + { + best = found; + } + } + + return best; + } + + private static int FindStructuredAuthValueEnd(string text, int start, int headerStart) + { + var enclosingQuote = headerStart > 0 && text[headerStart - 1] is '\'' or '"' ? text[headerStart - 1] : '\0'; + var index = start; + while (index < text.Length) + { + var c = text[index]; + if (c is '\r' or '\n') + { + var newlineLength = c == '\r' && index + 1 < text.Length && text[index + 1] == '\n' ? 2 : 1; + if (index + newlineLength < text.Length && text[index + newlineLength] is ' ' or '\t') + { + index += newlineLength; + continue; + } + + return index; + } + + if (c is ')' or '}') + { + return index; + } + + if (c is '"' or '\'' && index == text.Length - 1) + { + return index; + } + + if (enclosingQuote != '\0' && c == enclosingQuote && (index == 0 || text[index - 1] != '\\')) + { + return index; + } + + index++; + } + + return index; + } + + private static int FindOpaqueAuthEnd(string text, int start) + { + var index = start; + while (index < text.Length && !char.IsWhiteSpace(text[index]) && text[index] is not '"' and not '\'' and not ',' and not ';' and not ')' and not '}' and not ']') + { + index++; + } + + return index; + } + + private static bool IsAuthWhitespaceAt(string text, int index, out int length) + { + length = 0; + if (index >= text.Length) + { + return false; + } + + if (text[index] is ' ' or '\t') + { + length = 1; + return true; + } + + if (text[index] == '\\' && index + 1 < text.Length && text[index + 1] is 't' or 'n' or 'r') + { + length = 2; + return true; + } + + if (text[index] is '\r' or '\n') + { + length = text[index] == '\r' && index + 1 < text.Length && text[index + 1] == '\n' ? 2 : 1; + if (index + length < text.Length && text[index + length] is ' ' or '\t') + { + length++; + } + + return true; + } + + return false; + } + + private static bool IsSchemeChar(char c) => char.IsAsciiLetterOrDigit(c) || c is '-' or '+' or '.'; + + private static void MarkBitmapRange(bool[] bitmap, int start, int end) + { + var boundedStart = Math.Max(0, start); + var boundedEnd = Math.Min(bitmap.Length, end); + for (var i = boundedStart; i < boundedEnd; i++) + { + bitmap[i] = true; + } + } + + private static void MarkPatternRedactions(string text, bool[] bitmap, RedactRegex pattern) + { + try + { + foreach (Match match in pattern.Regex.Matches(text)) + { + MarkPatternMatchRedaction(bitmap, text, match, pattern); + } + } + catch (RegexMatchTimeoutException) + { + } + } + + private static void MarkPatternMatchRedaction(bool[] bitmap, string input, Match match, RedactRegex pattern) + { + if (match.Value.Contains("PRIVATE KEY-----", StringComparison.Ordinal)) + { + MarkBitmapRange(bitmap, match.Index, match.Index + match.Length); + return; + } + + var selected = SelectSecretCapture(match); + if (selected.Start < 0 || (pattern.Base64Boundary && IsInsideBase64Payload(input, match.Index + selected.Start))) + { + return; + } + + var secretValue = SplitSecretValueForMask(selected.Value); + MarkBitmapRange(bitmap, match.Index + selected.Start + secretValue.MaskStart, match.Index + selected.Start + secretValue.MaskEnd); + } + + private static void MarkUrlQueryPairRedactions(string text, bool[] bitmap) + { + if (string.IsNullOrEmpty(text) || !text.Contains('?', StringComparison.Ordinal)) + { + return; + } + + foreach (Match match in UrlQueryPairRe.Matches(text)) + { + var key = match.Groups[2].Value; + if (!HasEncodedOrInvisibleFormKey(key) || !IsSensitiveBodyKey(key)) + { + continue; + } + + var token = match.Groups[3].Value; + var secretValue = SplitSecretValueForMask(token); + var valueOffset = match.Groups[3].Index; + MarkBitmapRange(bitmap, valueOffset + secretValue.MaskStart, valueOffset + secretValue.MaskEnd); + } + } + + private static void MarkEncodedFormPairRedactions(string text, bool[] bitmap, int offset = 0) + { + if (string.IsNullOrEmpty(text) || (!text.Contains('%', StringComparison.Ordinal) && FormBodyKeyObfuscationRe.Replace(text, string.Empty) == text)) + { + return; + } + + foreach (Match match in EncodedFormPairRe.Matches(text)) + { + var key = match.Groups[2].Value; + if (!HasEncodedOrInvisibleFormKey(key) || !IsSensitiveBodyKey(key)) + { + continue; + } + + var secretValue = SplitSecretValueForMask(match.Groups[3].Value); + MarkBitmapRange(bitmap, offset + match.Groups[3].Index + secretValue.MaskStart, offset + match.Groups[3].Index + secretValue.MaskEnd); + } + } + + private static void MarkFormBodyContextSinglePairRedactions(string text, bool[] bitmap, int offset = 0) + { + if (string.IsNullOrEmpty(text) || !(text.Contains('=', StringComparison.Ordinal) || text.Contains(':', StringComparison.Ordinal))) + { + return; + } + + foreach (Match match in FormBodyContextSinglePairRe.Matches(text)) + { + var key = match.Groups[3].Value; + if (!IsSensitiveBodyKey(key)) + { + continue; + } + + var secretValue = SplitSecretValueForMask(match.Groups[4].Value); + MarkBitmapRange(bitmap, offset + match.Groups[4].Index + secretValue.MaskStart, offset + match.Groups[4].Index + secretValue.MaskEnd); + } + } + + private static void MarkSensitiveFormEncodedPairValues(bool[] bitmap, string value, int offset, bool onlyEncodedOrInvisibleKeys = false) + { + var cursor = 0; + foreach (var pair in value.Split('&')) + { + var pairStart = cursor; + cursor = pairStart + pair.Length + 1; + var equalsIndex = pair.IndexOf('=', StringComparison.Ordinal); + if (equalsIndex < 0) + { + continue; + } + + var key = pair[..equalsIndex]; + if (onlyEncodedOrInvisibleKeys && !HasEncodedOrInvisibleFormKey(key)) + { + continue; + } + + if (!IsSensitiveBodyKey(key)) + { + continue; + } + + var secretValue = SplitSecretValueForMask(pair[(equalsIndex + 1)..]); + var valueStart = pairStart + equalsIndex + 1 + secretValue.MaskStart; + var valueEnd = pairStart + equalsIndex + 1 + secretValue.MaskEnd; + MarkBitmapRange(bitmap, offset + valueStart, offset + valueEnd); + } + } + + private static void MarkFormBodyLineRedactions(string text, bool[] bitmap, int offset) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + MarkEncodedFormPairRedactions(text, bitmap, offset); + MarkFormBodyContextSinglePairRedactions(text, bitmap, offset); + if (!text.Contains('&', StringComparison.Ordinal)) + { + return; + } + + if (FormBodyRe.IsMatch(text)) + { + MarkSensitiveFormEncodedPairValues(bitmap, text, offset); + return; + } + + foreach (Match match in FormBodySubstringRe.Matches(text)) + { + MarkSensitiveFormEncodedPairValues(bitmap, match.Groups[2].Value, offset + match.Groups[2].Index); + } + } + + private static void MarkFormBodyRedactions(string text, bool[] bitmap) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + if (!FormBodyLineBreakSplitRe.IsMatch(text)) + { + MarkFormBodyLineRedactions(text, bitmap, 0); + return; + } + + var offset = 0; + foreach (var segment in FormBodyLineBreakSplitRe.Split(text)) + { + if (!FormBodyLineBreakSegmentRe.IsMatch(segment)) + { + MarkFormBodyLineRedactions(segment, bitmap, offset); + } + + offset += segment.Length; + } + } + + private static void MarkStructuredAuthHeaderRedactions(string text, bool[] bitmap) + { + if (!text.Contains("uthorization", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + foreach (Match match in AuthHeaderStartRe.Matches(text)) + { + var valueStart = match.Index + match.Length; + var replacement = TryRedactStructuredAuthHeader(text, match.Index, valueStart, out var end); + if (replacement is not null) + { + MarkBitmapRange(bitmap, valueStart, end); + } + } + } + + private static Regex CreateRegex(string pattern, RegexOptions options) => new(pattern, options, RegexTimeout); + + private static List BuildDefaultPatterns() + { + var envAssignmentPattern = @"\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|" + PaymentCredentialEnvKeys + @")\b\s*[=:]\s*([""']?)([^\s""'\\]+)\1"; + var escapedEnvAssignmentPattern = @"\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|" + PaymentCredentialEnvKeys + @")\b\s*[=:]\s*\\+([""'])([^\s""'\\]+)\\+\1"; + var standaloneAssignmentQuotedPattern = @"(^|[\s,;])(?:" + StandaloneAssignmentSecretKeys + @")=([""'\x60])((?:(?!\2)[^\r\n])+)\2"; + var standaloneAssignmentPattern = @"(^|[\s,;])(?:" + StandaloneAssignmentSecretKeys + @")=([""'\x60]?[^\s&#""'\x60<>]+)"; + var telegramBotTokenPattern = @"\bbot(\d{6,}:[A-Za-z0-9_-]{20,})\b"; + var telegramTokenPattern = @"\b(\d{6,}:[A-Za-z0-9_-]{20,})\b"; + var authorizationBearerPattern = @"Authorization(?:\\+)?[""']?[ \t]*[:=](?:[ \t]|\\[trn]|\r?\n[ \t]*)*(?:\\+)?[""']?Bearer(?:[ \t]|\\[trn]|\r?\n[ \t]*)+((?>[-A-Za-z0-9._~+/=:]+))(?!โ€ฆ)"; + var authorizationBasicPattern = @"Authorization(?:\\+)?[""']?[ \t]*[:=](?:[ \t]|\\[trn]|\r?\n[ \t]*)*(?:\\+)?[""']?Basic(?:[ \t]|\\[trn]|\r?\n[ \t]*)+((?>[-A-Za-z0-9._~+/=:]+))(?!โ€ฆ)"; + var authorizationBotPattern = @"Authorization(?:\\+)?[""']?[ \t]*[:=](?:[ \t]|\\[trn]|\r?\n[ \t]*)*(?:\\+)?[""']?Bot(?:[ \t]|\\[trn]|\r?\n[ \t]*)+((?>[-A-Za-z0-9._~+/=:]+))(?!โ€ฆ)"; + var standaloneBearerPattern = @"\bBearer\s+([-A-Za-z0-9._~+/=]{18,})(?![-A-Za-z0-9._~+/=])"; + + var patterns = new List + { + Add(envAssignmentPattern, DefaultRegexOptions, shellReferencePreserving: true), + Add(escapedEnvAssignmentPattern, DefaultRegexOptions, shellReferencePreserving: true), + Add(@"[?&](?:" + AuthQueryKeys + @"|" + PaymentCredentialQueryKeys + @")=([^&#\s<>]+)", IgnoreCaseRegexOptions), + Add(@"""(?:apiKey|api_key|apiToken|api_token|bearerToken|bearer_token|token|secret|password|passwd|credential|authorization|accessToken|access_token|refreshToken|refresh_token|idToken|id_token|authToken|auth_token|clientSecret|client_secret|privateKey|private_key|secret_value|raw_secret|secret_input|key_material|" + PaymentCredentialJsonKeys + @")""\s*:\s*""([^""]+)""", IgnoreCaseRegexOptions), + Add(@"(^|[\s,{])[""']?(?:api[-_]key|access[-_]token|refresh[-_]token|id[-_]token|authToken|auth[-_]token|clientSecret|client[-_]secret|appSecret|app[-_]secret|private[-_]key|credential|authorization|secret[-_]value|raw[-_]secret|secret[-_]input|key[-_]material)[""']?\s*[:=]\s*([""'])([^""'\r\n]+)\2", IgnoreCaseRegexOptions), + Add(@"(^|[\s,{])[""']?(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-auth-token)[""']?\s*[:=]\s*([""'])([^""'\r\n]+)\2", IgnoreCaseRegexOptions), + Add(@"--(?:api[-_]?key|hook[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|password|passwd|credential|private[-_]?key|client[-_]?secret|" + PaymentCredentialQueryKeys + @")\s+(?!(?:or|and)\b(?=\s+--))([""']?)([^\s""']+)\1", IgnoreCaseRegexOptions), + Add(authorizationBearerPattern, IgnoreCaseRegexOptions), + Add(authorizationBasicPattern, IgnoreCaseRegexOptions), + Add(authorizationBotPattern, IgnoreCaseRegexOptions), + Add(@"(?:^|[\s({\[,])Proxy-Authorization(?:\\+)?[""']?[ \t]*[:=](?:[ \t]|\\[trn]|\r?\n[ \t]*)*(?:\\+)?[""']?(?![A-Za-z][A-Za-z0-9+.-]*\s+\*\*\*)(?:[A-Za-z][A-Za-z0-9+.-]*\s+((?>[-A-Za-z0-9._~+/=:]+))|(?![A-Za-z][A-Za-z0-9+.-]*\s+[-A-Za-z0-9._~+/=:])((?>[-A-Za-z0-9._~+/=:]+)))(?!โ€ฆ)", IgnoreCaseRegexOptions), + Add(@"(?:^|[\s({\[,])Authorization(?:\\+)?[""']?[ \t]*[:=](?:[ \t]|\\[trn]|\r?\n[ \t]*)*(?:\\+)?[""']?(?!(?:Bearer|Basic|Bot)(?:[ \t]|\\[trn]|\r?\n[ \t]*))(?![A-Za-z][A-Za-z0-9+.-]*\s+\*\*\*)(?:[A-Za-z][A-Za-z0-9+.-]*\s+((?>[-A-Za-z0-9._~+/=:]+))|(?![A-Za-z][A-Za-z0-9+.-]*\s+[-A-Za-z0-9._~+/=:])((?>[-A-Za-z0-9._~+/=:]+)))(?!โ€ฆ)", IgnoreCaseRegexOptions), + Add(@"(^|[\s,{])(?:x-authorization|api-key|x-goog-api-key|x-access-token|x-api-key|x-auth-token)\s*[:=]\s*([^\s""',;]+)", IgnoreCaseRegexOptions), + Add(@"(?:X-OpenClaw-Token|x-pomerium-jwt-assertion|X-Api-Key|X-Auth-Token)\s*[:=]\s*([^\s""',;]+)", IgnoreCaseRegexOptions), + Add(standaloneBearerPattern, IgnoreCaseRegexOptions), + Add(@"\b(?:https?|wss?|ftp):\/\/[^\/\s:@]*:([^\/\s@]+)@", IgnoreCaseRegexOptions), + Add(@"\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^:\s/@]*:([^@\s]+)@", IgnoreCaseRegexOptions), + Add(@"(^|[\s,;])(?:" + FormBodyFirstPairKeys + @")=([^&\s]+)(?=&[A-Za-z_][A-Za-z0-9_.-]*=)", IgnoreCaseRegexOptions), + Add(standaloneAssignmentQuotedPattern, IgnoreCaseRegexOptions, shellReferencePreserving: true), + Add(standaloneAssignmentPattern, IgnoreCaseRegexOptions, shellReferencePreserving: true), + Add(@"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----", IgnoreCaseRegexOptions), + Add(@"(? + new(CreateRegex(source, options), shellReferencePreserving, base64Boundary); + + private sealed record RedactRegex(Regex Regex, bool ShellReferencePreserving = false, bool Base64Boundary = false); + + private sealed record SecretCaptureSelection(int Index, string Value, int Start); + + private sealed record SecretValueParts(string Maskable, string Suffix, int MaskStart, int MaskEnd); +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs index 7d455a161..d1a84d52c 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2InputValidator.cs @@ -14,6 +14,13 @@ public static class ExecApprovalV2InputValidator public static ExecApprovalV2ValidationOutcome Validate(NodeInvokeRequest request) { + if (request.Args.ValueKind == JsonValueKind.Object + && request.Args.TryGetProperty("command", out var commandElement) + && commandElement.ValueKind == JsonValueKind.String) + { + return Deny("command-array-required"); + } + var argv = TryParseArgv(request.Args, out bool malformedCommand); if (malformedCommand) return Deny("malformed-command"); @@ -49,7 +56,8 @@ public static ExecApprovalV2ValidationOutcome Validate(NodeInvokeRequest request return Deny("malformed-env"); dict[prop.Name] = prop.Value.GetString() ?? ""; } - env = dict; + if (dict.Count > 0) + return Deny("custom-env-not-supported"); } // timeoutMs / timeout โ€” positive integer; defaults to 30 000. @@ -73,7 +81,6 @@ public static ExecApprovalV2ValidationOutcome Validate(NodeInvokeRequest request return ExecApprovalV2ValidationOutcome.Ok(new ValidatedRunRequest( argv, - TryGetString(request.Args, "shell"), cwd, timeoutMs, env, @@ -91,39 +98,19 @@ private static ExecApprovalV2ValidationOutcome Deny(string reason) !args.TryGetProperty("command", out var cmdEl)) return null; - if (cmdEl.ValueKind == JsonValueKind.Array) + if (cmdEl.ValueKind != JsonValueKind.Array) { - var list = new List(); - foreach (var item in cmdEl.EnumerateArray()) - { - if (item.ValueKind != JsonValueKind.String) { malformed = true; return null; } - list.Add(item.GetString() ?? ""); - } - return list.Count > 0 ? [.. list] : null; + malformed = true; + return null; } - if (cmdEl.ValueKind == JsonValueKind.String) + var list = new List(); + foreach (var item in cmdEl.EnumerateArray()) { - var cmd = cmdEl.GetString(); - if (string.IsNullOrWhiteSpace(cmd)) return null; - - // Also merge a separate "args" array when command is a bare string. - // A non-array "args" value is a protocol violation. - if (args.TryGetProperty("args", out var argsEl)) - { - if (argsEl.ValueKind != JsonValueKind.Array) { malformed = true; return null; } - var list = new List { cmd }; - foreach (var item in argsEl.EnumerateArray()) - { - if (item.ValueKind != JsonValueKind.String) { malformed = true; return null; } - list.Add(item.GetString() ?? ""); - } - return [.. list]; - } - return [cmd]; + if (item.ValueKind != JsonValueKind.String) { malformed = true; return null; } + list.Add(item.GetString() ?? ""); } - - return null; + return list.Count > 0 ? [.. list] : null; } private static string? TryGetString(JsonElement args, string key) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs index e6688229b..f28b189de 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs @@ -42,8 +42,8 @@ public static ExecApprovalV2NormalizationOutcome Normalize(ValidatedRunRequest r // displayCommand is always derived from argv, never from rawCommand. var displayCommand = ShellQuoting.FormatExecCommand(argv); - // rawCommand is null in Windows v1 (system.run does not carry it). - // EvaluationRawCommand stays null โ€” correct and documented conservative output. + // rawCommand is display/consistency metadata, never executable input. + // Evaluation stays argv-only so approval and execution share one canonical command. string? evaluationRawCommand = null; // Singular resolution for state machine. diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NullHandler.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NullHandler.cs index 672bf70bc..ea655199a 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NullHandler.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NullHandler.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; namespace OpenClaw.Shared.ExecApprovals; @@ -13,4 +14,11 @@ public sealed class ExecApprovalV2NullHandler : IExecApprovalV2Handler public Task HandleAsync(OpenClaw.Shared.NodeInvokeRequest request, string correlationId) => Task.FromResult(ExecApprovalV2Result.Unavailable()); + + public ValueTask RevalidateAsync( + ExecApprovedExecution execution, + string correlationId, + CancellationToken cancellationToken = default) + => ValueTask.FromResult( + ExecApprovalRevalidationResult.NotCurrent("handler-not-available")); } diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs index 683a6a32c..d9d30c022 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs @@ -14,6 +14,12 @@ public sealed class ExecApprovalV2PromptRequest public string? Host { get; init; } public required ExecSecurity Security { get; init; } public required ExecAsk Ask { get; init; } + /// + /// Whether the prompt may offer "Allow Always". False when ask=always, or when the + /// command yields no reusable allowlist pattern (one-shot), mirroring macOS + /// resolveExecApprovalAllowedDecisions. Defaults false (fail-safe: allow-once/deny only). + /// + public bool AllowAlwaysAvailable { get; init; } public required string AgentId { get; init; } public string? ResolvedPath { get; init; } /// diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs index 4dc6ebf1c..0da469efb 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs @@ -22,6 +22,8 @@ public sealed record ExecApprovedExecution public string? Cwd { get; } public int TimeoutMs { get; } public IReadOnlyDictionary? Env { get; } + internal ExecApprovalsCurrency? PolicyCurrency { get; init; } + internal string? PolicyAgentId { get; init; } public ExecApprovedExecution( IReadOnlyList argv, diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs index 5d6da85a3..740013c72 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2UiPromptHandler.cs @@ -13,7 +13,8 @@ public sealed record ExecApprovalPromptView( string AgentLabel, string? CwdText, string? ExecutablePathText, - bool HasConfusableWarning); + bool HasConfusableWarning, + bool AllowAlwaysAvailable); /// /// Prompt-handler core behind the approval dialog. UI-free: the dispatcher hop and the @@ -48,7 +49,15 @@ public async Task PromptAsync( // Every field shown in the dialog originates from agent-controlled input, // including the agent label, so all of them go through the display sanitizer // before any UI binding to neutralize BiDi and control-character spoofing. - var commandText = ExecApprovalCommandDisplaySanitizer.Sanitize(request.DisplayCommand); + var commandStatus = ExecApprovalCommandDisplaySanitizer.SanitizeWithStatus(request.DisplayCommand); + // A command that cannot be shown in full (oversized, or truncated at the display cap) + // must not be approvable: the owner cannot review a hidden tail. Fail closed. + if (commandStatus.Truncated || commandStatus.Oversized) + { + _logger.Warn("[EXEC-APPROVALS] prompt: command exceeds display limit; denying (cannot review in full)"); + return ExecApprovalPromptOutcome.Deny; + } + var commandText = commandStatus.Text; var agentLabel = ExecApprovalCommandDisplaySanitizer.Sanitize(request.AgentId); var cwdText = request.Cwd is null ? null : ExecApprovalCommandDisplaySanitizer.Sanitize(request.Cwd); var pathText = request.ResolvedPath is null ? null : ExecApprovalCommandDisplaySanitizer.Sanitize(request.ResolvedPath); @@ -58,10 +67,11 @@ public async Task PromptAsync( // instead so the user knows the command may not read the way it looks. var hasConfusable = ExecApprovalConfusableDetector.HasMixedScriptConfusable(commandText) + || ExecApprovalConfusableDetector.HasMixedScriptConfusable(agentLabel) || ExecApprovalConfusableDetector.HasMixedScriptConfusable(cwdText) || ExecApprovalConfusableDetector.HasMixedScriptConfusable(pathText); - var view = new ExecApprovalPromptView(commandText, agentLabel, cwdText, pathText, hasConfusable); + var view = new ExecApprovalPromptView(commandText, agentLabel, cwdText, pathText, hasConfusable, request.AllowAlwaysAvailable); var tcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs index 2ffd011e1..db53ea3bd 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs @@ -75,6 +75,12 @@ public sealed class ExecApprovalsFile public Dictionary? Agents { get; set; } } +public sealed record ExecApprovalsSnapshot( + string Path, + bool Exists, + string Hash, + ExecApprovalsFile File); + internal sealed class ExecSecurityFallbackConverter : JsonConverter { public override ExecSecurity? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs index 21bd198aa..ace6b8b9d 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs @@ -18,6 +18,13 @@ public sealed class ExecApprovalsCoordinator : IExecApprovalV2Handler private readonly ICanPresentEvaluator _canPresent; private readonly IExecApprovalV2PromptHandler _prompt; private readonly IOpenClawLogger _logger; + private readonly TimeSpan _promptTimeout; + + // Bounded lifetime for an approval dialog: if the owner does not respond within this window + // the prompt is cancelled and resolves to Deny, so a request never hangs forever (the + // requester has its own independent timeout). Mirrors the spirit of the macOS approval + // timeout, shortened for the node's synchronous invoke path. + private static readonly TimeSpan DefaultPromptTimeout = TimeSpan.FromMinutes(5); // Serializes the prompt call + second-pass block. // Does NOT protect validate/normalize/buildContext โ€” those are stateless. @@ -27,12 +34,14 @@ public ExecApprovalsCoordinator( ExecApprovalsStore store, ICanPresentEvaluator canPresentEvaluator, IExecApprovalV2PromptHandler promptHandler, - IOpenClawLogger logger) + IOpenClawLogger logger, + TimeSpan? promptTimeout = null) { _store = store; _canPresent = canPresentEvaluator; _prompt = promptHandler; _logger = logger; + _promptTimeout = promptTimeout ?? DefaultPromptTimeout; } public async Task HandleAsync(NodeInvokeRequest request, string correlationId) @@ -58,23 +67,17 @@ public async Task HandleAsync(NodeInvokeRequest request, s // Step 3: buildContext var resolved = _store.ResolveReadOnly(identity.AgentId); - // Env injection guard โ€” preserves SystemCapability.HandleRunAsync:343-351 behavior. - // identity.Env is IReadOnlyDictionary; copy to Dictionary for Sanitize. - var envInput = identity.Env is null - ? null - : new Dictionary(identity.Env, StringComparer.OrdinalIgnoreCase); - var envResult = ExecEnvSanitizer.Sanitize(envInput); - - if (envResult.Blocked.Length > 0) - { - var blockedNames = (string[])envResult.Blocked.Clone(); - Array.Sort(blockedNames, StringComparer.OrdinalIgnoreCase); - _logger.Warn($"[EXEC-APPROVALS] [{correlationId}] env-blocked: [{string.Join(", ", blockedNames)}]"); - return LogAndReturn(ExecApprovalV2Result.ValidationFailed("env-blocked"), - correlationId, promptAttempted: false, fallbackUsed: false); - } - - var sanitizedEnv = envResult.Allowed as IReadOnlyDictionary; + // Snapshot the authorizing policy so a human-approved command can be re-checked + // against the live policy before execution (mirrors macOS + // policy-snapshot currency guard): if the owner tightens security, raises the + // ask mode, or revokes a relied-on allowlist entry while the prompt is open, the + // stale approval fails closed. + var policyCurrency = ExecApprovalsCurrency.Capture(resolved); + + // Non-empty custom environments are rejected during structural validation. + // Keep the execution payload environment-free until env is identity-bound and + // displayed to the approving operator. + IReadOnlyDictionary? sanitizedEnv = null; var needsAllowlistMatches = resolved.Defaults.Security == ExecSecurity.Allowlist || resolved.Defaults.AskFallback == ExecSecurity.Allowlist; IReadOnlyList matches = needsAllowlistMatches @@ -97,6 +100,17 @@ public async Task HandleAsync(NodeInvokeRequest request, s if (pass1 is ExecHostPolicyDecision.DenyOutcome denyPass1) return LogAndReturn(denyPass1.Error, correlationId, promptAttempted: false, fallbackUsed: false, canonical: context.DisplayCommand); + + // Security-audit-suppression changes must never be auto-allowed (even under + // security=full/ask=off or a satisfied allowlist): force an explicit decision. Read-only + // inspections are exempt. Mirrors macOS commandRequiresSecurityAuditSuppressionApproval. + var auditForcedApproval = + ExecApprovalAuditSuppressionGate.RequiresExtraApproval( + identity.Command, + context.DisplayCommand); + if (auditForcedApproval && pass1 is ExecHostPolicyDecision.AllowOutcome) + pass1 = ExecHostPolicyDecision.RequiresPrompt; + if (pass1 is ExecHostPolicyDecision.AllowOutcome) { // A stored executable-level rule must not authorize a command host whose @@ -113,7 +127,11 @@ public async Task HandleAsync(NodeInvokeRequest request, s // Pre-approved path (security=Full, ask=Off or allowlist satisfied): skip prompt. // Fail closed if the approved executable cannot be pinned to a resolved path. - var preApprovedExecution = BuildApprovedExecution(identity, sanitizedEnv); + var preApprovedExecution = BuildApprovedExecution( + identity, + sanitizedEnv, + policyCurrency, + resolved.AgentId); if (preApprovedExecution is null) return LogAndReturn(ExecApprovalV2Result.InternalError("unresolved-executable-on-allow"), correlationId, promptAttempted: false, fallbackUsed: false, canonical: context.DisplayCommand); @@ -145,9 +163,13 @@ public async Task HandleAsync(NodeInvokeRequest request, s ExecApprovalPromptOutcome promptResult; try { + // Bound the dialog's lifetime: on timeout the token cancels, the prompt + // handler tears the window down and resolves Deny, so an unanswered prompt + // never hangs the request forever. + using var promptCts = new CancellationTokenSource(_promptTimeout); promptResult = await _prompt.PromptAsync( BuildPromptRequest(context, identity, correlationId), - cancellationToken: default).ConfigureAwait(false); + promptCts.Token).ConfigureAwait(false); } catch { @@ -182,6 +204,13 @@ public async Task HandleAsync(NodeInvokeRequest request, s else { fallbackUsed = true; + // An audit-suppression change requires explicit human approval; with no UI + // available, deny rather than delegate to askFallback (which may be permissive). + if (auditForcedApproval) + return LogAndReturn( + ExecApprovalV2Result.UserDenied("audit-suppression-requires-approval"), + correlationId, promptAttempted, fallbackUsed: true, + canonical: context.DisplayCommand); followupDecision = FallbackDecision( context, resolved.Defaults.AskFallback, @@ -210,11 +239,27 @@ public async Task HandleAsync(NodeInvokeRequest request, s // Step 8: build payload before any store writes โ€” a fail-closed payload result // must not leave persistent allowlist state behind. - var execution = BuildApprovedExecution(identity, sanitizedEnv); + var execution = BuildApprovedExecution( + identity, + sanitizedEnv, + policyCurrency, + resolved.AgentId); if (execution is null) return LogAndReturn(ExecApprovalV2Result.InternalError("unresolved-executable-on-allow"), correlationId, promptAttempted, fallbackUsed, canonical: context.DisplayCommand); + // Step 8.5: policy-currency re-check. Both the prompt path (owner deciding) and the + // fallback path (which can queue behind another request's prompt on _promptLock) accrue + // a delay between the policy read and this point, so re-read and fail closed if the owner + // tightened the policy meanwhile. Runs before any persistence so a stale approval never + // writes an allowlist entry. Residual: actual process launch happens after HandleAsync + // returns (SystemCapability), so a change in that final window is not caught here; closing + // it fully requires revalidating the snapshot at the execution boundary. + if (!policyCurrency.IsStillCurrent(_store.ResolveReadOnly(identity.AgentId))) + return LogAndReturn( + ExecApprovalV2Result.ValidationFailed("policy-changed-before-execution"), + correlationId, promptAttempted, fallbackUsed, canonical: context.DisplayCommand); + var durableCommandHostAuthorization = persistAllowlistEntry || fallbackAllowWasMatchDependent; if (durableCommandHostAuthorization && IsIndirectCommandHost(identity)) @@ -242,6 +287,7 @@ public async Task HandleAsync(NodeInvokeRequest request, s // Step 10: return Allow return ExecApprovalV2Result.Allow(execution); } + catch (Exception ex) { // Outer safety net: any unhandled exception in buildContext, CanPresent, FallbackDecision, @@ -255,6 +301,37 @@ public async Task HandleAsync(NodeInvokeRequest request, s } } + public ValueTask RevalidateAsync( + ExecApprovedExecution execution, + string correlationId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (execution.PolicyCurrency is null) + { + return ValueTask.FromResult( + ExecApprovalRevalidationResult.NotCurrent("missing-policy-currency")); + } + + try + { + var fresh = _store.ResolveReadOnly(execution.PolicyAgentId); + return ValueTask.FromResult( + execution.PolicyCurrency.IsStillCurrent(fresh) + ? ExecApprovalRevalidationResult.Current + : ExecApprovalRevalidationResult.NotCurrent( + "policy-changed-before-execution")); + } + catch (Exception ex) + { + _logger.Error( + $"[EXEC-APPROVALS] [{correlationId}] execution-boundary revalidation failed", + ex); + return ValueTask.FromResult( + ExecApprovalRevalidationResult.NotCurrent("policy-revalidation-failed")); + } + } + // Builds the approved execution payload from the RESOLVED executable path, never // the raw argv[0]. The command must execute with the same canonical identity it // was evaluated under: a relative argv[0] in the payload would let Windows @@ -264,7 +341,9 @@ public async Task HandleAsync(NodeInvokeRequest request, s // rather than execute a command whose identity we cannot pin. internal static ExecApprovedExecution? BuildApprovedExecution( CanonicalCommandIdentity identity, - IReadOnlyDictionary? sanitizedEnv) + IReadOnlyDictionary? sanitizedEnv, + ExecApprovalsCurrency? policyCurrency = null, + string? policyAgentId = null) { var resolvedPath = identity.Resolution?.ResolvedPath; if (string.IsNullOrEmpty(resolvedPath)) @@ -296,7 +375,11 @@ public async Task HandleAsync(NodeInvokeRequest request, s for (var i = 1; i < effective.Count; i++) argv[i] = effective[i]; - return new ExecApprovedExecution(argv, identity.Cwd, identity.TimeoutMs, sanitizedEnv); + return new ExecApprovedExecution(argv, identity.Cwd, identity.TimeoutMs, sanitizedEnv) + { + PolicyCurrency = policyCurrency, + PolicyAgentId = policyAgentId, + }; } private static bool IsIndirectCommandHost(CanonicalCommandIdentity identity) @@ -372,8 +455,15 @@ private static ExecApprovalV2PromptRequest BuildPromptRequest( Cwd = identity.Cwd, Security = context.Security, Ask = context.Ask, + // Allow-always is offered only when a reusable allowlist pattern exists and the + // policy is not ask=always (which would re-add without a fresh decision). Mirrors + // macOS resolveExecApprovalAllowedDecisions; empty patterns == one-shot. + AllowAlwaysAvailable = + context.Ask != ExecAsk.Always + && context.AllowAlwaysPatterns.Count > 0 + && !IsIndirectCommandHost(identity), AgentId = context.AgentId ?? "main", - ResolvedPath = context.Resolution?.ResolvedPath, + ResolvedPath = ExecApprovalPathDisplay.ExpandShortPath(context.Resolution?.ResolvedPath), SessionKey = identity.SessionKey, CorrelationId = correlationId, // Host omitted (no gateway wiring yet) diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCurrency.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCurrency.cs new file mode 100644 index 000000000..7027373c8 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCurrency.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; + +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Snapshot of the effective exec-approval policy taken when a request is authorized, used +/// to detect a mid-approval policy change before execution. Mirrors the macOS +/// policy-snapshot currency guard: additive/looser changes stay current, +/// but tightening (more restrictive security, a higher ask mode) or revoking an allowlist +/// entry the approval relied on must fail closed. This closes the window between reading +/// the policy and executing a human-approved command, during which the node owner could +/// tighten the policy while the prompt is open. +/// +internal sealed class ExecApprovalsCurrency +{ + private readonly ExecSecurity _security; + private readonly ExecAsk _ask; + private readonly ExecSecurity _askFallback; + private readonly HashSet _allowlistPatterns; + + private ExecApprovalsCurrency( + ExecSecurity security, + ExecAsk ask, + ExecSecurity askFallback, + HashSet allowlistPatterns) + { + _security = security; + _ask = ask; + _askFallback = askFallback; + _allowlistPatterns = allowlistPatterns; + } + + public static ExecApprovalsCurrency Capture(ExecApprovalsResolved resolved) + => new( + resolved.Defaults.Security, + resolved.Defaults.Ask, + resolved.Defaults.AskFallback, + CollectPatterns(resolved)); + + /// + /// True when has not tightened relative to the snapshot. + /// Fails on: security made more restrictive (lower ), ask + /// raised (higher ), or any allowlist pattern the snapshot carried + /// now absent. Additive changes (new entries, looser policy) stay current. + /// + public bool IsStillCurrent(ExecApprovalsResolved fresh) + { + // ExecSecurity: Deny(0) < Allowlist(1) < Full(2). A lower value is more restrictive. + if (fresh.Defaults.Security < _security) + return false; + + // ExecAsk: Off(0) < OnMiss(1) < Always(2) < Deny(3). A higher value denies more. + if (fresh.Defaults.Ask > _ask) + return false; + + // AskFallback uses ExecSecurity ordering. A lower value is more restrictive. + if (fresh.Defaults.AskFallback < _askFallback) + return false; + + if (_allowlistPatterns.Count > 0) + { + var freshPatterns = CollectPatterns(fresh); + foreach (var pattern in _allowlistPatterns) + { + if (!freshPatterns.Contains(pattern)) + return false; + } + } + + return true; + } + + private static HashSet CollectPatterns(ExecApprovalsResolved resolved) + { + var patterns = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in resolved.Allowlist) + { + if (!string.IsNullOrWhiteSpace(entry.Pattern)) + patterns.Add(entry.Pattern!); + } + return patterns; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsPathGuard.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsPathGuard.cs new file mode 100644 index 000000000..584a82631 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsPathGuard.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Filesystem-integrity guards for the exec-approvals policy store, mirroring the macOS +/// store's symlink and hard-link protections on Windows: +/// +/// reparse-point rejection (symlink/junction) on the policy file and its immediate +/// parent directory, the Windows analogue of O_NOFOLLOW plus symlink-parent +/// rejection, bounded to the store's own file and data dir; +/// hard-link detection (nNumberOfLinks == 1), so a second path alias cannot be +/// used to observe or divert the policy file. +/// +/// Both checks fail closed: any inspection error is treated as "not trustworthy". +/// +internal static class ExecApprovalsPathGuard +{ + /// + /// True when neither nor its immediate parent directory (the + /// OpenClaw data dir) is a reparse point, so the store cannot be redirected by swapping the + /// file for a symlink or the data dir for a junction. The check is bounded to the store's + /// own file and directory: ancestors above the data dir are OS-controlled and may + /// legitimately be junctions on some Windows profiles. + /// + public static bool IsPathTrustworthy(string filePath) + { + try + { + var full = Path.GetFullPath(filePath); + + if (File.Exists(full) && IsReparsePoint(File.GetAttributes(full))) + return false; + + var parent = Directory.GetParent(full); + if (parent is not null && parent.Exists && IsReparsePoint(parent.Attributes)) + return false; + + return true; + } + catch + { + return false; + } + } + + /// + /// True when the file is referenced by exactly one directory entry (no hard-link alias). + /// Windows-only; on other platforms it returns true (the store runs on the Windows node, + /// and the reparse-point guard still applies everywhere). + /// + public static bool HasSingleHardLink(string filePath) + { + if (!OperatingSystem.IsWindows()) + return true; + + try + { + using var handle = CreateFileW( + filePath, + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + IntPtr.Zero); + + if (handle.IsInvalid) + return false; + + if (!GetFileInformationByHandle(handle, out var info)) + return false; + + return info.NumberOfLinks == 1; + } + catch + { + return false; + } + } + + private static bool IsReparsePoint(FileAttributes attributes) + => (attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + + private const uint FILE_READ_ATTRIBUTES = 0x0080; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint FILE_SHARE_DELETE = 0x00000004; + private const uint OPEN_EXISTING = 3; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFileW( + string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, + uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle hFile, out BY_HANDLE_FILE_INFORMATION lpFileInformation); + + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs index 6fadfd8b1..8920c70c4 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Text.Json; @@ -9,8 +11,8 @@ namespace OpenClaw.Shared.ExecApprovals; -// New store for exec-approvals.json. Separate from legacy ExecApprovalPolicy (exec-policy.json). -// Read path: ResolveReadOnly, LoadFile, EnsureFileAsync. Write path: AddAllowlistEntryAsync, RecordAllowlistUseAsync. +// Authoritative store for exec-approvals.json. +// Read path: ResolveReadOnly, LoadFile, EnsureFileAsync. Write path: ReplaceAsync, AddAllowlistEntryAsync, RecordAllowlistUseAsync. public sealed class ExecApprovalsStore { // KebabCaseLower covers all macOS enum values: deny, allowlist, full, off, on-miss, always, @@ -42,7 +44,10 @@ private enum LoadFileStatus Invalid, } - private readonly record struct LoadFileResult(LoadFileStatus Status, ExecApprovalsFile? File); + private readonly record struct LoadFileResult( + LoadFileStatus Status, + ExecApprovalsFile? File, + string? Hash); public ExecApprovalsStore(string dataPath, IOpenClawLogger logger) : this( @@ -64,10 +69,11 @@ internal ExecApprovalsStore( string? openClawHomeOverride = null, string? osHomeOverride = null) { - var stateDir = string.IsNullOrWhiteSpace(stateDirOverride) - ? dataPath - : ResolveStateDirPath(stateDirOverride, openClawHomeOverride, osHomeOverride); - _filePath = Path.Combine(stateDir, "exec-approvals.json"); + _filePath = ResolveFilePath( + dataPath, + stateDirOverride, + openClawHomeOverride, + osHomeOverride); var legacyFilePath = Path.Combine(dataPath, "exec-approvals.json"); _legacyFilePath = PathsEqual(_filePath, legacyFilePath) ? null : legacyFilePath; _logger = logger; @@ -78,13 +84,27 @@ internal ExecApprovalsStore( // No side effects; does not create the file. public ExecApprovalsResolved ResolveReadOnly(string? agentId) { - if (_legacyFilePath is not null && File.Exists(_legacyFilePath) && !File.Exists(_filePath)) - return UnmigratedLegacyFallback(agentId); + if (_legacyFilePath is not null) + { + var targetStatus = LoadFile().Status; + var legacyStatus = LoadFile(_legacyFilePath).Status; + if (targetStatus == LoadFileStatus.Missing + && legacyStatus != LoadFileStatus.Missing) + { + return UnmigratedLegacyFallback(agentId); + } + } var result = LoadFile(); - return result.Status != LoadFileStatus.Loaded || result.File is null - ? DefaultResolved(NormalizeAgentId(agentId)) - : ResolveFromFile(result.File, agentId); + return result.Status switch + { + LoadFileStatus.Loaded when result.File is not null => + ResolveFromFile(result.File, agentId), + LoadFileStatus.Missing => + DefaultResolved(NormalizeAgentId(agentId)), + _ => + FailClosedResolved(NormalizeAgentId(agentId)), + }; } // Adds a new allowlist entry for the agent. Best-effort: never throws. @@ -180,6 +200,78 @@ public async Task ResolveAsync(string? agentId) public void MigrateLegacyFileIfNeeded() => TryMigrateLegacyFile(); + public async Task GetSnapshotAsync() + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + if (TryMigrateLegacyFile() == LegacyMigrationStatus.Blocked) + throw new IOException("Unmigrated exec approvals file is unreadable."); + + var result = LoadFile(); + if (result.Status == LoadFileStatus.Invalid) + throw new IOException("Exec approvals file is malformed, unsupported, or untrusted."); + + if (result.Status == LoadFileStatus.Missing) + { + await SaveFileAsync(NewDefaultFile()).ConfigureAwait(false); + result = LoadFile(); + } + + if (result.Status != LoadFileStatus.Loaded || result.File is null) + throw new IOException("Exec approvals snapshot is unavailable."); + + return CreateSnapshot(result.File, exists: true, result.Hash!); + } + finally + { + _lock.Release(); + } + } + + public async Task ReplaceAsync( + string baseHash, + ExecApprovalsFile replacement, + Func? deltaValidator = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseHash); + ArgumentNullException.ThrowIfNull(replacement); + + await _lock.WaitAsync().ConfigureAwait(false); + try + { + if (TryMigrateLegacyFile() == LegacyMigrationStatus.Blocked) + throw new IOException("Unmigrated exec approvals file is unreadable."); + + var result = LoadFile(); + if (result.Status == LoadFileStatus.Invalid) + throw new IOException("Exec approvals file is malformed, unsupported, or untrusted."); + + var currentHash = result.Hash ?? ComputeMissingHash(); + if (!string.Equals(baseHash.Trim(), currentHash, StringComparison.Ordinal)) + return null; + + var currentFile = result.File ?? NewDefaultFile(); + var validationError = deltaValidator?.Invoke(currentFile, replacement); + if (!string.IsNullOrWhiteSpace(validationError)) + throw new ExecApprovalsValidationException(validationError); + + var currentSocket = result.File?.Socket; + var normalized = Normalize(replacement); + normalized.Version = 1; + normalized.Defaults = WithResolvedDefaults(normalized.Defaults); + normalized.Agents ??= []; + normalized.Socket = MergeSocket(normalized.Socket, currentSocket); + + var savedHash = await SaveFileAsync(normalized).ConfigureAwait(false); + return CreateSnapshot(normalized, exists: true, savedHash); + } + finally + { + _lock.Release(); + } + } + // โ”€โ”€ File I/O โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private LoadFileResult LoadFile() @@ -187,7 +279,40 @@ private LoadFileResult LoadFile() private LoadFileResult LoadFile(string filePath) { - if (!File.Exists(filePath)) return new LoadFileResult(LoadFileStatus.Missing, null); + try + { + var attributes = File.GetAttributes(filePath); + if ((attributes & FileAttributes.Directory) != 0) + { + _logger.Warn("[EXEC-APPROVALS] exec-approvals.json path is a directory; applying default-deny"); + return new LoadFileResult(LoadFileStatus.Invalid, null, null); + } + } + catch (FileNotFoundException) + { + return MissingOrUntrusted(filePath); + } + catch (DirectoryNotFoundException) + { + return MissingOrUntrusted(filePath); + } + catch (Exception ex) + { + _logger.Warn($"[EXEC-APPROVALS] Failed to inspect exec-approvals.json ({ex.Message}); applying default-deny"); + return new LoadFileResult(LoadFileStatus.Invalid, null, null); + } + + // Fail closed if a symlink/junction sits in the store path, or the file has a hard-link + // alias: either could load or shadow a policy the node owner never authorized. Mirrors + // macOS O_NOFOLLOW + nlink==1. Residual: this is a check-then-open (a racing swap between + // the check and the File.ReadAllText below is not caught); fully closing that requires + // opening once by handle with FILE_FLAG_OPEN_REPARSE_POINT and reading through it. + if (!ExecApprovalsPathGuard.IsPathTrustworthy(filePath) + || !ExecApprovalsPathGuard.HasSingleHardLink(filePath)) + { + _logger.Warn("[EXEC-APPROVALS] exec-approvals.json path is not trustworthy (reparse point or hard link); applying default-deny"); + return new LoadFileResult(LoadFileStatus.Invalid, null, null); + } try { var json = File.ReadAllText(filePath); @@ -195,28 +320,40 @@ private LoadFileResult LoadFile(string filePath) if (file is null) { _logger.Warn("[EXEC-APPROVALS] exec-approvals.json deserialized to null; applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null); + return new LoadFileResult(LoadFileStatus.Invalid, null, null); } if (file.Version != 1) { var version = file.Version?.ToString() ?? "missing"; _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json has unsupported version {version}; applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null); + return new LoadFileResult(LoadFileStatus.Invalid, null, null); } - return new LoadFileResult(LoadFileStatus.Loaded, Normalize(file)); + return new LoadFileResult( + LoadFileStatus.Loaded, + Normalize(file), + ComputeRawHash(json)); } catch (JsonException ex) { _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json is malformed ({ex.Message}); applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null); + return new LoadFileResult(LoadFileStatus.Invalid, null, null); } catch (Exception ex) { _logger.Warn($"[EXEC-APPROVALS] Failed to load exec-approvals.json ({ex.Message}); applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null); + return new LoadFileResult(LoadFileStatus.Invalid, null, null); } } + private LoadFileResult MissingOrUntrusted(string filePath) + { + if (ExecApprovalsPathGuard.IsPathTrustworthy(filePath)) + return new LoadFileResult(LoadFileStatus.Missing, null, ComputeMissingHash()); + + _logger.Warn("[EXEC-APPROVALS] missing exec-approvals.json path is not trustworthy; applying default-deny"); + return new LoadFileResult(LoadFileStatus.Invalid, null, null); + } + private async Task EnsureFileAsync() { if (TryMigrateLegacyFile() == LegacyMigrationStatus.Blocked) @@ -243,11 +380,11 @@ private async Task EnsureFileAsync() if (result.Status == LoadFileStatus.Invalid) { _logger.Warn($"[EXEC-APPROVALS] Preserving unreadable exec-approvals.json at {_filePath}; using empty in-memory store"); - return new ExecApprovalsFile { Version = 1, Agents = [] }; + return UnmigratedLegacyFallbackFile(); } // socket intentionally omitted in Windows v1. - var newFile = new ExecApprovalsFile { Version = 1, Agents = [] }; + var newFile = NewDefaultFile(); await SaveFileAsync(newFile).ConfigureAwait(false); _logger.Info($"[EXEC-APPROVALS] Created {_filePath}"); return newFile; @@ -255,10 +392,18 @@ private async Task EnsureFileAsync() private LegacyMigrationStatus TryMigrateLegacyFile() { - if (_legacyFilePath is null || !File.Exists(_legacyFilePath) || File.Exists(_filePath)) + if (_legacyFilePath is null) + return LegacyMigrationStatus.NotNeeded; + + var targetResult = LoadFile(); + if (targetResult.Status == LoadFileStatus.Loaded) return LegacyMigrationStatus.NotNeeded; + if (targetResult.Status == LoadFileStatus.Invalid) + return LegacyMigrationStatus.Blocked; var legacyResult = LoadFile(_legacyFilePath); + if (legacyResult.Status == LoadFileStatus.Missing) + return LegacyMigrationStatus.NotNeeded; if (legacyResult.Status != LoadFileStatus.Loaded || legacyResult.File is null) { _logger.Warn($"[EXEC-APPROVALS] Legacy approvals at {_legacyFilePath} could not be migrated; applying default-deny without creating {_filePath}"); @@ -312,6 +457,32 @@ private static string NextArchivePath(string legacyFilePath) private static bool PathsEqual(string left, string right) => string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); + public static string ResolveFilePath(string dataPath) + => ResolveFilePath( + dataPath, + Environment.GetEnvironmentVariable("OPENCLAW_STATE_DIR"), + Environment.GetEnvironmentVariable("OPENCLAW_HOME"), + FirstUsablePathValue( + Environment.GetEnvironmentVariable("HOME"), + Environment.GetEnvironmentVariable("USERPROFILE"), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))); + + public static bool IsValidAllowlistPattern(string? pattern) + => ExecAllowlistMatcher.IsValidPattern(pattern); + + internal static string ResolveFilePath( + string dataPath, + string? stateDirOverride, + string? openClawHomeOverride, + string? osHomeOverride) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataPath); + var stateDir = string.IsNullOrWhiteSpace(stateDirOverride) + ? dataPath + : ResolveStateDirPath(stateDirOverride, openClawHomeOverride, osHomeOverride); + return Path.Combine(stateDir, "exec-approvals.json"); + } + private static string ResolveStateDirPath( string stateDirOverride, string? openClawHomeOverride, @@ -365,11 +536,26 @@ private static ExecApprovalsFile UnmigratedLegacyFallbackFile() => private static ExecApprovalsResolved UnmigratedLegacyFallback(string? agentId) => ResolveFromFile(UnmigratedLegacyFallbackFile(), agentId); - private async Task SaveFileAsync(ExecApprovalsFile file) + private async Task SaveFileAsync(ExecApprovalsFile file) { var dir = Path.GetDirectoryName(_filePath)!; if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + // Refuse to write through a redirected path: a symlink/junction in the store path + // (O_NOFOLLOW analogue) or a hard-linked target (nlink==1 analogue) could divert the + // policy file to an attacker-observable or attacker-controlled location. + if (!ExecApprovalsPathGuard.IsPathTrustworthy(_filePath)) + { + _logger.Error($"[EXEC-APPROVALS] Refusing to write {_filePath}: reparse point in store path"); + throw new IOException("exec-approvals store path is not trustworthy (reparse point)"); + } + + if (File.Exists(_filePath) && !ExecApprovalsPathGuard.HasSingleHardLink(_filePath)) + { + _logger.Error($"[EXEC-APPROVALS] Refusing to write {_filePath}: target has multiple hard links"); + throw new IOException("exec-approvals store target has multiple hard links"); + } + var tmp = Path.Combine(dir, $".exec-approvals-{Guid.NewGuid():N}.tmp"); try { @@ -377,6 +563,7 @@ private async Task SaveFileAsync(ExecApprovalsFile file) await File.WriteAllTextAsync(tmp, json).ConfigureAwait(false); // Atomic replace on NTFS via MoveFileExW (MOVEFILE_REPLACE_EXISTING). File.Move(tmp, _filePath, overwrite: true); + return ComputeRawHash(json); } catch (Exception ex) { @@ -387,6 +574,58 @@ private async Task SaveFileAsync(ExecApprovalsFile file) } } + private ExecApprovalsSnapshot CreateSnapshot( + ExecApprovalsFile file, + bool exists, + string hash) + { + return new ExecApprovalsSnapshot( + _filePath, + exists, + hash, + new ExecApprovalsFile + { + Version = 1, + Socket = file.Socket, + Defaults = WithResolvedDefaults(file.Defaults), + Agents = file.Agents ?? [], + }); + } + + private static string ComputeRawHash(string raw) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant(); + + private static string ComputeMissingHash() => + $"missing:{ComputeRawHash(string.Empty)}"; + + private static ExecApprovalsFile NewDefaultFile() => + new() + { + Version = 1, + Defaults = WithResolvedDefaults(null), + Agents = [], + }; + + private static ExecApprovalsDefaults WithResolvedDefaults(ExecApprovalsDefaults? defaults) => + new() + { + Security = defaults?.Security ?? ExecSecurity.Allowlist, + Ask = defaults?.Ask ?? ExecAsk.OnMiss, + AskFallback = defaults?.AskFallback ?? ExecSecurity.Deny, + AutoAllowSkills = defaults?.AutoAllowSkills ?? false, + }; + + private static ExecApprovalsSocketConfig? MergeSocket( + ExecApprovalsSocketConfig? replacement, + ExecApprovalsSocketConfig? current) + { + var path = replacement?.Path ?? current?.Path; + var token = replacement?.Token ?? current?.Token; + return path is null && token is null + ? null + : new ExecApprovalsSocketConfig { Path = path, Token = token }; + } + // Best-effort mutate-and-save. Serialized by the store lock. // Never throws. Refuses to overwrite a malformed file. // Returns true if the file was mutated and saved; false if the mutate was a no-op, @@ -551,7 +790,7 @@ private static ExecApprovalsResolved ResolveFromFile(ExecApprovalsFile file, str var defaults = file.Defaults; // Cascade: agentEntry โ†’ wildcard โ†’ defaults โ†’ systemDefault - var security = agentEntry?.Security ?? wildcardEntry?.Security ?? defaults?.Security ?? ExecSecurity.Deny; + var security = agentEntry?.Security ?? wildcardEntry?.Security ?? defaults?.Security ?? ExecSecurity.Allowlist; var ask = agentEntry?.Ask ?? wildcardEntry?.Ask ?? defaults?.Ask ?? ExecAsk.OnMiss; var askFallback = agentEntry?.AskFallback ?? wildcardEntry?.AskFallback ?? defaults?.AskFallback ?? ExecSecurity.Deny; var autoAllowSkills = agentEntry?.AutoAllowSkills ?? wildcardEntry?.AutoAllowSkills ?? defaults?.AutoAllowSkills ?? false; @@ -582,7 +821,7 @@ private static ExecApprovalsResolved DefaultResolved(string agentId) => AgentId = agentId, Defaults = new ExecApprovalsResolvedDefaults { - Security = ExecSecurity.Deny, + Security = ExecSecurity.Allowlist, Ask = ExecAsk.OnMiss, AskFallback = ExecSecurity.Deny, AutoAllowSkills = false, @@ -590,7 +829,23 @@ private static ExecApprovalsResolved DefaultResolved(string agentId) => Allowlist = [], }; + private static ExecApprovalsResolved FailClosedResolved(string agentId) => + new() + { + AgentId = agentId, + Defaults = new ExecApprovalsResolvedDefaults + { + Security = ExecSecurity.Deny, + Ask = ExecAsk.Always, + AskFallback = ExecSecurity.Deny, + AutoAllowSkills = false, + }, + Allowlist = [], + }; + // null/empty agentId โ†’ "main". Mirrors macOS. Evaluator does not need to know this. private static string NormalizeAgentId(string? agentId) => string.IsNullOrWhiteSpace(agentId) ? "main" : agentId; } + +internal sealed class ExecApprovalsValidationException(string message) : Exception(message); diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs b/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs index 278f27551..c00af0a5e 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs @@ -242,11 +242,25 @@ private static void CollectPatterns( var wrapper = ExecShellWrapperNormalizer.Extract(command); if (wrapper.IsWrapper && wrapper.InlineCommand is not null) { + // A runtime shell payload (variable, subexpression, backtick, script block, + // encoded command, Invoke-Expression) cannot be pinned to a stable reusable rule, + // so it is one-shot: surface no allow-always pattern. Mirrors the macOS one-shot + // classification ($VAR/backtick/$(...)), extended for PowerShell forms. + if (IsOneShotShellPayload(wrapper.InlineCommand)) + return; + var segments = SplitShellCommandChain(wrapper.InlineCommand); if (segments is null) return; + // A segment invoking PowerShell with an encoded command (-EncodedCommand or any + // unambiguous alias: -e, -ec, -enc, ...) carries an opaque payload โ†’ one-shot. + foreach (var seg in segments) + { + var t = ExecCommandToken.ParseFirstToken(seg); + if (t is not null && SegmentUsesEncodedCommand(seg, t)) + return; + } foreach (var seg in segments) { - // allowAlwaysPatterns does NOT fail-closed on -EncodedCommand: it's UX only. var token = ExecCommandToken.ParseFirstToken(seg); if (token is null) continue; var res = ResolveExecutable(token, cwd, env); @@ -260,6 +274,9 @@ private static void CollectPatterns( // For direct exec, unwrap env including with-modifier cases for pattern discovery. var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command); if (effective.Count == 0) return; + // A direct PowerShell invocation carrying an encoded or dynamic payload is one-shot: + // it must not surface an allow-always pattern (which the command-host guard would reject). + if (IsDirectPowerShellOneShot(effective)) return; var rawToken = effective[0].Trim(); if (rawToken.Length == 0) return; var resolution = ResolveExecutable(rawToken, cwd, env); @@ -268,6 +285,36 @@ private static void CollectPatterns( if (seen.Add(pat)) patterns.Add(pat); } + // โ”€โ”€ one-shot payload detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // Runtime shell payloads cannot be pinned to a stable allowlist rule, so a command that + // contains one is one-shot and must not offer allow-always. Flags variables, subexpressions, + // braced expansions, backticks, script blocks, encoded commands, and Invoke-Expression across + // PowerShell and POSIX shells. Mirrors the macOS one-shot classification ($VAR/backtick/$(...)). + private static readonly System.Text.RegularExpressions.Regex OneShotPayloadRe = + new(@"\$\(|\$\{|\$\w|`|&\s*\{|-enc(?:odedcommand)?\b|\b(?:iex|invoke-expression)\b", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + private static bool IsOneShotShellPayload(string inlineCommand) + => !string.IsNullOrEmpty(inlineCommand) && OneShotPayloadRe.IsMatch(inlineCommand); + + // A direct (non-shell-wrapped) PowerShell invocation is one-shot when it carries an encoded + // command (any -EncodedCommand alias) or a dynamic payload (variable/subexpression/etc.). + private static bool IsDirectPowerShellOneShot(IReadOnlyList command) + { + if (command.Count == 0) return false; + var basename = ExecCommandToken.NormalizedBasename(command[0]); + if (basename is not ("powershell" or "pwsh")) return false; + + for (var i = 1; i < command.Count; i++) + { + if (IsEncodedCommandFlag(command[i])) + return true; + } + return OneShotPayloadRe.IsMatch(string.Join(' ', command)); + } + // โ”€โ”€ -EncodedCommand detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Research doc 04 S1: if a chain segment invokes PowerShell with -EncodedCommand (or any diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs b/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs index 119faacdf..10e9d3395 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs @@ -40,10 +40,46 @@ internal static bool IsEnv(string token) => "sh", "bash", "zsh", "dash", "ash", "ksh", "fish", "cmd", "powershell", "pwsh", "wsl", "cscript", "wscript", + "py", "pyw", "python", "pythonw", "pypy", + "node", "nodejs", "deno", "bun", "qjs", + "ruby", "jruby", "perl", "php", "lua", "luajit", + "java", "javaw", "jshell", "dotnet", "csi", "fsi", "fsharpi", + "r", "rscript", "tclsh", "wish", "groovy", }; - internal static bool IsIndirectCommandHost(string token) => - s_indirectCommandHosts.Contains(NormalizedBasename(token)); + internal static bool IsIndirectCommandHost(string token) + { + var basename = NormalizedBasename(token); + return s_indirectCommandHosts.Contains(basename) + || IsVersionedInterpreter(basename, "python") + || IsVersionedInterpreter(basename, "pythonw") + || IsVersionedInterpreter(basename, "pypy"); + } + + private static bool IsVersionedInterpreter(string basename, string prefix) + { + if (!basename.StartsWith(prefix, StringComparison.Ordinal) + || basename.Length == prefix.Length) + { + return false; + } + + var suffix = basename.AsSpan(prefix.Length); + var sawDigit = false; + foreach (var ch in suffix) + { + if (char.IsDigit(ch)) + { + sawDigit = true; + continue; + } + + if (ch != '.') + return false; + } + + return sawDigit; + } // Extracts the first shell-tokenized word from a command pattern. Quoted paths // remain one token, and a suffix after the closing quote is preserved so diff --git a/src/OpenClaw.Shared/ExecApprovals/IExecApprovalV2Handler.cs b/src/OpenClaw.Shared/ExecApprovals/IExecApprovalV2Handler.cs index 9da995de9..1be425c80 100644 --- a/src/OpenClaw.Shared/ExecApprovals/IExecApprovalV2Handler.cs +++ b/src/OpenClaw.Shared/ExecApprovals/IExecApprovalV2Handler.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; namespace OpenClaw.Shared.ExecApprovals; @@ -11,4 +12,23 @@ public interface IExecApprovalV2Handler { /// Short identifier propagated through logging for this request. Task HandleAsync(OpenClaw.Shared.NodeInvokeRequest request, string correlationId); + + /// + /// Revalidates the authorizing policy immediately before process launch. The default + /// fails closed so a handler cannot authorize execution without implementing currency. + /// + ValueTask RevalidateAsync( + ExecApprovedExecution execution, + string correlationId, + CancellationToken cancellationToken = default) + => ValueTask.FromResult( + ExecApprovalRevalidationResult.NotCurrent("policy-revalidation-unavailable")); +} + +public readonly record struct ExecApprovalRevalidationResult(bool IsCurrent, string Reason) +{ + public static ExecApprovalRevalidationResult Current { get; } = new(true, "current"); + + public static ExecApprovalRevalidationResult NotCurrent(string reason) + => new(false, reason); } diff --git a/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs b/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs index d93c90815..9543725c2 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ValidatedRunRequest.cs @@ -9,7 +9,6 @@ namespace OpenClaw.Shared.ExecApprovals; public sealed class ValidatedRunRequest { public string[] Argv { get; } - public string? Shell { get; } public string? Cwd { get; } public int TimeoutMs { get; } public IReadOnlyDictionary? Env { get; } @@ -18,7 +17,6 @@ public sealed class ValidatedRunRequest internal ValidatedRunRequest( string[] argv, - string? shell, string? cwd, int timeoutMs, IReadOnlyDictionary? env, @@ -26,7 +24,6 @@ internal ValidatedRunRequest( string? sessionKey) { Argv = argv; - Shell = shell; Cwd = cwd; TimeoutMs = timeoutMs; Env = env; diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index a197c4d83..81533d080 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -286,15 +286,15 @@ private object HandleToolsList() ["system.notify"] = "Show a Windows toast notification on the node. Args: title (string, default 'OpenClaw'), body (string), subtitle (string), sound (bool, default true). Returns { sent: true }.", ["system.run"] = - "Execute a shell command on the Windows node host. Args: command (string or string[] argv, required), args (string[]), shell (string), cwd (string), timeoutMs (int, default 30000), env (object). Subject to the local exec approval policy. Returns { stdout, stderr, exitCode, timedOut, success, durationMs }.", + "Execute canonical argv on the Windows node host. Args: command (string[] argv, required), rawCommand (string, optional display metadata), cwd (string), timeoutMs (int, default 30000). Non-empty custom env is not supported. Shell commands must name their wrapper explicitly, for example [\"cmd.exe\",\"/d\",\"/s\",\"/c\",\"echo hello\"]. Subject to the local exec approval policy. Returns { stdout, stderr, exitCode, timedOut, success, durationMs }.", ["system.run.prepare"] = "Pre-flight a system.run invocation: returns the parsed execution plan (argv, cwd, rawCommand, agentId, sessionKey) without running anything. The gateway uses this to build its approval context before the actual run.", ["system.which"] = "Resolve executable names to absolute paths by searching PATH (PATHEXT-aware on Windows). Args: bins (string[], required). Returns { bins: { name: resolvedPath, ... } } including only names that were found.", ["system.execApprovals.get"] = - "Return the current exec approval policy: { enabled, defaultAction ('allow'|'deny'|'prompt'), rules: [{ pattern, action, shells, description, enabled }, ...] }.", + "Return the V2 exec approvals snapshot: { path, exists, hash, baseHash, file: { version, defaults: { security, ask, askFallback, autoAllowSkills }, agents: { agentId: { security, ask, askFallback, autoAllowSkills, allowlist: [{ id, pattern, lastUsedAt?, lastResolvedPath? }] } } } }. Socket credentials are redacted.", ["system.execApprovals.set"] = - "Replace the exec approval policy. Args: rules (array of { pattern, action, shells?, description?, enabled? }), defaultAction (string, optional). Persisted to disk; used by future system.run calls.", + "Replace the V2 exec approvals file using compare-and-swap. Args: baseHash (required hash from system.execApprovals.get), file (required full { version, defaults, agents } object). Remote updates may preserve or remove existing allowlist grants but cannot add or change grants or set full access. Returns the updated { path, exists, hash, baseHash, file } snapshot.", // canvas.* โ€” agent-controlled WebView2 panel for HTML/CSS/JS, A2UI, and small interactive UI surfaces. ["canvas.present"] = diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 3b709013a..a82c160f4 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -81,13 +81,8 @@ public string ResolveEffectiveShell(string? requestedShell) } /// - /// A direct-argv request can only be honored by the host runner: the sandbox - /// protocol carries the legacy command/shell/args fields and cannot transport - /// an argv faithfully yet. This reports which transport a request submitted - /// now would reach, so callers that must execute an approved argv verbatim - /// can fail closed up front instead of after approval. It never disables or - /// bypasses the sandbox: when the sandbox would run the request, the answer - /// is simply "not supported". + /// Every active route preserves direct argv: host runners use ArgumentList, + /// and MXC uses a CommandLineToArgvW-reversible process command line. /// public bool CanExecuteDirectArgv() { @@ -95,20 +90,20 @@ public bool CanExecuteDirectArgv() if (!settings.SystemRunSandboxEnabled) return true; - // Sandbox enabled but unavailable: the compatibility host fallback honors - // Argv, and the strict-blocking variant rejects every request with its own - // explicit denial, so neither case needs the up-front argv gate. if (!_isSandboxAvailable()) return true; - return false; + return true; } public async Task RunAsync(CommandRequest request, CancellationToken ct = default) { var settings = _settingsProvider(); - var effectiveShell = ResolveEffectiveShell(request.Shell); - if (!TryValidateApprovedEffectiveShell(request, effectiveShell, out var approvalDeny)) + var effectiveShell = request.Argv is null + ? ResolveEffectiveShell(request.Shell) + : null; + if (effectiveShell is not null + && !TryValidateApprovedEffectiveShell(request, effectiveShell, out var approvalDeny)) return approvalDeny!; if (!settings.SystemRunSandboxEnabled) @@ -143,28 +138,6 @@ public async Task RunAsync(CommandRequest request, CancellationTo ct); } - // A direct-argv request reaching the sandbox cannot be honored: the sandbox - // protocol only carries the legacy command/shell/args fields, so serializing - // would silently run something other than the approved argv. Fail closed until - // the sandbox transport carries argv faithfully. The host-fallback branches - // above keep working because the host runner does honor Argv. - if (request.Argv is not null) - { - _logger.Warn("[mxc] system.run BLOCKED: direct-argv request reached the sandbox, " + - "which has no argv transport yet. Failing closed rather than running the legacy fields."); - return new CommandResult - { - Stdout = string.Empty, - Stderr = "Sandboxed system.run cannot execute a direct-argv command yet.", - ExitCode = -1, - TimedOut = false, - DurationMs = 0, - ExecutionMode = NodeToolExecutionMode.Sandbox, - ErrorCategory = NodeToolErrorCategory.SandboxDenied, - SandboxDenialReason = NodeToolSandboxDenialReason.DirectArgvUnsupported, - }; - } - var settingsDirectoryPath = _settingsDirectoryPathProvider(); var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath); var argsJson = SerializeArgs(request, effectiveShell); @@ -219,8 +192,12 @@ public async Task RunAsync(CommandRequest request, CancellationTo _logger.Warn( $"[mxc] system.run UNCONTAINED: sandbox became unavailable at runtime ({ex.Message}); " + "routing through host runner for compatibility."); - if (!TryResolveApprovedHostFallbackShell(request, effectiveShell, out var hostShell, out var deny)) + string? hostShell = null; + if (request.Argv is null + && !TryResolveApprovedHostFallbackShell(request, effectiveShell!, out hostShell, out var deny)) + { return deny!; + } return await RunHostFallbackAsync( request, @@ -296,7 +273,7 @@ private CommandResult DenySandboxUnavailable(string stderr, string logMessage) private async Task RunHostFallbackAsync( CommandRequest request, - string effectiveShell, + string? effectiveShell, NodeToolExecutionMode executionMode, CancellationToken ct) { @@ -444,13 +421,14 @@ private static NodeToolErrorCategory ClassifyProcessResult(CommandResult result) : NodeToolErrorCategory.CommandFailed; } - private static JsonElement SerializeArgs(CommandRequest request, string effectiveShell) + private static JsonElement SerializeArgs(CommandRequest request, string? effectiveShell) { var payload = new { command = request.Command, shell = effectiveShell, args = request.Args ?? Array.Empty(), + argv = request.Argv, cwd = request.Cwd, env = request.Env, timeoutMs = request.TimeoutMs, @@ -463,7 +441,7 @@ private static JsonElement SerializeArgs(CommandRequest request, string effectiv private void LogSandboxRequest( SandboxExecutionRequest sandboxRequest, CommandRequest commandRequest, - string effectiveShell, + string? effectiveShell, SettingsData settings, string settingsDirectoryPath, SandboxPolicy policy) @@ -479,7 +457,7 @@ private void LogSandboxRequest( $"downloads={settings.SandboxDownloadsAccess?.ToString() ?? ""},desktop={settings.SandboxDesktopAccess?.ToString() ?? ""}," + $"customFolderCount={settings.SandboxCustomFolders?.Count ?? 0},timeoutMs={settings.SandboxTimeoutMs},maxOutputBytes={settings.SandboxMaxOutputBytes}," + $"settingsDirectoryPath={(string.IsNullOrWhiteSpace(settingsDirectoryPath) ? "" : "")}}}; " + - $"shell={effectiveShell}; requestedShell={(string.IsNullOrWhiteSpace(commandRequest.Shell) ? "" : "")}; " + + $"shell={effectiveShell ?? ""}; requestedShell={(string.IsNullOrWhiteSpace(commandRequest.Shell) ? "" : "")}; " + $"commandLength={commandRequest.Command?.Length ?? 0}; " + $"cwd={(string.IsNullOrEmpty(commandRequest.Cwd) ? "" : "")}; " + $"envKeys=[{string.Join(",", envKeys)}]; " + diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs index 656fe136e..ce37904b0 100644 --- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -27,8 +27,9 @@ namespace OpenClaw.Shared.Mxc; /// directory, and adds an explicit request cwd as readonly when not already /// covered by an allow grant. AppContainer does NOT auto-grant cwd. /// Defensive re-filter of allow lists against the deny list. -/// Shell command-line construction (cmd /S /C, powershell -/// -EncodedCommand). +/// Command-line construction: direct argv uses Win32-reversible escaping; +/// legacy commands use cmd /S /C or powershell +/// -EncodedCommand. /// /// Env scrubbing happens upstream in SystemCapability.HandleRunAsync /// via ExecEnvSanitizer.Sanitize; this class rejects explicit env until @@ -71,11 +72,15 @@ internal static MxcConfig Build( var policy = request.Policy; var workingDirectory = string.IsNullOrWhiteSpace(request.Cwd) ? scratchDir : request.Cwd; var args = ParseSystemRunArgs(request.Args); - var shell = NormalizeSupportedShell(args.Shell); - if (IsPowerShellFamilyShell(shell) && policy?.Ui?.AllowWindows != true) + string? shell = null; + if (args.DirectArgv is null) { - throw new NotSupportedException( - "PowerShell-family shells require UI access with the Windows MXC 0.7 processcontainer backend."); + shell = NormalizeSupportedShell(args.Shell); + if (IsPowerShellFamilyShell(shell) && policy?.Ui?.AllowWindows != true) + { + throw new NotSupportedException( + "PowerShell-family shells require UI access with the Windows MXC 0.7 processcontainer backend."); + } } if (request.Env is { Count: > 0 }) @@ -97,9 +102,14 @@ internal static MxcConfig Build( roFromPolicy.Add(dir); } - // commandLine โ€” shell-quoted, with PATH/TEMP/TMP/TMPDIR bootstrapped - // inside the shell because MXC 0.7 rejects non-empty process.env. - var commandLine = ShellCommandLine.Build(shell, args.Command, args.Argv, scratchDir, pathDirs); + // Normal direct argv is passed straight to CreateProcessW with reversible + // Win32 escaping. cmd.exe /C is special: cmd parses its raw command line + // instead of CommandLineToArgvW, so the canonical gateway wrapper must use + // the cmd-aware serializer. It also carries the PATH/temp bootstrap because + // MXC 0.7 rejects non-empty process.env. + var commandLine = args.DirectArgv is not null + ? BuildDirectArgvCommandLine(args.DirectArgv, scratchDir, pathDirs) + : ShellCommandLine.Build(shell!, args.Command, args.Arguments, scratchDir, pathDirs); var allowWindows = policy?.Ui?.AllowWindows == true; // readwrite = UI grants + scratch dir. @@ -455,6 +465,63 @@ private static string NormalizePath(string path) _ => "none", }; + private static string BuildDirectArgvCommandLine( + IReadOnlyList argv, + string scratchDir, + IReadOnlyList pathDirs) + { + if (!IsCmdExecutable(argv.Count > 0 ? argv[0] : null)) + return DirectArgvCommandLine.Build(argv); + + if (!SelectsCmdCommandMode(argv)) + return DirectArgvCommandLine.Build(argv); + + if (argv.Count != 5 + || !string.Equals(argv[1], "/d", StringComparison.OrdinalIgnoreCase) + || !string.Equals(argv[2], "/s", StringComparison.OrdinalIgnoreCase) + || !string.Equals(argv[3], "/c", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException( + "Direct cmd.exe command wrappers must use canonical argv: cmd.exe /d /s /c ."); + } + + return ShellCommandLine.BuildCanonicalCmdWrapper( + argv[0], + argv[4], + scratchDir, + pathDirs); + } + + private static bool SelectsCmdCommandMode(IReadOnlyList argv) + { + for (var i = 1; i < argv.Count; i++) + { + var argument = argv[i].Trim(); + if (!argument.StartsWith("/", StringComparison.Ordinal)) + continue; + + if (argument.StartsWith("/c", StringComparison.OrdinalIgnoreCase) + || argument.StartsWith("/k", StringComparison.OrdinalIgnoreCase) + || argument.Contains("/c", StringComparison.OrdinalIgnoreCase) + || argument.Contains("/k", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool IsCmdExecutable(string? executable) + { + if (string.IsNullOrWhiteSpace(executable)) + return false; + + var fileName = Path.GetFileName(executable.Trim()); + return string.Equals(fileName, "cmd", StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, "cmd.exe", StringComparison.OrdinalIgnoreCase); + } + /// /// Capability args envelope for system.run. Other capability shapes can add /// their own parser here as they're rehosted. @@ -462,21 +529,36 @@ private static string NormalizePath(string path) private static SystemRunArgs ParseSystemRunArgs(System.Text.Json.JsonElement args) { if (args.ValueKind != System.Text.Json.JsonValueKind.Object) - return new SystemRunArgs("", DefaultShell, Array.Empty()); + return new SystemRunArgs("", DefaultShell, Array.Empty(), null); string command = args.TryGetProperty("command", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.String ? (c.GetString() ?? "") : ""; string shell = args.TryGetProperty("shell", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.String ? (s.GetString() ?? DefaultShell) : DefaultShell; - string[] argv = Array.Empty(); + string[] arguments = Array.Empty(); if (args.TryGetProperty("args", out var a) && a.ValueKind == System.Text.Json.JsonValueKind.Array) { - argv = a.EnumerateArray() + arguments = a.EnumerateArray() .Where(e => e.ValueKind == System.Text.Json.JsonValueKind.String) .Select(e => e.GetString() ?? "") .ToArray(); } - return new SystemRunArgs(command, shell, argv); + + string[]? directArgv = null; + if (args.TryGetProperty("argv", out var direct) + && direct.ValueKind != System.Text.Json.JsonValueKind.Null) + { + if (direct.ValueKind != System.Text.Json.JsonValueKind.Array) + throw new NotSupportedException("Direct argv must be an array of strings."); + + directArgv = direct.EnumerateArray() + .Select(e => e.ValueKind == System.Text.Json.JsonValueKind.String + ? e.GetString()! + : throw new NotSupportedException("Direct argv entries must be strings.")) + .ToArray(); + } + + return new SystemRunArgs(command, shell, arguments, directArgv); } private static bool IsPowerShellFamilyShell(string shell) @@ -498,7 +580,11 @@ private static string NormalizeSupportedShell(string shell) }; } - private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList Argv); + private sealed record SystemRunArgs( + string Command, + string Shell, + IReadOnlyList Arguments, + IReadOnlyList? DirectArgv); } internal sealed record MxcConfigBuildContext( @@ -509,6 +595,72 @@ internal sealed record MxcConfigBuildContext( public static MxcConfigBuildContext Default { get; } = new(); } +/// +/// Builds a Windows process command line whose arguments round-trip through +/// CommandLineToArgvW without a shell parsing the approved argv. +/// +internal static class DirectArgvCommandLine +{ + private static readonly char[] QuoteRequiredChars = [' ', '\t', '\n', '\v', '"']; + + public static string Build(IReadOnlyList argv) + { + ArgumentNullException.ThrowIfNull(argv); + if (argv.Count == 0) + throw new ArgumentException("Direct argv requires an executable.", nameof(argv)); + + var commandLine = new StringBuilder(); + for (var i = 0; i < argv.Count; i++) + { + var argument = argv[i] + ?? throw new ArgumentException("Direct argv entries cannot be null.", nameof(argv)); + if (argument.Contains('\0')) + throw new ArgumentException("Direct argv entries cannot contain NUL.", nameof(argv)); + + if (i > 0) + commandLine.Append(' '); + AppendArgument(commandLine, argument); + } + + return commandLine.ToString(); + } + + private static void AppendArgument(StringBuilder commandLine, string argument) + { + if (argument.Length > 0 && argument.IndexOfAny(QuoteRequiredChars) < 0) + { + commandLine.Append(argument); + return; + } + + commandLine.Append('"'); + var index = 0; + while (index < argument.Length) + { + var backslashStart = index; + while (index < argument.Length && argument[index] == '\\') + index++; + + var backslashCount = index - backslashStart; + if (index == argument.Length) + { + commandLine.Append('\\', backslashCount * 2); + break; + } + + if (argument[index] == '"') + commandLine.Append('\\', backslashCount * 2 + 1); + else + commandLine.Append('\\', backslashCount); + + commandLine.Append(argument[index]); + index++; + } + + commandLine.Append('"'); + } +} + /// /// Shell command-line construction for the sandboxed payload โ€” wraps the /// agent's command in cmd.exe /S /C "..." or @@ -543,6 +695,23 @@ public static string Build( }; } + public static string BuildCanonicalCmdWrapper( + string executable, + string command, + string scratchDir, + IReadOnlyList pathDirs) + { + if (string.IsNullOrWhiteSpace(executable)) + throw new ArgumentException("Canonical cmd wrapper requires an executable.", nameof(executable)); + + return BuildCmd( + command, + Array.Empty(), + scratchDir, + LimitPathDirsForCommandLine(pathDirs), + executable); + } + private static IReadOnlyList LimitPathDirsForCommandLine(IReadOnlyList pathDirs) { if (pathDirs.Count == 0) @@ -567,11 +736,16 @@ private static string BuildCmd( string command, IReadOnlyList argv, string scratchDir, - IReadOnlyList pathDirs) + IReadOnlyList pathDirs, + string? executable = null) { ThrowIfCmdContainsLineBreak(command, nameof(command)); foreach (var arg in argv) ThrowIfCmdContainsLineBreak(arg, "argv"); + var containsDelayedExpansionSyntax = + command.Contains('!') || argv.Any(arg => arg.Contains('!')); + var bootstrapContainsDelayedExpansionSyntax = + scratchDir.Contains('!') || pathDirs.Any(path => path.Contains('!')); // cmd /S /C " [args]" โ€” /S strips outer quotes so cmd treats // everything after /C as the command line verbatim. If the payload @@ -585,11 +759,17 @@ private static string BuildCmd( rewrittenArgv.Add(RewriteCmdBootstrapEnvRefs(arg, pathDirs, out var argNeedsDelayedExpansion)); needsDelayedExpansion |= argNeedsDelayedExpansion; } + if (needsDelayedExpansion + && (containsDelayedExpansionSyntax || bootstrapContainsDelayedExpansionSyntax)) + { + throw new NotSupportedException( + "cmd payloads cannot combine MXC bootstrap environment references with '!' delayed-expansion syntax in the payload or bootstrap paths."); + } - var sb = new StringBuilder(QuoteProcessPath(ResolveCmdExe())); + var sb = new StringBuilder(QuoteProcessPath(executable ?? ResolveCmdExe())); if (needsDelayedExpansion) sb.Append(" /V:ON"); - sb.Append(" /S /C \""); + sb.Append(" /D /S /C \""); AppendCmdEnvironmentBootstrap(sb, scratchDir, pathDirs); sb.Append(rewrittenCommand); foreach (var a in rewrittenArgv) diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index cc5d4a997..baf59bfb4 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -174,29 +174,6 @@ public record class SettingsData /// public bool SystemRunAllowOutbound { get; set; } = false; - /// - /// Route system.run through the new exec approvals pipeline instead of the - /// legacy policy. Default false. When enabled, any failure inside - /// the new pipeline produces a typed deny โ€” it never falls back silently - /// to the legacy path. No Settings UI yet; enabled by editing settings.json. - /// - /// Interaction with (intentional, not - /// a misconfiguration): the pipeline executes approved commands as a direct - /// argv, and the sandbox transport cannot carry that form yet, so - /// system.run behaves as follows while this setting is on: - /// - /// sandbox enabled and available โ€” every request returns a typed - /// unavailable error before evaluation; the sandbox is never bypassed. - /// sandbox enabled, unavailable, host fallback allowed โ€” the approved - /// argv executes uncontained on the host. - /// sandbox enabled, unavailable, strict fallback blocking โ€” the - /// sandbox settings deny execution, as they do for the legacy path. - /// sandbox disabled โ€” the approved argv executes on the host. - /// - /// - /// - public bool ExecApprovalsNewPathEnabled { get; set; } = false; - /// /// Clipboard access policy inside the sandbox. Default None โ€” the /// sandboxed payload cannot see or change the user's clipboard. diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 2f7f1707b..e36f63e2b 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -6,6 +6,7 @@ using Microsoft.UI.Xaml.Controls.Primitives; using OpenClaw.Shared; using OpenClaw.Shared.Capabilities; +using OpenClaw.Shared.ExecApprovals; using OpenClaw.Shared.Sessions; using OpenClaw.Shared.Mxc; using OpenClaw.Shared.Telemetry; @@ -104,6 +105,10 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands internal string? NodeFullDeviceId => _nodeService?.FullDeviceId; /// Live node service instance used by settings surfaces for MCP status. internal NodeService? ActiveNodeService => _nodeService; + internal ExecApprovalsStore ExecApprovalsStore => + _execApprovalsStore ??= new ExecApprovalsStore( + AppIdentity.ResolveRoamingDataDirectory(), + new AppLogger()); /// /// Session key that the chat surface should select on its next mount. @@ -254,6 +259,7 @@ public IntPtr GetHubWindowHandle() // Node service (optional, enabled in settings) private NodeService? _nodeService; + private ExecApprovalsStore? _execApprovalsStore; // Keep-alive window to anchor WinUI runtime (prevents GC/threading issues) private Window? _keepAliveWindow; private SetupWindow? _setupWindow; @@ -2089,16 +2095,14 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna new AppLogger(), _dispatcherQueue, DataPath, - rootProvider: () => _keepAliveWindow?.Content as FrameworkElement, - chatProviderProvider: () => _chatCoordinator?.Provider, - inlineApprovalAvailable: _ => IsNativeChatSurfaceActive, settings: settings, enableMcpServer: settings.EnableMcpServer, identityDataPath: IdentityDataPath, sharedGatewayTokenResolver: () => _gatewayRegistry?.GetActive()?.SharedGatewayToken, browserControlPortResolver: () => _gatewayRegistry?.GetActive()?.BrowserControlPort, activeGatewayTunnelResolver: () => _gatewayRegistry?.GetActive()?.SshTunnel, - activeGatewayUrlResolver: () => _gatewayRegistry?.GetActive()?.Url); + activeGatewayUrlResolver: () => _gatewayRegistry?.GetActive()?.Url, + execApprovalsStore: ExecApprovalsStore); _nodeService.StatusChanged += OnNodeStatusChanged; _nodeService.NotificationRequested += OnNodeNotificationRequested; _nodeService.ToastRequested += OnNodeToastRequested; @@ -2107,8 +2111,6 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna _nodeService.InvokeCompleted += OnNodeInvokeCompleted; _nodeService.ToolTelemetryCompleted += OnNodeToolTelemetryCompleted; _nodeService.GatewaySelfUpdated += _gatewayService.OnGatewaySelfUpdated; - _nodeService.LocalExecApprovalRequested += OnLocalExecApprovalRequested; - _nodeService.LocalExecApprovalDecided += OnLocalExecApprovalDecided; return _nodeService; } catch (Exception ex) @@ -2562,186 +2564,9 @@ private void OnNodeToastRequested(object? sender, NodeToastRequestedEventArgs ar args.ToastDeviceId)), msg => Logger.Warn($"Failed to show node toast: {msg}"))); - private void OnLocalExecApprovalRequested(object? sender, ExecApprovalPromptRequestedEventArgs args) - { - if (string.IsNullOrWhiteSpace(args.Request.SessionKey)) - return; - - try - { - _appNotificationService?.Show(new AppNotification - { - Id = BuildLocalApprovalPendingNotificationId(args.Request), - Title = LocalizationHelper.GetString("AppNotification_ExecApprovalPending_Title"), - Message = BuildLocalApprovalPendingNotificationMessage(args.Request), - Source = "exec-approval", - Category = "node.invoke", - Severity = AppNotificationSeverity.Warning, - DedupeKey = BuildLocalApprovalPendingDedupeKey(args.Request), - ActionLabel = LocalizationHelper.GetString("AppNotification_ExecApprovalPending_OpenChatAction"), - ActionRoute = AppNotificationActionRoutes.Chat(args.Request.SessionKey!) - }); - } - catch (Exception ex) - { - Logger.Warn($"Failed to post pending exec-approval app notification: {ex.Message}"); - } - } - - private void OnLocalExecApprovalDecided(object? sender, ExecApprovalPromptDecidedEventArgs args) - { - try { _appNotificationService?.Dismiss(BuildLocalApprovalPendingNotificationId(args.Request)); } - catch (Exception ex) { Logger.Debug($"Failed to dismiss pending exec-approval notification: {ex.Message}"); } - - if (args.Source is ExecApprovalPromptDecisionSource.UserAllowOnce - or ExecApprovalPromptDecisionSource.UserAlwaysAllow) - { - try { _appNotificationService?.DismissByDedupeKey(BuildLocalDenyDedupeKey(args.Request)); } - catch (Exception ex) { Logger.Debug($"Failed to dismiss stale denied exec-approval notification: {ex.Message}"); } - return; - } - - if (args.Source is not (ExecApprovalPromptDecisionSource.UserDeny - or ExecApprovalPromptDecisionSource.PolicyAutoDeny)) - return; - try - { - _appNotificationService?.Show(new AppNotification - { - Title = LocalizationHelper.GetString("AppNotification_LocalCommandDenied_Title"), - Message = BuildLocalDenyNotificationMessage(args.Request), - Source = "exec-approval", - Category = "node.invoke", - Severity = AppNotificationSeverity.Warning, - DedupeKey = BuildLocalDenyDedupeKey(args.Request) - }); - } - catch (Exception ex) - { - Logger.Warn($"Failed to post local-deny app notification: {ex.Message}"); - } - } - - private static string BuildLocalApprovalPendingNotificationMessage(ExecApprovalPromptRequest request) - { - var session = FormatSessionKeyForNotification(request.SessionKey); - var command = string.IsNullOrWhiteSpace(request.Command) - ? LocalizationHelper.GetString("AppNotification_LocalCommandDenied_UnknownCommandSubject") - : CompactNotificationText(request.Command.Trim()); - return LocalizationHelper.Format( - "AppNotification_ExecApprovalPending_MessageFormat", - session, - command); - } - - private static string FormatSessionKeyForNotification(string? sessionKey) - { - if (string.IsNullOrWhiteSpace(sessionKey)) - return LocalizationHelper.GetString("AppNotification_ExecApprovalPending_UnknownChatLabel"); - - var parts = sessionKey.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length >= 3 && string.Equals(parts[0], "agent", StringComparison.OrdinalIgnoreCase)) - { - var agent = FormatSessionSegment(parts[1]); - var slot = FormatSessionSegment(parts[2]); - var main = LocalizationHelper.GetString("AppNotification_ExecApprovalPending_MainSessionLabel"); - var isMainAgent = string.Equals(parts[1], "main", StringComparison.OrdinalIgnoreCase); - var isMainSlot = string.Equals(parts[2], "main", StringComparison.OrdinalIgnoreCase); - var isDefaultSlot = string.Equals(parts[2], "default", StringComparison.OrdinalIgnoreCase); - - if (isMainAgent && isMainSlot) - return LocalizationHelper.GetString("AppNotification_ExecApprovalPending_MainChatLabel"); - - if (isMainSlot || isDefaultSlot) - { - return LocalizationHelper.Format( - "AppNotification_ExecApprovalPending_AgentChatLabelFormat", - isMainAgent ? main : agent); - } - - return LocalizationHelper.Format( - "AppNotification_ExecApprovalPending_AgentSlotLabelFormat", - isMainAgent ? main : agent, - slot); - } - - return CompactNotificationText(sessionKey); - } - - private static string FormatSessionSegment(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return LocalizationHelper.GetString("AppNotification_ExecApprovalPending_UnknownChatLabel"); - - var words = value.Replace('-', ' ').Replace('_', ' '); - return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(words.ToLower(CultureInfo.CurrentCulture)); - } - - private static string BuildLocalApprovalPendingNotificationId(ExecApprovalPromptRequest request) => - "exec-approval-pending-" + HashNotificationKey(BuildLocalApprovalPendingDedupeKey(request)); - - private static string BuildLocalApprovalPendingDedupeKey(ExecApprovalPromptRequest request) - { - return string.Join("|", - "exec-approval-pending", - request.SessionKey ?? "", - request.CorrelationId ?? "", - request.Command ?? "", - request.Shell ?? ""); - } - private static string HashNotificationKey(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); - private static string BuildLocalDenyNotificationMessage(ExecApprovalPromptRequest request) - { - var subject = string.IsNullOrWhiteSpace(request.Command) - ? LocalizationHelper.GetString("AppNotification_LocalCommandDenied_UnknownCommandSubject") - : LocalizationHelper.Format( - "AppNotification_LocalCommandDenied_CommandSubjectFormat", - CompactNotificationText(request.Command.Trim())); - - string message; - if (!string.IsNullOrWhiteSpace(request.Reason)) - { - message = LocalizationHelper.Format( - "AppNotification_LocalCommandDenied_MessageFormat", - subject, - CompactNotificationText(request.Reason.Trim())); - } - else - { - message = LocalizationHelper.Format( - "AppNotification_LocalCommandDenied_MessageNoReasonFormat", - subject); - } - - if (!string.IsNullOrWhiteSpace(request.MatchedPattern)) - { - message += " " + LocalizationHelper.Format( - "AppNotification_LocalCommandDenied_PatternSuffixFormat", - CompactNotificationText(request.MatchedPattern.Trim())); - } - - return message; - } - - private static string CompactNotificationText(string text) - { - const int maxLength = 240; - if (text.Length <= maxLength) - return text; - return text[..(maxLength - 1)] + "โ€ฆ"; - } - - private static string BuildLocalDenyDedupeKey(ExecApprovalPromptRequest request) - { - var command = request.Command?.Trim() ?? string.Empty; - var reason = request.Reason?.Trim() ?? string.Empty; - var pattern = request.MatchedPattern?.Trim() ?? string.Empty; - return $"exec-denied:{command}:{reason}:{pattern}"; - } - private void OnNodeInvokeCompleted(object? sender, NodeInvokeCompletedEventArgs args) { var status = args.Ok ? "completed" : "failed"; diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 439b86875..f95307060 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -103,7 +103,6 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private long _toolMetaSaveVersion; private bool _toolMetaCacheDirty; private readonly Dictionary _timelines = new(); - private readonly Dictionary _localInlineApprovals = new(StringComparer.Ordinal); private readonly Dictionary _activeRunIds = new(); // sessionKey โ†’ runId private readonly Dictionary _activeRunStartSequences = new(); // sessionKey โ†’ lifecycle.start sequence private readonly Dictionary _pendingAbortCounts = new(); // threads โ†’ count of pending aborts waiting for lifecycle.start @@ -190,12 +189,6 @@ private enum AssistantQueueFrameDisposition Render, Drop, } - private sealed record LocalInlineApproval( - string ThreadId, - string RequestId, - string Detail, - TaskCompletionSource Response); - private static readonly TimeSpan LocalInlineApprovalTimeout = TimeSpan.FromSeconds(30); // Per-thread, per-entry metadata: timestamp + model snapshot at the // moment the entry was created. Built up as events are applied so the // timeline renderer can show a " ยท ยท " footer @@ -1905,9 +1898,6 @@ public async Task RespondToPermissionAsync(string threadId, string requestId, st return; var decision = NormalizeApprovalAction(action); - if (TryResolveLocalInlineApproval(threadId, requestId, decision)) - return; - // Use the operator-approvals gateway RPC (``exec.approval.resolve``) // rather than the ``/approve `` chat slash command. // @@ -1937,129 +1927,6 @@ public async Task RespondToPermissionAsync(string threadId, string requestId, st decision: ChatDecisionForApprovalAction(decision)); } - internal async Task RequestLocalExecApprovalAsync( - ExecApprovalPromptRequest request, - CancellationToken cancellationToken = default, - TimeSpan? approvalTimeout = null) - { - cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(request.SessionKey) || _disposed) - return null; - - var threadId = request.SessionKey!; - var requestId = !string.IsNullOrWhiteSpace(request.CorrelationId) - ? $"local-{request.CorrelationId}" - : $"local-{Guid.NewGuid():N}"; - var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var detail = request.Command ?? string.Empty; - var inline = new LocalInlineApproval(threadId, requestId, detail, response); - - ChatDataSnapshot snapshot; - lock (_gate) - { - if (_disposed) - return null; - - _localInlineApprovals[requestId] = inline; - var meta = BuildLiveMetaLocked(threadId); - snapshot = ApplyEventLocked( - threadId, - new ChatPermissionRequestEvent( - requestId, - LocalizationHelper.GetString("Chat_Permission_CommandApprovalTitle"), - request.Shell ?? "exec", - detail, - ChatPermissionActionKeys.ExecApprovalDefaults), - meta); - } - Publish(snapshot); - - using var registration = cancellationToken.Register(() => - TryResolveLocalInlineApproval(threadId, requestId, ChatPermissionActionKeys.Deny)); - - _ = ExpireLocalInlineApprovalAfterDelayAsync(threadId, requestId, approvalTimeout ?? LocalInlineApprovalTimeout); - - return await response.Task.ConfigureAwait(false); - } - - private async Task ExpireLocalInlineApprovalAfterDelayAsync(string threadId, string requestId, TimeSpan delay) - { - try - { - await Task.Delay(delay).ConfigureAwait(false); - TryExpireLocalInlineApproval(threadId, requestId); - } - catch (Exception ex) - { - Logger.Debug($"[Approval] inline approval timeout task failed: {ex.Message}"); - } - } - - private bool TryExpireLocalInlineApproval(string threadId, string requestId) - { - LocalInlineApproval inline; - ChatDataSnapshot snapshot; - lock (_gate) - { - if (!_localInlineApprovals.TryGetValue(requestId, out var found) || - !string.Equals(found.ThreadId, threadId, StringComparison.Ordinal)) - { - return false; - } - - inline = found; - _localInlineApprovals.Remove(requestId); - var current = GetOrCreateTimelineLocked(threadId); - _timelines[threadId] = ChatTimelineReducer.ResolvePermission( - current, - requestId, - ChatPermissionDecision.Expired); - snapshot = BuildSnapshotLocked(); - } - - Publish(snapshot); - inline.Response.TrySetResult(ExecApprovalPromptDecision.TimedOut()); - return true; - } - - private bool TryResolveLocalInlineApproval(string threadId, string requestId, string decision) - { - LocalInlineApproval inline; - ChatDataSnapshot snapshot; - lock (_gate) - { - if (!_localInlineApprovals.TryGetValue(requestId, out var found) || - !string.Equals(found.ThreadId, threadId, StringComparison.Ordinal)) - { - return false; - } - - inline = found; - _localInlineApprovals.Remove(requestId); - var current = GetOrCreateTimelineLocked(threadId); - _timelines[threadId] = ChatTimelineReducer.ResolvePermission( - current, - requestId, - ChatDecisionForApprovalAction(decision)); - var meta = BuildLiveMetaLocked(threadId); - snapshot = ApplyEventLocked( - threadId, - new ChatStatusEvent(FormatApprovalResult(decision, inline.Detail, requestId), ApprovalToneForDecision(decision)), - meta); - } - - Publish(snapshot); - inline.Response.TrySetResult(DecisionForApprovalAction(decision)); - return true; - } - - private static ExecApprovalPromptDecision DecisionForApprovalAction(string decision) => - string.Equals(decision, ChatPermissionActionKeys.AllowAlways, StringComparison.OrdinalIgnoreCase) - ? ExecApprovalPromptDecision.AlwaysAllow() - : string.Equals(decision, ChatPermissionActionKeys.AllowOnce, StringComparison.OrdinalIgnoreCase) - ? ExecApprovalPromptDecision.AllowOnce() - : ExecApprovalPromptDecision.Deny(); - private static string FormatApprovalResult(string decision, string detail, string requestId) => string.Format( System.Globalization.CultureInfo.CurrentCulture, @@ -2139,7 +2006,6 @@ public ValueTask DisposeAsync() { System.Threading.Timer? timerToDispose; System.Threading.Timer? chatStateTimerToDispose; - List pendingLocalApprovals; CancellationTokenSource historyGenerationToCancel; lock (_gate) { @@ -2152,8 +2018,6 @@ public ValueTask DisposeAsync() _toolMetaSaveVersion++; chatStateTimerToDispose = _lastChatStateSaveTimer; _lastChatStateSaveTimer = null; - pendingLocalApprovals = _localInlineApprovals.Values.ToList(); - _localInlineApprovals.Clear(); _queuedMessages.Clear(); _queuedSendRequests.Clear(); _queuedDrainScheduledThreads.Clear(); @@ -2164,8 +2028,6 @@ public ValueTask DisposeAsync() _resetSubmittedLocalEchoTexts.Clear(); } CancelAndDisposeHistoryGeneration(historyGenerationToCancel); - foreach (var approval in pendingLocalApprovals) - approval.Response.TrySetResult(ExecApprovalPromptDecision.Deny()); timerToDispose?.Dispose(); chatStateTimerToDispose?.Dispose(); SaveToolMetaCache(); @@ -6337,22 +6199,6 @@ private ChatDataSnapshot BuildSnapshotLocked() }); } - foreach (var approval in _localInlineApprovals.Values) - { - if (threadList.Any(s => string.Equals(s.Id, approval.ThreadId, StringComparison.Ordinal))) - continue; - - threadList.Add(new ChatThread - { - Id = approval.ThreadId, - Title = _lastChatState?.ThreadTitle ?? "OpenClaw Windows Tray", - Status = ChatThreadStatus.Running, - Activity = ChatActivity.AwaitingPermission, - Model = _lastChatState?.Model, - ModelProvider = _lastChatState?.ModelProvider, - }); - } - var threads = threadList.ToArray(); // Snapshot a defensive copy of the timeline dict. diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/ExecApprovalDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/ExecApprovalDialog.cs index fa0824b9d..046e67f12 100644 --- a/src/OpenClaw.Tray.WinUI/Dialogs/ExecApprovalDialog.cs +++ b/src/OpenClaw.Tray.WinUI/Dialogs/ExecApprovalDialog.cs @@ -50,6 +50,7 @@ public sealed class ExecApprovalDialog : WindowEx private ScrollViewer? _bodyScroll; private bool _delayElapsed; private bool _commandFullySeen; + private readonly bool _allowAlwaysAvailable; public bool IsClosed { get; private set; } @@ -57,6 +58,8 @@ public ExecApprovalDialog(ExecApprovalPromptView view) { ArgumentNullException.ThrowIfNull(view); + _allowAlwaysAvailable = view.AllowAlwaysAvailable; + var windowTitle = $"{AppIdentity.DisplayName} - {LocalizationHelper.GetString("ExecApproval_WindowTitle")}"; Title = windowTitle; this.SetWindowSize(520, 400); @@ -186,6 +189,11 @@ public ExecApprovalDialog(ExecApprovalPromptView view) _allowAlwaysButton.Click += (_, _) => Decide(ExecApprovalPromptOutcome.AllowAlways); Grid.SetColumn(_allowAlwaysButton, 2); buttonGrid.Children.Add(_allowAlwaysButton); + // Allow Always is hidden when the policy would not persist a reusable rule + // (ask=always or a one-shot command), matching the macOS decision set. Deny and + // Allow Once always remain. + if (!_allowAlwaysAvailable) + _allowAlwaysButton.Visibility = Visibility.Collapsed; _allowOnceButton = new Button { @@ -275,8 +283,15 @@ private void ArmAllowGuard() private void UpdateCommandSeen() { if (_commandFullySeen || _bodyScroll is null) return; - var atEnd = _bodyScroll.ScrollableHeight <= 0.5 - || _bodyScroll.VerticalOffset >= _bodyScroll.ScrollableHeight - 2.0; + // Ignore pre-layout passes: until the ScrollViewer has actually measured its content + // (non-zero viewport AND extent), a zero ScrollableHeight means "not laid out yet", not + // "everything fits". Latching on that would arm the allow buttons for a long command the + // user never scrolled to the end of. A later ViewChanged/SizeChanged/Loaded re-evaluates. + if (_bodyScroll.ViewportHeight <= 0 || _bodyScroll.ExtentHeight <= 0) + return; + var scrollable = _bodyScroll.ScrollableHeight; + var atEnd = scrollable <= 0.5 + || _bodyScroll.VerticalOffset >= scrollable - 2.0; if (atEnd) { _commandFullySeen = true; @@ -290,16 +305,20 @@ private void MaybeArmAllow() if (_delayElapsed && _commandFullySeen) { _allowOnceButton.IsEnabled = true; - _allowAlwaysButton.IsEnabled = true; ToolTipService.SetToolTip(_allowOnceButton, null); - ToolTipService.SetToolTip(_allowAlwaysButton, null); + if (_allowAlwaysAvailable) + { + _allowAlwaysButton.IsEnabled = true; + ToolTipService.SetToolTip(_allowAlwaysButton, null); + } return; } var hintKey = _commandFullySeen ? "ExecApproval_ArmHint" : "ExecApproval_ScrollHint"; var hint = LocalizationHelper.GetString(hintKey); ToolTipService.SetToolTip(_allowOnceButton, hint); - ToolTipService.SetToolTip(_allowAlwaysButton, hint); + if (_allowAlwaysAvailable) + ToolTipService.SetToolTip(_allowAlwaysButton, hint); } private static Border BuildConfusableWarning() diff --git a/src/OpenClaw.Tray.WinUI/Pages/ExecPolicyRuleList.cs b/src/OpenClaw.Tray.WinUI/Pages/ExecPolicyRuleList.cs index a914cb067..6247fdf6b 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ExecPolicyRuleList.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ExecPolicyRuleList.cs @@ -6,12 +6,15 @@ namespace OpenClawTray.Pages; internal sealed class ExecPolicyRule { + public Guid? Id { get; set; } public string Pattern { get; set; } = ""; public string Action { get; set; } = "deny"; public int Index { get; set; } public string[]? Shells { get; set; } public string? Description { get; set; } public bool Enabled { get; set; } = true; + public double? LastUsedAt { get; set; } + public string? LastResolvedPath { get; set; } } internal static class ExecPolicyRuleList diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml index 5ef2f5466..d98862789 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml @@ -187,7 +187,7 @@ @@ -203,7 +203,7 @@ - @@ -232,7 +232,7 @@ - @@ -324,13 +324,11 @@ - - public sealed class TrayAppFixture : IAsyncLifetime { + public const string SeededExecApprovalPattern = "**/where.exe"; + public const string SeededExecApprovalId = "11111111-1111-1111-1111-111111111111"; + public string DataDir { get; } public int McpPort { get; } public string McpEndpoint => $"http://127.0.0.1:{McpPort}/mcp"; @@ -36,6 +39,7 @@ public TrayAppFixture() McpPort = FindFreePort(); WriteSettings(); + WriteExecApprovals(); _exePath = LocateTrayExe(); _process = SpawnTray(); @@ -166,6 +170,37 @@ private void WriteSettings() File.WriteAllText(Path.Combine(DataDir, "settings.json"), settings.ToJson()); } + private void WriteExecApprovals() + { + File.WriteAllText( + Path.Combine(DataDir, "exec-approvals.json"), + $$""" + { + "version": 1, + "defaults": { + "security": "allowlist", + "ask": "off", + "askFallback": "deny", + "autoAllowSkills": false + }, + "agents": { + "main": { + "security": "allowlist", + "ask": "off", + "askFallback": "deny", + "autoAllowSkills": false, + "allowlist": [ + { + "id": "{{SeededExecApprovalId}}", + "pattern": "{{SeededExecApprovalPattern}}" + } + ] + } + } + } + """); + } + private static string LocateTrayExe() { var rid = RuntimeInformation.ProcessArchitecture switch diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 32a07e77d..a8629e0e7 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -325,14 +325,41 @@ public void ChatWebView_KeepsBaseChatUrlSeparateFromPendingSessionKey() } [Fact] - public void PermissionsPage_ExecPolicy_UsesAppDataDirectory() + public void PermissionsPage_ExecApprovals_UsesAppOwnedStoreWithCas() { var root = TestRepositoryPaths.GetRepositoryRoot(); var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs")); - Assert.Contains("Path.Combine(CurrentApp.DataDirectoryPath, \"exec-policy.json\")", source); - Assert.DoesNotContain("SpecialFolder.LocalApplicationData", source); - Assert.DoesNotContain("SettingsManager.SettingsDirectoryPath, \"exec-policy.json\"", source); + Assert.Contains("CurrentApp.ExecApprovalsStore.GetSnapshotAsync()", source); + Assert.Contains("CurrentApp.ExecApprovalsStore.ReplaceAsync(expectedHash, file)", source); + Assert.Contains("ExecPolicyMutationKind.AddRule", source); + Assert.Contains("ExecPolicyMutationKind.RemoveRule", source); + Assert.DoesNotContain("main.Allowlist = _policyRules", source); + Assert.DoesNotContain("Path.Combine(CurrentApp.DataDirectoryPath, \"exec-approvals.json\")", source); + Assert.DoesNotContain("File.WriteAllText(tmpPath", source); + } + + [Fact] + public void App_ExecApprovalsStore_UsesRoamingProductionDataRoot() + { + var source = ReadAppSources(); + + Assert.Contains("_execApprovalsStore ??= new ExecApprovalsStore(", source); + Assert.Contains("AppIdentity.ResolveRoamingDataDirectory()", source); + } + + [Fact] + public void TrayArtifactCleanup_UsesActiveExecApprovalsStatePath() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine( + root, + "src", + "OpenClaw.SetupEngine", + "TrayArtifactCleanup.cs")); + + Assert.Contains("ExecApprovalsStore.ResolveFilePath(appDataDir)", source); + Assert.Contains("legacyExecApprovalsPath", source); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/GatewayConnectionManagerConnectTests.cs b/tests/OpenClaw.Tray.Tests/GatewayConnectionManagerConnectTests.cs index f9b337287..109cca02f 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayConnectionManagerConnectTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayConnectionManagerConnectTests.cs @@ -60,15 +60,19 @@ private sealed class FakeClientFactory : IGatewayClientFactory public IGatewayClientLifecycle Create(string gatewayUrl, GatewayCredential credential, string identityPath, IOpenClawLogger logger) { - var lifecycle = new FakeLifecycle(gatewayUrl); + var lifecycle = new FakeLifecycle(gatewayUrl, identityPath); CreatedClients.Add(lifecycle); return lifecycle; } } - private sealed class FakeLifecycle(string gatewayUrl) : IGatewayClientLifecycle + private sealed class FakeLifecycle(string gatewayUrl, string identityPath) : IGatewayClientLifecycle { - public OpenClawGatewayClient DataClient { get; } = new(gatewayUrl, "mock-token", NullLogger.Instance); + public OpenClawGatewayClient DataClient { get; } = new( + gatewayUrl, + "mock-token", + NullLogger.Instance, + identityPath: identityPath); public bool IsDisposed { get; private set; } #pragma warning disable CS0067 // Events required by interface but not fired in this regression test. diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 74b55c88b..debaba8f0 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -9010,105 +9010,6 @@ public async Task ApprovalRequested_DedupesSlugFirstUuidTwin_AndUuidOnlyResolved Assert.Null(snapshots[^1].Timelines["main"].PendingPermission); } - [Fact] - public async Task LocalExecApproval_InlineDecisionCompletesPromptAndAddsHistoryResult() - { - var (_, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); - await provider.LoadAsync(); - snapshots.Clear(); - - var promptTask = provider.RequestLocalExecApprovalAsync(new ExecApprovalPromptRequest - { - Command = "del \"E:\\Temp\\sample.txt\"", - Shell = "auto", - Reason = "No matching rule; default policy applied", - SessionKey = "main", - CorrelationId = "abc12345" - }); - - Assert.False(promptTask.IsCompleted); - var pendingTimeline = snapshots[^1].Timelines["main"]; - var pendingEntry = Assert.Single(pendingTimeline.Entries, - e => e.Kind == ChatTimelineItemKind.PermissionRequest); - Assert.Equal("local-abc12345", pendingEntry.PermissionRequestId); - Assert.Contains(ChatPermissionActionKeys.AllowOnce, pendingEntry.PermissionActions!); - Assert.Contains(ChatPermissionActionKeys.AllowAlways, pendingEntry.PermissionActions!); - Assert.Contains(ChatPermissionActionKeys.Deny, pendingEntry.PermissionActions!); - - await provider.RespondToPermissionAsync("main", "local-abc12345", ChatPermissionActionKeys.AllowAlways); - - var decision = await promptTask; - Assert.Equal(ExecApprovalPromptDecisionKind.AlwaysAllow, decision!.Kind); - - var decidedTimeline = snapshots[^1].Timelines["main"]; - var decidedEntry = Assert.Single(decidedTimeline.Entries, - e => e.Kind == ChatTimelineItemKind.PermissionRequest); - Assert.Equal(ChatPermissionDecision.AllowedAlways, decidedEntry.PermissionDecision); - Assert.Contains(decidedTimeline.Entries, e => - e.Kind == ChatTimelineItemKind.Status && - e.Text.Contains("Always allow", StringComparison.Ordinal) && - e.Text.Contains("del \"E:\\Temp\\sample.txt\"", StringComparison.Ordinal)); - } - - [Fact] - public async Task LocalExecApproval_SyntheticThreadUsesCachedModelProvider() - { - var session = MainSession(); - session.Model = "gpt-5.4"; - session.Provider = "openrouter"; - var (bridge, provider, snapshots, _) = CreateProvider(new[] { session }); - await provider.LoadAsync(); - Assert.Equal("gpt-5.4", provider.CachedLastChatState?.Model); - Assert.Equal("openrouter", provider.CachedLastChatState?.ModelProvider); - bridge.RaiseSessions(Array.Empty()); - Assert.Equal("gpt-5.4", provider.CachedLastChatState?.Model); - Assert.Equal("openrouter", provider.CachedLastChatState?.ModelProvider); - snapshots.Clear(); - - var promptTask = provider.RequestLocalExecApprovalAsync(new ExecApprovalPromptRequest - { - Command = "tasklist", - Shell = "cmd", - SessionKey = "main", - CorrelationId = "provider-context" - }); - - var synthetic = Assert.Single(snapshots[^1].Threads, t => t.Id == "main"); - Assert.Equal("gpt-5.4", synthetic.Model); - Assert.Equal("openrouter", synthetic.ModelProvider); - - await provider.RespondToPermissionAsync("main", "local-provider-context", ChatPermissionActionKeys.Deny); - await promptTask; - } - - [Fact] - public async Task LocalExecApproval_TimeoutExpiresEntryAndCompletesAsTimedOutDeny() - { - var (_, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); - await provider.LoadAsync(); - snapshots.Clear(); - - var promptTask = provider.RequestLocalExecApprovalAsync(new ExecApprovalPromptRequest - { - Command = "tasklist", - Shell = "cmd", - SessionKey = "main", - CorrelationId = "timeout1" - }, approvalTimeout: TimeSpan.FromMilliseconds(10)); - - var decision = await promptTask.WaitAsync(TimeSpan.FromSeconds(2)); - - Assert.NotNull(decision); - Assert.Equal(ExecApprovalPromptDecisionKind.Deny, decision!.Kind); - Assert.Equal(ExecApprovalPromptDecision.TimedOutReason, decision.Reason); - - var timedOutTimeline = snapshots[^1].Timelines["main"]; - var timedOutEntry = Assert.Single(timedOutTimeline.Entries, - e => e.Kind == ChatTimelineItemKind.PermissionRequest); - Assert.Equal(ChatPermissionDecision.Expired, timedOutEntry.PermissionDecision); - Assert.Null(timedOutTimeline.PendingPermission); - } - private static async Task WaitForConditionAsync(Func condition, int attempts = 50) { for (var i = 0; i < attempts && !condition(); i++) diff --git a/tests/OpenClaw.Tray.Tests/Services/AppUserModelIdIdentityTests.cs b/tests/OpenClaw.Tray.Tests/Services/AppUserModelIdIdentityTests.cs index 76f3f6b7c..44ffce45a 100644 --- a/tests/OpenClaw.Tray.Tests/Services/AppUserModelIdIdentityTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/AppUserModelIdIdentityTests.cs @@ -77,13 +77,13 @@ public void ApprovalPopups_UseCompanionDisplayName() var root = TestRepositoryPaths.GetRepositoryRoot(); var pairingDialog = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Dialogs", "PairingApprovalDialog.cs")); var recordingDialog = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Dialogs", "RecordingConsentDialog.cs")); - var execPrompt = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Services", "ExecApprovalPromptService.cs")); + var execPrompt = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Dialogs", "ExecApprovalDialog.cs")); Assert.Contains("AppIdentity.DisplayName", pairingDialog); Assert.Contains("AppIdentity.DisplayName", recordingDialog); Assert.Contains("AppIdentity.DisplayName", execPrompt); Assert.DoesNotContain("OpenClaw ยท Permission Request", File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw"))); - Assert.Contains("NativePromptTitle", execPrompt); + Assert.Contains("ExecApproval_WindowTitle", execPrompt); Assert.DoesNotContain("OpenClaw.Tray.WinUI", pairingDialog); Assert.DoesNotContain("OpenClaw.Tray.WinUI", recordingDialog); Assert.DoesNotContain("OpenClaw.Tray.WinUI", execPrompt);