diff --git a/hooks/block-monk.ps1 b/hooks/block-monk.ps1 index 8170c7e..b9d96f6 100644 --- a/hooks/block-monk.ps1 +++ b/hooks/block-monk.ps1 @@ -8,21 +8,77 @@ $agentDir = if ($env:MONK_AGENT_INSTALL_DIR) { $env:MONK_AGENT_INSTALL_DIR } else { Join-Path $HOME ".monk\bin" } $agent = if ($env:MONK_AGENT_PATH) { $env:MONK_AGENT_PATH } else { Join-Path $agentDir "monk-agent.exe" } -# Let the binary read the hook payload straight from stdin. Reading it into a -# PowerShell string first and re-piping it corrupts non-ASCII bytes (e.g. the -# leading UTF-8 BOM that some hosts prepend) whenever the console is on an OEM -# code page - which is exactly what the cmd.exe launcher sets - so the binary -# would receive unparseable JSON and silently allow the command. The binary -# reads raw UTF-8 (BOM-tolerant) from the inherited stdin and is unaffected. +# Buffer stdin as bytes so the helper receives the original UTF-8 payload +# without a PowerShell console-code-page round trip. Keeping the bytes also +# lets the native parser make the decision if the helper fails or emits an +# invalid response. +$inputStream = [Console]::OpenStandardInput() +$inputBuffer = New-Object byte[] 4096 +$payloadStream = New-Object System.IO.MemoryStream +while (($bytesRead = $inputStream.Read($inputBuffer, 0, $inputBuffer.Length)) -gt 0) { + $payloadStream.Write($inputBuffer, 0, $bytesRead) +} +$hookBytes = $payloadStream.ToArray() +$payloadStream.Dispose() + if (Test-Path $agent) { - & $agent hook block-monk --format claude - exit $LASTEXITCODE + # Treat the helper as authoritative only when it succeeds and emits a + # decision. An interrupted update, incompatible binary, or startup failure + # must not turn the guard off: discard the helper error and use the native + # parser below. A successful empty response also falls through safely; the + # fallback permits ordinary commands and still blocks direct `monk` calls. + $agentProcess = $null + $agentText = $null + $agentExitCode = $null + try { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $agent + $startInfo.Arguments = "hook block-monk --format claude" + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.CreateNoWindow = $true + + $agentProcess = New-Object System.Diagnostics.Process + $agentProcess.StartInfo = $startInfo + [void]$agentProcess.Start() + $outputTask = $agentProcess.StandardOutput.ReadToEndAsync() + $errorTask = $agentProcess.StandardError.ReadToEndAsync() + $agentProcess.StandardInput.BaseStream.Write($hookBytes, 0, $hookBytes.Length) + $agentProcess.StandardInput.BaseStream.Close() + $agentProcess.WaitForExit() + $agentText = $outputTask.GetAwaiter().GetResult() + [void]$errorTask.GetAwaiter().GetResult() + $agentExitCode = $agentProcess.ExitCode + } catch { + $agentText = $null + } finally { + if ($agentProcess) { + $agentProcess.Dispose() + } + } + + if ($agentExitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($agentText)) { + try { + $agentDecision = $agentText | ConvertFrom-Json + } catch { + $agentDecision = $null + } + if ($agentDecision.hookSpecificOutput.hookEventName -eq "PreToolUse" -and + $agentDecision.hookSpecificOutput.permissionDecision -in @("allow", "ask", "deny")) { + Write-Output $agentText + exit 0 + } + } } -# Fallback: binary unavailable. Read stdin as UTF-8 (BOM stripped) so the payload -# survives a non-UTF-8 console code page, then match `monk` in command position. -$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) -$hookInput = $reader.ReadToEnd() +# Fallback: decode the buffered UTF-8 payload and strip a leading BOM before +# matching `monk` in command position. +$hookInput = [System.Text.Encoding]::UTF8.GetString($hookBytes) +if ($hookInput.Length -gt 0 -and $hookInput[0] -eq [char]0xFEFF) { + $hookInput = $hookInput.Substring(1) +} try { $command = ($hookInput | ConvertFrom-Json).tool_input.command } catch { exit 0 } if (-not $command) { exit 0 } diff --git a/plugins/monk/hooks/block-monk.ps1 b/plugins/monk/hooks/block-monk.ps1 index 8170c7e..b9d96f6 100644 --- a/plugins/monk/hooks/block-monk.ps1 +++ b/plugins/monk/hooks/block-monk.ps1 @@ -8,21 +8,77 @@ $agentDir = if ($env:MONK_AGENT_INSTALL_DIR) { $env:MONK_AGENT_INSTALL_DIR } else { Join-Path $HOME ".monk\bin" } $agent = if ($env:MONK_AGENT_PATH) { $env:MONK_AGENT_PATH } else { Join-Path $agentDir "monk-agent.exe" } -# Let the binary read the hook payload straight from stdin. Reading it into a -# PowerShell string first and re-piping it corrupts non-ASCII bytes (e.g. the -# leading UTF-8 BOM that some hosts prepend) whenever the console is on an OEM -# code page - which is exactly what the cmd.exe launcher sets - so the binary -# would receive unparseable JSON and silently allow the command. The binary -# reads raw UTF-8 (BOM-tolerant) from the inherited stdin and is unaffected. +# Buffer stdin as bytes so the helper receives the original UTF-8 payload +# without a PowerShell console-code-page round trip. Keeping the bytes also +# lets the native parser make the decision if the helper fails or emits an +# invalid response. +$inputStream = [Console]::OpenStandardInput() +$inputBuffer = New-Object byte[] 4096 +$payloadStream = New-Object System.IO.MemoryStream +while (($bytesRead = $inputStream.Read($inputBuffer, 0, $inputBuffer.Length)) -gt 0) { + $payloadStream.Write($inputBuffer, 0, $bytesRead) +} +$hookBytes = $payloadStream.ToArray() +$payloadStream.Dispose() + if (Test-Path $agent) { - & $agent hook block-monk --format claude - exit $LASTEXITCODE + # Treat the helper as authoritative only when it succeeds and emits a + # decision. An interrupted update, incompatible binary, or startup failure + # must not turn the guard off: discard the helper error and use the native + # parser below. A successful empty response also falls through safely; the + # fallback permits ordinary commands and still blocks direct `monk` calls. + $agentProcess = $null + $agentText = $null + $agentExitCode = $null + try { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $agent + $startInfo.Arguments = "hook block-monk --format claude" + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.CreateNoWindow = $true + + $agentProcess = New-Object System.Diagnostics.Process + $agentProcess.StartInfo = $startInfo + [void]$agentProcess.Start() + $outputTask = $agentProcess.StandardOutput.ReadToEndAsync() + $errorTask = $agentProcess.StandardError.ReadToEndAsync() + $agentProcess.StandardInput.BaseStream.Write($hookBytes, 0, $hookBytes.Length) + $agentProcess.StandardInput.BaseStream.Close() + $agentProcess.WaitForExit() + $agentText = $outputTask.GetAwaiter().GetResult() + [void]$errorTask.GetAwaiter().GetResult() + $agentExitCode = $agentProcess.ExitCode + } catch { + $agentText = $null + } finally { + if ($agentProcess) { + $agentProcess.Dispose() + } + } + + if ($agentExitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($agentText)) { + try { + $agentDecision = $agentText | ConvertFrom-Json + } catch { + $agentDecision = $null + } + if ($agentDecision.hookSpecificOutput.hookEventName -eq "PreToolUse" -and + $agentDecision.hookSpecificOutput.permissionDecision -in @("allow", "ask", "deny")) { + Write-Output $agentText + exit 0 + } + } } -# Fallback: binary unavailable. Read stdin as UTF-8 (BOM stripped) so the payload -# survives a non-UTF-8 console code page, then match `monk` in command position. -$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) -$hookInput = $reader.ReadToEnd() +# Fallback: decode the buffered UTF-8 payload and strip a leading BOM before +# matching `monk` in command position. +$hookInput = [System.Text.Encoding]::UTF8.GetString($hookBytes) +if ($hookInput.Length -gt 0 -and $hookInput[0] -eq [char]0xFEFF) { + $hookInput = $hookInput.Substring(1) +} try { $command = ($hookInput | ConvertFrom-Json).tool_input.command } catch { exit 0 } if (-not $command) { exit 0 } diff --git a/tests/block-monk-windows.ps1 b/tests/block-monk-windows.ps1 index dd70549..dbf0b0b 100644 --- a/tests/block-monk-windows.ps1 +++ b/tests/block-monk-windows.ps1 @@ -5,6 +5,7 @@ param( $ErrorActionPreference = "Stop" $WindowsPowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" $RootHook = Join-Path $RepoRoot "hooks\block-monk.ps1" +$PluginHook = Join-Path $RepoRoot "plugins\monk\hooks\block-monk.ps1" $AntigravityHook = Join-Path $RepoRoot ".antigravity-plugin\hooks\block-monk.ps1" $MissingAgent = Join-Path $env:TEMP "missing-monk-agent-$PID.exe" $PreviousAgentPath = $env:MONK_AGENT_PATH @@ -58,12 +59,19 @@ function Assert-HookCases { } try { - $env:MONK_AGENT_PATH = $MissingAgent - - Assert-HookCases -Hook $RootHook -Format "claude" + foreach ($AgentPath in @( + $MissingAgent, + (Join-Path $env:SystemRoot "System32\net.exe"), + (Join-Path $env:SystemRoot "System32\cmd.exe") + )) { + $env:MONK_AGENT_PATH = $AgentPath + Assert-HookCases -Hook $RootHook -Format "claude" + Assert-HookCases -Hook $PluginHook -Format "claude" + } # Antigravity runs both hook siblings when bash is available. Limit PATH so # this test exercises the stock-Windows PowerShell fallback specifically. + $env:MONK_AGENT_PATH = $MissingAgent $env:Path = Split-Path -Parent $WindowsPowerShell Assert-HookCases -Hook $AntigravityHook -Format "antigravity" } finally {