From bcde7a5697cf5848b3f5c44cee24d27fac487685 Mon Sep 17 00:00:00 2001 From: Dean Patterson Date: Sun, 26 Jul 2026 20:31:27 -0600 Subject: [PATCH] fix: post-filter monk shell guard wrappers --- .antigravity-plugin/hooks/block-monk.ps1 | 125 +++++++++++++++++++---- .antigravity-plugin/hooks/block-monk.sh | 57 +++++++++-- hooks/block-monk.ps1 | 125 +++++++++++++++++++---- hooks/block-monk.sh | 72 ++++++++++--- plugins/monk/hooks/block-monk.ps1 | 125 +++++++++++++++++++---- plugins/monk/hooks/block-monk.sh | 72 ++++++++++--- tests/block-monk-posix-postfilter.sh | 68 ++++++++++++ tests/block-monk-windows.ps1 | 14 ++- 8 files changed, 557 insertions(+), 101 deletions(-) create mode 100644 tests/block-monk-posix-postfilter.sh diff --git a/.antigravity-plugin/hooks/block-monk.ps1 b/.antigravity-plugin/hooks/block-monk.ps1 index ea8bf4f..c40f188 100644 --- a/.antigravity-plugin/hooks/block-monk.ps1 +++ b/.antigravity-plugin/hooks/block-monk.ps1 @@ -1,13 +1,15 @@ -# PreToolUse hook for the run_command tool: block any shell-out to the `monk` CLI. +# PreToolUse hook for the run_command tool: block any shell-out to the `monk` +# CLI or `monkd` daemon. # Windows (stock, no Git Bash) counterpart of block-monk.sh. # # Antigravity PreToolUse I/O: # stdin: {"toolCall":{"name":"run_command","args":{"CommandLine":"..."}},...} # stdout: {"decision":"deny","reason":"..."} to block, or exit 0 to allow # -# Delegates to `monk-agent hook block-monk --format antigravity`; falls back to a -# native regex biased toward BLOCKING when the binary is unavailable. Always -# exits 0 (the deny JSON is the block signal). +# Delegates to `monk-agent hook block-monk --format antigravity`; post-filters +# helper allow/no-output results and falls back to a native regex biased toward +# BLOCKING when the binary is unavailable. Always exits 0 (the deny JSON is the +# block signal). $ErrorActionPreference = "SilentlyContinue" @@ -19,27 +21,112 @@ if ($env:OS -ne 'Windows_NT' -and (Get-Command bash -ErrorAction SilentlyContinu $InstallDir = 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 $InstallDir "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. a -# leading UTF-8 BOM) under an OEM console code page - which the cmd.exe launcher -# sets - so the binary would get unparseable JSON. The binary reads raw UTF-8 -# (BOM-tolerant) from the inherited stdin and is unaffected. -if (Test-Path $agent) { - & $agent hook block-monk --format antigravity - exit $LASTEXITCODE +function Read-StandardInputBytes { + $stream = [Console]::OpenStandardInput() + $buffer = New-Object byte[] 8192 + $memory = New-Object System.IO.MemoryStream + try { + while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $memory.Write($buffer, 0, $read) + } + $memory.ToArray() + } finally { + $memory.Dispose() + } } -# Fallback: binary unavailable. Read stdin as UTF-8 (BOM stripped), match `monk`. -$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) -$hookInput = $reader.ReadToEnd() -try { $command = ($hookInput | ConvertFrom-Json).toolCall.args.CommandLine } catch { exit 0 } -if (-not $command) { exit 0 } +function Invoke-AgentHook { + param([byte[]]$InputBytes) + + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $agent + $startInfo.Arguments = "hook block-monk --format antigravity" + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + [void]$process.Start() + if ($InputBytes.Length -gt 0) { + $process.StandardInput.BaseStream.Write($InputBytes, 0, $InputBytes.Length) + } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() -if ($command -match '(^|[\r\n;&|`({])\s*(sudo\s+)?monk(\s|$)') { + @{ + ExitCode = $process.ExitCode + Stdout = $stdout + Stderr = $stderr + } +} + +function Test-MonkShellCommand { + param([string]$Command) + + $binary = '(?:(?:[A-Za-z]:\\|~[\\/]|/)(?:[^\s"'';&|(){}]+[\\/])*)?monkd?(?:\.exe)?' + $boundary = '(^|[\r\n;&|`({])\s*' + $prefix = '(?:(?:sudo|command)\s+)*(?:env(?:\s+(?:-\S+|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|''[^'']*''|[^\s]+)))*\s+)?' + $tail = '(\s|$|[;&|`"''\)])' + $quote = '(?:["'']\s*)?' + $inlineLead = '(?:[^"'';&|]*[;&|]\s*)?' + + if ($Command -match ($boundary + $prefix + $binary + $tail)) { return $true } + if ($Command -match ($boundary + '\$\([^\r\n)]*\bwhich\s+monkd?\b[^\r\n)]*\)\s+\S+')) { return $true } + + $shell = '(?:bash|sh|zsh)(?:\.exe)?' + if ($Command -match ($boundary + '(?:sudo\s+)?' + $shell + '\s+-[A-Za-z]*c\s+' + $quote + $inlineLead + $prefix + $binary + $tail)) { + return $true + } + + $powerShell = '(?:powershell|powershell\.exe|pwsh|pwsh\.exe)' + if ($Command -match ($boundary + '(?:sudo\s+)?' + $powerShell + '\b[^\r\n;&|]*\s-(?:Command|c)\s+' + $quote + $inlineLead + $prefix + $binary + $tail)) { + return $true + } + + return $false +} + +function Write-Deny { @{ decision = "deny" - reason = "Blocked: do not shell out to the ``monk`` CLI - it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." + reason = "Blocked: do not shell out to the ``monk`` CLI or ``monkd`` daemon - it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." } | ConvertTo-Json -Compress } +$hookBytes = Read-StandardInputBytes +$agentResult = $null + +if (Test-Path $agent) { + $agentResult = Invoke-AgentHook -InputBytes $hookBytes + if ($agentResult["Stdout"] -match '"decision"\s*:\s*"deny"') { + [Console]::Out.Write($agentResult["Stdout"]) + exit 0 + } + if ($agentResult["ExitCode"] -ne 0) { + if ($agentResult["Stdout"]) { [Console]::Out.Write($agentResult["Stdout"]) } + if ($agentResult["Stderr"]) { [Console]::Error.Write($agentResult["Stderr"]) } + exit $agentResult["ExitCode"] + } +} + +$hookInput = [System.Text.Encoding]::UTF8.GetString($hookBytes) +if ($hookInput.Length -gt 0 -and [int][char]$hookInput[0] -eq 0xFEFF) { + $hookInput = $hookInput.Substring(1) +} + +try { $command = ($hookInput | ConvertFrom-Json).toolCall.args.CommandLine } catch { $command = "" } +if ($command -and (Test-MonkShellCommand -Command $command)) { + Write-Deny + exit 0 +} + +if ($agentResult -and $agentResult["Stdout"]) { + [Console]::Out.Write($agentResult["Stdout"]) +} + exit 0 diff --git a/.antigravity-plugin/hooks/block-monk.sh b/.antigravity-plugin/hooks/block-monk.sh index cb27933..d3c3bb7 100755 --- a/.antigravity-plugin/hooks/block-monk.sh +++ b/.antigravity-plugin/hooks/block-monk.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh -# PreToolUse hook for the run_command tool: block any shell-out to the `monk` CLI. -# Monk owns its own cluster state — running `monk ...` from a shell desyncs it. +# PreToolUse hook for the run_command tool: block any shell-out to the `monk` +# CLI or `monkd` daemon. Monk owns its own cluster state — running these from a shell desyncs it. # Use monk-agent MCP tools instead. # # Antigravity PreToolUse I/O: @@ -23,23 +23,58 @@ if [ -t 0 ]; then exit 0; fi input="$(cat)" -agent="${MONK_AGENT_PATH:-${MONK_AGENT_INSTALL_DIR:-"$HOME/.monk/bin"}/monk-agent}" -if [ -x "$agent" ]; then - if printf '%s' "$input" | "$agent" hook block-monk --format antigravity; then - exit 0 +should_block_monk_shellout() { + payload=$1 + boundary='(^|[";&|`({]|\\n|\\r\\n)[[:space:]]*' + monk_bin='((([A-Za-z]:\\\\|~[\\/]|/)([^[:space:]";&|(){}]+[\\/])*)?monkd?(\.exe)?)' + prefix='((sudo|command)[[:space:]]+)*(env([[:space:]]+(-[^[:space:]]+|[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|[^[:space:]]+)))*[[:space:]]+)?' + + if printf '%s' "$payload" | grep -Eq "${boundary}${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + if printf '%s' "$payload" | grep -Eq "${boundary}\\\$\\([^)]*which[[:space:]]+monkd?[^)]*\\)[[:space:]]+[^[:space:]]"; then + return 0 fi -fi -# Fallback: binary unavailable. Grep the raw hook payload for a `monk` command -# in command position. False positives only ever BLOCK, never allow. -if printf '%s' "$input" | grep -Eq '(^|["[:space:];&|`(])(sudo[[:space:]]+)?monk([[:space:]"]|$)'; then + if printf '%s' "$payload" | grep -Eq "${boundary}(sudo[[:space:]]+)?(bash|sh|zsh)(\\.exe)?[[:space:]]+-[A-Za-z]*c[[:space:]]+(\\\\?\")?[[:space:]]*([^\";&|]*[;&|][[:space:]]*)?${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + if printf '%s' "$payload" | grep -Eq "${boundary}(sudo[[:space:]]+)?(powershell|powershell\\.exe|pwsh|pwsh\\.exe)[^;&|]*[[:space:]]-(Command|c)[[:space:]]+(\\\\?\")?[[:space:]]*([^\";&|]*[;&|][[:space:]]*)?${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + return 1 +} + +emit_deny() { cat <<'JSON' { "decision": "deny", - "reason": "Blocked: do not shell out to the `monk` CLI — it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." + "reason": "Blocked: do not shell out to the `monk` CLI or `monkd` daemon — it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." } JSON +} + +agent="${MONK_AGENT_PATH:-${MONK_AGENT_INSTALL_DIR:-"$HOME/.monk/bin"}/monk-agent}" +agent_output= +if [ -x "$agent" ]; then + if agent_output="$(printf '%s' "$input" | "$agent" hook block-monk --format antigravity)"; then + if printf '%s' "$agent_output" | grep -Eq '"decision"[[:space:]]*:[[:space:]]*"deny"'; then + printf '%s' "$agent_output" + exit 0 + fi + fi +fi + +if should_block_monk_shellout "$input"; then + emit_deny exit 0 fi +if [ -n "$agent_output" ]; then + printf '%s' "$agent_output" +fi + exit 0 diff --git a/hooks/block-monk.ps1 b/hooks/block-monk.ps1 index 03c3e46..e228e79 100644 --- a/hooks/block-monk.ps1 +++ b/hooks/block-monk.ps1 @@ -1,39 +1,124 @@ -# PreToolUse hook for the Bash tool: block any shell-out to the `monk` CLI. -# Monk owns its own cluster state - running `monk ...` from a shell desyncs it. +# PreToolUse hook for the Bash tool: block any shell-out to the `monk` CLI or +# `monkd` daemon. +# Monk owns its own cluster state - running these from a shell desyncs it. # Use monk-agent MCP tools instead. # # Delegates to `monk-agent hook block-monk` so the logic stays in one place. -# Falls back to native PowerShell if the binary is unavailable. +# Post-filters helper allow/no-output results and falls back to native +# PowerShell if the binary is unavailable. $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. -if (Test-Path $agent) { - & $agent hook block-monk --format claude - exit $LASTEXITCODE +function Read-StandardInputBytes { + $stream = [Console]::OpenStandardInput() + $buffer = New-Object byte[] 8192 + $memory = New-Object System.IO.MemoryStream + try { + while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $memory.Write($buffer, 0, $read) + } + $memory.ToArray() + } finally { + $memory.Dispose() + } +} + +function Invoke-AgentHook { + param([byte[]]$InputBytes) + + $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 + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + [void]$process.Start() + if ($InputBytes.Length -gt 0) { + $process.StandardInput.BaseStream.Write($InputBytes, 0, $InputBytes.Length) + } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + @{ + ExitCode = $process.ExitCode + Stdout = $stdout + Stderr = $stderr + } } -# 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() -try { $command = ($hookInput | ConvertFrom-Json).tool_input.command } catch { exit 0 } -if (-not $command) { exit 0 } +function Test-MonkShellCommand { + param([string]$Command) + + $binary = '(?:(?:[A-Za-z]:\\|~[\\/]|/)(?:[^\s"'';&|(){}]+[\\/])*)?monkd?(?:\.exe)?' + $boundary = '(^|[\r\n;&|`({])\s*' + $prefix = '(?:(?:sudo|command)\s+)*(?:env(?:\s+(?:-\S+|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|''[^'']*''|[^\s]+)))*\s+)?' + $tail = '(\s|$|[;&|`"''\)])' + $quote = '(?:["'']\s*)?' + $inlineLead = '(?:[^"'';&|]*[;&|]\s*)?' + + if ($Command -match ($boundary + $prefix + $binary + $tail)) { return $true } + if ($Command -match ($boundary + '\$\([^\r\n)]*\bwhich\s+monkd?\b[^\r\n)]*\)\s+\S+')) { return $true } + + $shell = '(?:bash|sh|zsh)(?:\.exe)?' + if ($Command -match ($boundary + '(?:sudo\s+)?' + $shell + '\s+-[A-Za-z]*c\s+' + $quote + $inlineLead + $prefix + $binary + $tail)) { + return $true + } + + $powerShell = '(?:powershell|powershell\.exe|pwsh|pwsh\.exe)' + if ($Command -match ($boundary + '(?:sudo\s+)?' + $powerShell + '\b[^\r\n;&|]*\s-(?:Command|c)\s+' + $quote + $inlineLead + $prefix + $binary + $tail)) { + return $true + } -if ($command -match '(^|[\r\n;&|`({])\s*(sudo\s+)?monk(\s|$)') { + return $false +} + +function Write-Deny { @{ hookSpecificOutput = @{ hookEventName = "PreToolUse" permissionDecision = "deny" - permissionDecisionReason = "Blocked: do not shell out to the ``monk`` CLI - it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." + permissionDecisionReason = "Blocked: do not shell out to the ``monk`` CLI or ``monkd`` daemon - it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." } } | ConvertTo-Json -Compress } +$hookBytes = Read-StandardInputBytes +$agentResult = $null + +if (Test-Path $agent) { + $agentResult = Invoke-AgentHook -InputBytes $hookBytes + if ($agentResult["Stdout"] -match '"permissionDecision"\s*:\s*"deny"') { + [Console]::Out.Write($agentResult["Stdout"]) + exit 0 + } + if ($agentResult["ExitCode"] -ne 0) { + if ($agentResult["Stdout"]) { [Console]::Out.Write($agentResult["Stdout"]) } + if ($agentResult["Stderr"]) { [Console]::Error.Write($agentResult["Stderr"]) } + exit $agentResult["ExitCode"] + } +} + +$hookInput = [System.Text.Encoding]::UTF8.GetString($hookBytes) +if ($hookInput.Length -gt 0 -and [int][char]$hookInput[0] -eq 0xFEFF) { + $hookInput = $hookInput.Substring(1) +} + +try { $command = ($hookInput | ConvertFrom-Json).tool_input.command } catch { $command = "" } +if ($command -and (Test-MonkShellCommand -Command $command)) { + Write-Deny + exit 0 +} + +if ($agentResult -and $agentResult["Stdout"]) { + [Console]::Out.Write($agentResult["Stdout"]) +} + exit 0 diff --git a/hooks/block-monk.sh b/hooks/block-monk.sh index 3e4cd9a..8488556 100755 --- a/hooks/block-monk.sh +++ b/hooks/block-monk.sh @@ -1,13 +1,13 @@ #!/usr/bin/env sh -# PreToolUse hook for the Bash tool: block any shell-out to the `monk` CLI. -# Monk owns its own cluster state — running `monk ...` from a shell desyncs it. +# PreToolUse hook for the Bash tool: block any shell-out to the `monk` CLI or +# `monkd` daemon. Monk owns its own cluster state — running these from a shell desyncs it. # Use monk-agent MCP tools instead. # # The decision is made by `monk-agent hook block-monk` so the only dependency is # the monk-agent binary the plugin already installs. If that binary is somehow # missing we fall back to pure POSIX shell + grep, biased toward BLOCKING: a -# `monk` invocation is still caught with zero tooling, and non-monk commands are -# never blocked. The hook always exits 0 (the deny JSON is the block signal). +# `monk`/`monkd` invocation is still caught with zero tooling, and non-monk +# commands are never blocked. The hook always exits 0 (the deny JSON is the block signal). set -eu @@ -20,28 +20,70 @@ if [ -t 0 ]; then exit 0; fi input="$(cat)" -agent="${MONK_AGENT_PATH:-${MONK_AGENT_INSTALL_DIR:-"$HOME/.monk/bin"}/monk-agent}" -if [ -x "$agent" ]; then - if printf '%s' "$input" | "$agent" hook block-monk --format claude; then - exit 0 +should_block_monk_shellout() { + payload=$1 + boundary='(^|[";&|`({]|\\n|\\r\\n)[[:space:]]*' + monk_bin='((([A-Za-z]:\\\\|~[\\/]|/)([^[:space:]";&|(){}]+[\\/])*)?monkd?(\.exe)?)' + prefix='((sudo|command)[[:space:]]+)*(env([[:space:]]+(-[^[:space:]]+|[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|[^[:space:]]+)))*[[:space:]]+)?' + + # Direct command-position invocations and wrappers such as `command monk`, + # `env monk`, absolute paths, and `monkd`. + if printf '%s' "$payload" | grep -Eq "${boundary}${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + # Command substitution that resolves the binary and then executes it: + # `$(which monk) deploy`. + if printf '%s' "$payload" | grep -Eq "${boundary}\\\$\\([^)]*which[[:space:]]+monkd?[^)]*\\)[[:space:]]+[^[:space:]]"; then + return 0 fi -fi -# Fallback: binary unavailable. Grep the raw hook payload for a `monk` command. -# Matches `monk` only in command position (start, or after a shell separator), -# optional `sudo`, followed by whitespace/quote/end — so `monkey` is not matched. -# False positives only ever BLOCK, never allow, which is the safe direction. -if printf '%s' "$input" | grep -Eq '(^|["[:space:];&|`(])(sudo[[:space:]]+)?monk([[:space:]"]|$)'; then + # Nested shells where the inline script starts with a Monk command or reaches + # one after a simple shell separator. + if printf '%s' "$payload" | grep -Eq "${boundary}(sudo[[:space:]]+)?(bash|sh|zsh)(\\.exe)?[[:space:]]+-[A-Za-z]*c[[:space:]]+(\\\\?\")?[[:space:]]*([^\";&|]*[;&|][[:space:]]*)?${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + # PowerShell inline commands have the same bypass shape on Windows hosts. + if printf '%s' "$payload" | grep -Eq "${boundary}(sudo[[:space:]]+)?(powershell|powershell\\.exe|pwsh|pwsh\\.exe)[^;&|]*[[:space:]]-(Command|c)[[:space:]]+(\\\\?\")?[[:space:]]*([^\";&|]*[;&|][[:space:]]*)?${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + return 1 +} + +emit_deny() { cat <<'JSON' { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": "Blocked: do not shell out to the `monk` CLI — it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." + "permissionDecisionReason": "Blocked: do not shell out to the `monk` CLI or `monkd` daemon — it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." } } JSON +} + +agent="${MONK_AGENT_PATH:-${MONK_AGENT_INSTALL_DIR:-"$HOME/.monk/bin"}/monk-agent}" +agent_output= +if [ -x "$agent" ]; then + if agent_output="$(printf '%s' "$input" | "$agent" hook block-monk --format claude)"; then + if printf '%s' "$agent_output" | grep -Eq '"permissionDecision"[[:space:]]*:[[:space:]]*"deny"'; then + printf '%s' "$agent_output" + exit 0 + fi + fi +fi + +# Post-filter the helper allow/no-output path and also cover the missing-helper +# fallback path with the same wrapper-aware local matcher. +if should_block_monk_shellout "$input"; then + emit_deny exit 0 fi +if [ -n "$agent_output" ]; then + printf '%s' "$agent_output" +fi + exit 0 diff --git a/plugins/monk/hooks/block-monk.ps1 b/plugins/monk/hooks/block-monk.ps1 index 03c3e46..e228e79 100644 --- a/plugins/monk/hooks/block-monk.ps1 +++ b/plugins/monk/hooks/block-monk.ps1 @@ -1,39 +1,124 @@ -# PreToolUse hook for the Bash tool: block any shell-out to the `monk` CLI. -# Monk owns its own cluster state - running `monk ...` from a shell desyncs it. +# PreToolUse hook for the Bash tool: block any shell-out to the `monk` CLI or +# `monkd` daemon. +# Monk owns its own cluster state - running these from a shell desyncs it. # Use monk-agent MCP tools instead. # # Delegates to `monk-agent hook block-monk` so the logic stays in one place. -# Falls back to native PowerShell if the binary is unavailable. +# Post-filters helper allow/no-output results and falls back to native +# PowerShell if the binary is unavailable. $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. -if (Test-Path $agent) { - & $agent hook block-monk --format claude - exit $LASTEXITCODE +function Read-StandardInputBytes { + $stream = [Console]::OpenStandardInput() + $buffer = New-Object byte[] 8192 + $memory = New-Object System.IO.MemoryStream + try { + while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $memory.Write($buffer, 0, $read) + } + $memory.ToArray() + } finally { + $memory.Dispose() + } +} + +function Invoke-AgentHook { + param([byte[]]$InputBytes) + + $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 + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + [void]$process.Start() + if ($InputBytes.Length -gt 0) { + $process.StandardInput.BaseStream.Write($InputBytes, 0, $InputBytes.Length) + } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + @{ + ExitCode = $process.ExitCode + Stdout = $stdout + Stderr = $stderr + } } -# 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() -try { $command = ($hookInput | ConvertFrom-Json).tool_input.command } catch { exit 0 } -if (-not $command) { exit 0 } +function Test-MonkShellCommand { + param([string]$Command) + + $binary = '(?:(?:[A-Za-z]:\\|~[\\/]|/)(?:[^\s"'';&|(){}]+[\\/])*)?monkd?(?:\.exe)?' + $boundary = '(^|[\r\n;&|`({])\s*' + $prefix = '(?:(?:sudo|command)\s+)*(?:env(?:\s+(?:-\S+|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|''[^'']*''|[^\s]+)))*\s+)?' + $tail = '(\s|$|[;&|`"''\)])' + $quote = '(?:["'']\s*)?' + $inlineLead = '(?:[^"'';&|]*[;&|]\s*)?' + + if ($Command -match ($boundary + $prefix + $binary + $tail)) { return $true } + if ($Command -match ($boundary + '\$\([^\r\n)]*\bwhich\s+monkd?\b[^\r\n)]*\)\s+\S+')) { return $true } + + $shell = '(?:bash|sh|zsh)(?:\.exe)?' + if ($Command -match ($boundary + '(?:sudo\s+)?' + $shell + '\s+-[A-Za-z]*c\s+' + $quote + $inlineLead + $prefix + $binary + $tail)) { + return $true + } + + $powerShell = '(?:powershell|powershell\.exe|pwsh|pwsh\.exe)' + if ($Command -match ($boundary + '(?:sudo\s+)?' + $powerShell + '\b[^\r\n;&|]*\s-(?:Command|c)\s+' + $quote + $inlineLead + $prefix + $binary + $tail)) { + return $true + } -if ($command -match '(^|[\r\n;&|`({])\s*(sudo\s+)?monk(\s|$)') { + return $false +} + +function Write-Deny { @{ hookSpecificOutput = @{ hookEventName = "PreToolUse" permissionDecision = "deny" - permissionDecisionReason = "Blocked: do not shell out to the ``monk`` CLI - it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." + permissionDecisionReason = "Blocked: do not shell out to the ``monk`` CLI or ``monkd`` daemon - it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." } } | ConvertTo-Json -Compress } +$hookBytes = Read-StandardInputBytes +$agentResult = $null + +if (Test-Path $agent) { + $agentResult = Invoke-AgentHook -InputBytes $hookBytes + if ($agentResult["Stdout"] -match '"permissionDecision"\s*:\s*"deny"') { + [Console]::Out.Write($agentResult["Stdout"]) + exit 0 + } + if ($agentResult["ExitCode"] -ne 0) { + if ($agentResult["Stdout"]) { [Console]::Out.Write($agentResult["Stdout"]) } + if ($agentResult["Stderr"]) { [Console]::Error.Write($agentResult["Stderr"]) } + exit $agentResult["ExitCode"] + } +} + +$hookInput = [System.Text.Encoding]::UTF8.GetString($hookBytes) +if ($hookInput.Length -gt 0 -and [int][char]$hookInput[0] -eq 0xFEFF) { + $hookInput = $hookInput.Substring(1) +} + +try { $command = ($hookInput | ConvertFrom-Json).tool_input.command } catch { $command = "" } +if ($command -and (Test-MonkShellCommand -Command $command)) { + Write-Deny + exit 0 +} + +if ($agentResult -and $agentResult["Stdout"]) { + [Console]::Out.Write($agentResult["Stdout"]) +} + exit 0 diff --git a/plugins/monk/hooks/block-monk.sh b/plugins/monk/hooks/block-monk.sh index 3e4cd9a..8488556 100755 --- a/plugins/monk/hooks/block-monk.sh +++ b/plugins/monk/hooks/block-monk.sh @@ -1,13 +1,13 @@ #!/usr/bin/env sh -# PreToolUse hook for the Bash tool: block any shell-out to the `monk` CLI. -# Monk owns its own cluster state — running `monk ...` from a shell desyncs it. +# PreToolUse hook for the Bash tool: block any shell-out to the `monk` CLI or +# `monkd` daemon. Monk owns its own cluster state — running these from a shell desyncs it. # Use monk-agent MCP tools instead. # # The decision is made by `monk-agent hook block-monk` so the only dependency is # the monk-agent binary the plugin already installs. If that binary is somehow # missing we fall back to pure POSIX shell + grep, biased toward BLOCKING: a -# `monk` invocation is still caught with zero tooling, and non-monk commands are -# never blocked. The hook always exits 0 (the deny JSON is the block signal). +# `monk`/`monkd` invocation is still caught with zero tooling, and non-monk +# commands are never blocked. The hook always exits 0 (the deny JSON is the block signal). set -eu @@ -20,28 +20,70 @@ if [ -t 0 ]; then exit 0; fi input="$(cat)" -agent="${MONK_AGENT_PATH:-${MONK_AGENT_INSTALL_DIR:-"$HOME/.monk/bin"}/monk-agent}" -if [ -x "$agent" ]; then - if printf '%s' "$input" | "$agent" hook block-monk --format claude; then - exit 0 +should_block_monk_shellout() { + payload=$1 + boundary='(^|[";&|`({]|\\n|\\r\\n)[[:space:]]*' + monk_bin='((([A-Za-z]:\\\\|~[\\/]|/)([^[:space:]";&|(){}]+[\\/])*)?monkd?(\.exe)?)' + prefix='((sudo|command)[[:space:]]+)*(env([[:space:]]+(-[^[:space:]]+|[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|[^[:space:]]+)))*[[:space:]]+)?' + + # Direct command-position invocations and wrappers such as `command monk`, + # `env monk`, absolute paths, and `monkd`. + if printf '%s' "$payload" | grep -Eq "${boundary}${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + # Command substitution that resolves the binary and then executes it: + # `$(which monk) deploy`. + if printf '%s' "$payload" | grep -Eq "${boundary}\\\$\\([^)]*which[[:space:]]+monkd?[^)]*\\)[[:space:]]+[^[:space:]]"; then + return 0 fi -fi -# Fallback: binary unavailable. Grep the raw hook payload for a `monk` command. -# Matches `monk` only in command position (start, or after a shell separator), -# optional `sudo`, followed by whitespace/quote/end — so `monkey` is not matched. -# False positives only ever BLOCK, never allow, which is the safe direction. -if printf '%s' "$input" | grep -Eq '(^|["[:space:];&|`(])(sudo[[:space:]]+)?monk([[:space:]"]|$)'; then + # Nested shells where the inline script starts with a Monk command or reaches + # one after a simple shell separator. + if printf '%s' "$payload" | grep -Eq "${boundary}(sudo[[:space:]]+)?(bash|sh|zsh)(\\.exe)?[[:space:]]+-[A-Za-z]*c[[:space:]]+(\\\\?\")?[[:space:]]*([^\";&|]*[;&|][[:space:]]*)?${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + # PowerShell inline commands have the same bypass shape on Windows hosts. + if printf '%s' "$payload" | grep -Eq "${boundary}(sudo[[:space:]]+)?(powershell|powershell\\.exe|pwsh|pwsh\\.exe)[^;&|]*[[:space:]]-(Command|c)[[:space:]]+(\\\\?\")?[[:space:]]*([^\";&|]*[;&|][[:space:]]*)?${prefix}${monk_bin}([[:space:]\";&|)]|$)"; then + return 0 + fi + + return 1 +} + +emit_deny() { cat <<'JSON' { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": "Blocked: do not shell out to the `monk` CLI — it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." + "permissionDecisionReason": "Blocked: do not shell out to the `monk` CLI or `monkd` daemon — it desyncs the cluster state Monk manages. Use the monk-agent MCP tools instead." } } JSON +} + +agent="${MONK_AGENT_PATH:-${MONK_AGENT_INSTALL_DIR:-"$HOME/.monk/bin"}/monk-agent}" +agent_output= +if [ -x "$agent" ]; then + if agent_output="$(printf '%s' "$input" | "$agent" hook block-monk --format claude)"; then + if printf '%s' "$agent_output" | grep -Eq '"permissionDecision"[[:space:]]*:[[:space:]]*"deny"'; then + printf '%s' "$agent_output" + exit 0 + fi + fi +fi + +# Post-filter the helper allow/no-output path and also cover the missing-helper +# fallback path with the same wrapper-aware local matcher. +if should_block_monk_shellout "$input"; then + emit_deny exit 0 fi +if [ -n "$agent_output" ]; then + printf '%s' "$agent_output" +fi + exit 0 diff --git a/tests/block-monk-posix-postfilter.sh b/tests/block-monk-posix-postfilter.sh new file mode 100644 index 0000000..bbde6b6 --- /dev/null +++ b/tests/block-monk-posix-postfilter.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env sh +set -eu + +repo_root=${1:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)} +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT + +stub_agent=$tmp_dir/monk-agent +printf '%s\n' '#!/usr/bin/env sh' 'cat >/dev/null' 'exit 0' > "$stub_agent" +chmod +x "$stub_agent" +export MONK_AGENT_PATH=$stub_agent + +assert_denied() { + name=$1 + payload=$2 + hook=$3 + deny_pattern=$4 + + output=$(printf '%s' "$payload" | sh "$hook") + if ! printf '%s' "$output" | grep -Eq "$deny_pattern"; then + printf 'expected denial for %s, got: %s\n' "$name" "$output" >&2 + exit 1 + fi +} + +assert_allowed() { + name=$1 + payload=$2 + hook=$3 + deny_pattern=$4 + + output=$(printf '%s' "$payload" | sh "$hook") + if printf '%s' "$output" | grep -Eq "$deny_pattern"; then + printf 'expected allow for %s, got denial: %s\n' "$name" "$output" >&2 + exit 1 + fi +} + +claude_hook=$repo_root/hooks/block-monk.sh +antigravity_hook=$repo_root/.antigravity-plugin/hooks/block-monk.sh + +claude_deny='"permissionDecision"[[:space:]]*:[[:space:]]*"deny"' +antigravity_deny='"decision"[[:space:]]*:[[:space:]]*"deny"' + +assert_denied direct-monk '{"tool_input":{"command":"monk deploy"}}' "$claude_hook" "$claude_deny" +assert_denied command-wrapper '{"tool_input":{"command":"command monk deploy"}}' "$claude_hook" "$claude_deny" +assert_denied env-wrapper '{"tool_input":{"command":"env monk deploy"}}' "$claude_hook" "$claude_deny" +assert_denied nested-shell '{"tool_input":{"command":"bash -lc \"monk deploy\""}}' "$claude_hook" "$claude_deny" +assert_denied which-substitution '{"tool_input":{"command":"$(which monk) deploy"}}' "$claude_hook" "$claude_deny" +assert_denied absolute-posix '{"tool_input":{"command":"/usr/local/bin/monk deploy"}}' "$claude_hook" "$claude_deny" +assert_denied windows-absolute '{"tool_input":{"command":"C:\\Users\\PC\\.monk\\bin\\monk.exe deploy"}}' "$claude_hook" "$claude_deny" +assert_denied direct-daemon '{"tool_input":{"command":"monkd version"}}' "$claude_hook" "$claude_deny" +assert_denied daemon-separator '{"tool_input":{"command":"echo ok; monkd"}}' "$claude_hook" "$claude_deny" +assert_denied daemon-home '{"tool_input":{"command":"~/.monk/bin/monkd"}}' "$claude_hook" "$claude_deny" +assert_denied powershell-inline '{"tool_input":{"command":"powershell.exe -Command monk deploy"}}' "$claude_hook" "$claude_deny" + +assert_allowed similar-command '{"tool_input":{"command":"monkey deploy"}}' "$claude_hook" "$claude_deny" +assert_allowed data-argument '{"tool_input":{"command":"grep monk README.md"}}' "$claude_hook" "$claude_deny" +assert_allowed echo-data '{"tool_input":{"command":"echo monk"}}' "$claude_hook" "$claude_deny" +assert_allowed nested-data '{"tool_input":{"command":"bash -lc \"grep monk README.md\""}}' "$claude_hook" "$claude_deny" + +assert_denied antigravity-direct-daemon '{"toolCall":{"name":"run_command","args":{"CommandLine":"monkd version"}}}' "$antigravity_hook" "$antigravity_deny" +assert_denied antigravity-command-wrapper '{"toolCall":{"name":"run_command","args":{"CommandLine":"command monk deploy"}}}' "$antigravity_hook" "$antigravity_deny" +assert_denied antigravity-nested-shell '{"toolCall":{"name":"run_command","args":{"CommandLine":"bash -lc \"monk deploy\""}}}' "$antigravity_hook" "$antigravity_deny" +assert_denied antigravity-powershell-inline '{"toolCall":{"name":"run_command","args":{"CommandLine":"powershell.exe -Command monkd"}}}' "$antigravity_hook" "$antigravity_deny" +assert_allowed antigravity-data-argument '{"toolCall":{"name":"run_command","args":{"CommandLine":"grep monk README.md"}}}' "$antigravity_hook" "$antigravity_deny" + +printf 'POSIX block-monk post-filter tests passed.\n' diff --git a/tests/block-monk-windows.ps1 b/tests/block-monk-windows.ps1 index d92327d..62ed43d 100644 --- a/tests/block-monk-windows.ps1 +++ b/tests/block-monk-windows.ps1 @@ -12,12 +12,24 @@ $PreviousPath = $env:Path $Cases = @( @{ Name = "direct"; Command = "monk deploy"; Denied = $true }, + @{ Name = "direct-daemon"; Command = "monkd version"; Denied = $true }, @{ Name = "newline"; Command = "echo ok`nmonk deploy"; Denied = $true }, @{ Name = "crlf"; Command = "echo ok`r`nmonk deploy"; Denied = $true }, @{ Name = "brace"; Command = "{ monk deploy; }"; Denied = $true }, @{ Name = "newline-sudo"; Command = "echo ok`nsudo monk deploy"; Denied = $true }, + @{ Name = "separator-daemon"; Command = "echo ok; monkd"; Denied = $true }, + @{ Name = "command-wrapper"; Command = "command monk deploy"; Denied = $true }, + @{ Name = "env-wrapper"; Command = "env monk deploy"; Denied = $true }, + @{ Name = "nested-bash"; Command = 'bash -lc "monk deploy"'; Denied = $true }, + @{ Name = "which-substitution"; Command = '$(which monk) deploy'; Denied = $true }, + @{ Name = "absolute-posix"; Command = "/usr/local/bin/monk deploy"; Denied = $true }, + @{ Name = "absolute-home-daemon"; Command = "~/.monk/bin/monkd"; Denied = $true }, + @{ Name = "absolute-windows"; Command = "C:\Users\PC\.monk\bin\monk.exe deploy"; Denied = $true }, + @{ Name = "powershell-inline"; Command = "powershell.exe -Command monk deploy"; Denied = $true }, @{ Name = "similar-command"; Command = "monkey deploy"; Denied = $false }, - @{ Name = "argument"; Command = "grep monk README.md"; Denied = $false } + @{ Name = "argument"; Command = "grep monk README.md"; Denied = $false }, + @{ Name = "echo-data"; Command = "echo monk"; Denied = $false }, + @{ Name = "nested-data"; Command = 'bash -lc "grep monk README.md"'; Denied = $false } ) function Assert-HookCases {