Payload/claude/hooks/invoke-claude-hook.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Run a harness hook and return the Claude host decision. .DESCRIPTION Project Claude settings call this adapter. It runs the same harness script the other hosts use, then returns the decision Claude enforces: stdout {} when a tool is allowed, exit 2 with a stderr reason when a tool is denied, one BLOCKED stop, and a plain session line for an open turn. After a paired reviewer, the parent reminder is PostToolUse additionalContext on the Agent tool. SubagentStop stays quiet so the finished reviewer is not the audience. Direct script output is not host use. .PARAMETER Hook Harness script stem: reviewer-stop, harness-pretooluse-guard, harness-stop-gate, or harness-session-start. .PARAMETER InputJson Hook payload. When omitted, the script reads stdin. .PARAMETER InputJsonFile Path to a payload file. Used by tests so the JSON is not re-quoted. .PARAMETER RepoRoot Repository root passed to the harness script. .PARAMETER FeatureRoot Feature folder passed to the guard or session-start script. .PARAMETER SkipJournal Forwarded to the tool-use guard for an isolated feature root under TEMP. .PARAMETER ShowHelp Print operator help and exit. .EXAMPLE pwsh -File ./.claude/hooks/invoke-claude-hook.ps1 -Hook harness-stop-gate .EXAMPLE pwsh -File ./.claude/hooks/invoke-claude-hook.ps1 -Hook harness-pretooluse-guard -InputJsonFile .\payload.json #> [CmdletBinding(DefaultParameterSetName = 'Run')] param( [Parameter(Mandatory = $true, ParameterSetName = 'Run')] [ValidateSet('reviewer-stop', 'harness-pretooluse-guard', 'harness-stop-gate', 'harness-session-start')] [string]$Hook, [string]$InputJson = '', [string]$InputJsonFile = '', [string]$RepoRoot = '', [string]$FeatureRoot = '', [switch]$SkipJournal, [Parameter(ParameterSetName = 'Help')] [Alias('h', 'help')] [switch]$ShowHelp ) function Show-ClaudeHookHelp { Write-Output 'Translates a harness hook into the Claude allow, deny, stop, reminder, or session-start decision.' Write-Output 'Example: pwsh -File ./.claude/hooks/invoke-claude-hook.ps1 -Hook harness-stop-gate' } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($ShowHelp) { Show-ClaudeHookHelp exit 0 } function Invoke-BoundHookProcess { param( [string]$ScriptPath = '', [string]$Stdin = '', [string[]]$ScriptArguments = @(), [string]$CommandText = '' ) $psi = [System.Diagnostics.ProcessStartInfo]::new() $psi.FileName = (Get-Command pwsh -ErrorAction Stop).Source $psi.UseShellExecute = $false $psi.RedirectStandardInput = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true $psi.ArgumentList.Add('-NoProfile') $psi.ArgumentList.Add('-ExecutionPolicy') $psi.ArgumentList.Add('Bypass') if (-not [string]::IsNullOrWhiteSpace($CommandText)) { $psi.ArgumentList.Add('-Command') $psi.ArgumentList.Add($CommandText) } else { $psi.ArgumentList.Add('-File') $psi.ArgumentList.Add($ScriptPath) foreach ($argument in @($ScriptArguments)) { if (-not [string]::IsNullOrWhiteSpace($argument)) { $psi.ArgumentList.Add($argument) } } } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $psi [void]$process.Start() if ($null -eq $Stdin) { $Stdin = '' } $process.StandardInput.Write($Stdin) $process.StandardInput.Close() $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() [void]$process.WaitForExit() return [pscustomobject]@{ ExitCode = $process.ExitCode Stdout = $stdoutTask.Result Stderr = $stderrTask.Result } } function Get-ClaudePropertyValue { param( $Object, [string]$Name ) if ($null -eq $Object -or $Object -isnot [pscustomobject] -or [string]::IsNullOrWhiteSpace($Name)) { return $null } $prop = $Object.PSObject.Properties[$Name] if ($null -eq $prop) { return $null } return $prop.Value } # Blank text, invalid JSON, arrays, and scalars are not a hook object. # Callers must stop before they treat that result as an allow, a reminder, or a skipped block. function ConvertFrom-ClaudeHookJson { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } try { $parsed = $Text | ConvertFrom-Json } catch { return $null } if ($parsed -isnot [pscustomobject]) { return $null } return $parsed } function Get-GuardDecision { param([AllowEmptyString()][string]$Text) $reason = 'The tool call was denied.' $parsed = ConvertFrom-ClaudeHookJson -Text $Text if ($null -eq $parsed) { return [pscustomobject]@{ Parsed = $false; Denied = $false; Reason = $reason } } $denied = $false foreach ($name in @('permission', 'permissionDecision')) { $value = Get-ClaudePropertyValue -Object $parsed -Name $name if ($value -is [string] -and $value -eq 'deny') { $denied = $true } } $nested = Get-ClaudePropertyValue -Object $parsed -Name 'hookSpecificOutput' if ($nested -is [pscustomobject]) { $nestedDecision = Get-ClaudePropertyValue -Object $nested -Name 'permissionDecision' if ($nestedDecision -is [string] -and $nestedDecision -eq 'deny') { $denied = $true } $nestedReason = Get-ClaudePropertyValue -Object $nested -Name 'permissionDecisionReason' if ($nestedReason -is [string] -and -not [string]::IsNullOrWhiteSpace($nestedReason)) { $reason = $nestedReason } } foreach ($name in @('agent_message', 'permissionDecisionReason', 'user_message')) { $value = Get-ClaudePropertyValue -Object $parsed -Name $name if ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) { $reason = $value break } } return [pscustomobject]@{ Parsed = $true; Denied = $denied; Reason = $reason } } function Test-StopHookAlreadyActive { param([AllowEmptyString()][string]$Text) $parsed = ConvertFrom-ClaudeHookJson -Text $Text if ($null -eq $parsed) { return $false } $active = Get-ClaudePropertyValue -Object $parsed -Name 'stop_hook_active' # Only a real boolean true releases the second stop. A missing field or the # string "true" still runs the gate, so a bad payload cannot skip BLOCKED. return ($active -is [bool] -and $active -eq $true) } function Get-ClaudeParentReminder { param([AllowEmptyString()][string]$Text) $quiet = [pscustomobject]@{ SpeakToParent = $false; ReviewerType = '' } $parsed = ConvertFrom-ClaudeHookJson -Text $Text if ($null -eq $parsed) { return $quiet } $eventName = Get-ClaudePropertyValue -Object $parsed -Name 'hook_event_name' if ($eventName -isnot [string]) { $eventName = '' } # SubagentStop additionalContext continues the finished reviewer. The parent # hears the reminder on PostToolUse for the Agent tool, and only when this # process is not already inside that reviewer. if ($eventName -eq 'SubagentStop') { return $quiet } $agentId = Get-ClaudePropertyValue -Object $parsed -Name 'agent_id' if ($agentId -is [string] -and -not [string]::IsNullOrWhiteSpace($agentId)) { return $quiet } $toolName = Get-ClaudePropertyValue -Object $parsed -Name 'tool_name' if ($eventName -ne 'PostToolUse' -or $toolName -isnot [string] -or $toolName -ne 'Agent') { return $quiet } $reviewerType = '' $toolInput = Get-ClaudePropertyValue -Object $parsed -Name 'tool_input' if ($toolInput -is [pscustomobject]) { $fromInput = Get-ClaudePropertyValue -Object $toolInput -Name 'subagent_type' if ($fromInput -is [string]) { $reviewerType = $fromInput } } if ([string]::IsNullOrWhiteSpace($reviewerType)) { $fromPayload = Get-ClaudePropertyValue -Object $parsed -Name 'subagent_type' if ($fromPayload -is [string]) { $reviewerType = $fromPayload } } if ([string]::IsNullOrWhiteSpace($reviewerType) -or $reviewerType -notmatch 'reviewer') { return $quiet } return [pscustomobject]@{ SpeakToParent = $true; ReviewerType = $reviewerType } } $installRoot = (Resolve-Path (Join-Path $PSScriptRoot (Join-Path '..' '..'))).Path if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = $installRoot } $payload = '' if (-not [string]::IsNullOrWhiteSpace($InputJsonFile)) { $payload = Get-Content -LiteralPath $InputJsonFile -Raw } elseif (-not [string]::IsNullOrWhiteSpace($InputJson)) { $payload = $InputJson } else { $payload = [Console]::In.ReadToEnd() } function Invoke-ClaudePreToolUseGuard { param( [string]$InstallRoot, [string]$RepoRoot, [string]$FeatureRoot, [switch]$SkipJournal, [AllowEmptyString()][string]$Payload ) $parsed = ConvertFrom-ClaudeHookJson -Text $Payload $toolName = Get-ClaudePropertyValue -Object $parsed -Name 'tool_name' if ($null -eq $parsed -or $toolName -isnot [string] -or [string]::IsNullOrWhiteSpace($toolName)) { [Console]::Error.WriteLine('The tool call was denied.') exit 2 } $stdin = $Payload if ($toolName -in @('MultiEdit', 'NotebookEdit')) { $parsed.tool_name = 'Edit' $stdin = $parsed | ConvertTo-Json -Compress -Depth 8 } $scriptPath = Join-Path $InstallRoot 'scripts\kaden\harness\harness-pretooluse-guard.ps1' $scriptArguments = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace($RepoRoot)) { $scriptArguments.Add('-RepoRoot') $scriptArguments.Add($RepoRoot) } if (-not [string]::IsNullOrWhiteSpace($FeatureRoot)) { $scriptArguments.Add('-FeatureRoot') $scriptArguments.Add($FeatureRoot) } if ($SkipJournal) { $scriptArguments.Add('-SkipJournal') } $result = Invoke-BoundHookProcess -ScriptPath $scriptPath -Stdin $stdin -ScriptArguments @($scriptArguments.ToArray()) $stdout = ([string]$result.Stdout).Trim() $childError = ([string]$result.Stderr).Trim() if ($result.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($stdout)) { $reason = $childError if ([string]::IsNullOrWhiteSpace($reason)) { $reason = 'The tool call was denied.' } [Console]::Error.WriteLine($reason) exit 2 } $decision = Get-GuardDecision -Text $stdout if (-not $decision.Parsed -or $decision.Denied) { [Console]::Error.WriteLine($decision.Reason) exit 2 } # {} lets Claude apply permissions.ask. permissionDecision allow would skip those prompts. Write-Output '{}' exit 0 } function Invoke-ClaudeStopGate { param( [string]$InstallRoot, [string]$RepoRoot, [AllowEmptyString()][string]$Payload ) # Claude sets stop_hook_active on the attempt that follows a block. # Exit 0 here so that attempt does not print BLOCKED again. # Invalid JSON is not an active stop, so the gate still runs. if (Test-StopHookAlreadyActive -Text $Payload) { exit 0 } $scriptPath = Join-Path $InstallRoot 'scripts\kaden\harness\harness-stop-gate.ps1' # -Command, not -File. A redirected -File run stops on the first non-terminating # shape-repair error and never prints BLOCKED. $quotedScript = $scriptPath.Replace("'", "''") $quotedRoot = $RepoRoot.Replace("'", "''") $commandText = "& '$quotedScript' -RepoRoot '$quotedRoot'" $result = Invoke-BoundHookProcess -CommandText $commandText $stdout = ([string]$result.Stdout).Trim() if ($result.ExitCode -eq 0) { exit 0 } $message = $stdout $childError = '' if ($null -ne $result.Stderr) { $childError = ([string]$result.Stderr).Trim() } if (-not [string]::IsNullOrWhiteSpace($childError)) { if ([string]::IsNullOrWhiteSpace($message)) { $message = $childError } else { $message = $message + [Environment]::NewLine + $childError } } if (-not [string]::IsNullOrWhiteSpace($message)) { Write-Output $message [Console]::Error.WriteLine($message) } exit 2 } function Invoke-ClaudeSessionStart { param( [string]$InstallRoot, [string]$RepoRoot, [string]$FeatureRoot, [AllowEmptyString()][string]$Payload ) $scriptPath = Join-Path $InstallRoot 'scripts\kaden\harness\harness-session-start.ps1' $scriptArguments = [System.Collections.Generic.List[string]]::new() $scriptArguments.Add('-RepoRoot') $scriptArguments.Add($RepoRoot) if (-not [string]::IsNullOrWhiteSpace($FeatureRoot)) { $scriptArguments.Add('-FeatureRoot') $scriptArguments.Add($FeatureRoot) } $result = Invoke-BoundHookProcess -ScriptPath $scriptPath -Stdin $Payload -ScriptArguments @($scriptArguments.ToArray()) $stdout = ([string]$result.Stdout).Trim() if ($result.ExitCode -ne 0) { $childError = '' if ($null -ne $result.Stderr) { $childError = ([string]$result.Stderr).Trim() } if ([string]::IsNullOrWhiteSpace($childError)) { $childError = 'Session start did not finish.' } [Console]::Error.WriteLine($childError) exit 2 } $parsed = ConvertFrom-ClaudeHookJson -Text $stdout $context = Get-ClaudePropertyValue -Object $parsed -Name 'additionalContext' if ($context -is [string] -and -not [string]::IsNullOrWhiteSpace($context)) { Write-Output $context } exit 0 } function Invoke-ClaudeReviewerStop { param( [string]$InstallRoot, [AllowEmptyString()][string]$Payload ) $parentReminder = Get-ClaudeParentReminder -Text $Payload if (-not $parentReminder.SpeakToParent) { exit 0 } $scriptPath = Join-Path $InstallRoot '.cursor\hooks\reviewer-stop.ps1' $normalized = @{ subagent_type = $parentReminder.ReviewerType } | ConvertTo-Json -Compress $result = Invoke-BoundHookProcess -ScriptPath $scriptPath -Stdin $normalized -ScriptArguments @() $stdout = ([string]$result.Stdout).Trim() if ($result.ExitCode -ne 0) { [Console]::Error.WriteLine('The reviewer finish did not return a parent reminder.') exit 2 } $parsed = ConvertFrom-ClaudeHookJson -Text $stdout $followup = Get-ClaudePropertyValue -Object $parsed -Name 'followup_message' if ($followup -isnot [string] -or [string]::IsNullOrWhiteSpace($followup)) { exit 0 } $reminder = [pscustomobject]@{ hookSpecificOutput = [pscustomobject]@{ hookEventName = 'PostToolUse' additionalContext = $followup } } Write-Output ($reminder | ConvertTo-Json -Compress -Depth 5) exit 0 } switch ($Hook) { 'harness-pretooluse-guard' { Invoke-ClaudePreToolUseGuard -InstallRoot $installRoot -RepoRoot $RepoRoot -FeatureRoot $FeatureRoot -SkipJournal:$SkipJournal -Payload $payload } 'harness-stop-gate' { Invoke-ClaudeStopGate -InstallRoot $installRoot -RepoRoot $RepoRoot -Payload $payload } 'harness-session-start' { Invoke-ClaudeSessionStart -InstallRoot $installRoot -RepoRoot $RepoRoot -FeatureRoot $FeatureRoot -Payload $payload } 'reviewer-stop' { Invoke-ClaudeReviewerStop -InstallRoot $installRoot -Payload $payload } } |