# meathook Claude Code hook, Windows edition — the audited surface (spec §11). # # EVERYTHING this script can send is listed here (spec §8 allowlist): # event type, event_id, occurred_at, session_key, # machine / project / repo / worktree / branch labels, # harness name, model name, tool name (on tool_error), # coalesced activity count. # It NEVER sends: file contents, diffs, prompt text, model output, command # arguments, error text, environment variables. # # TWO opt-in exceptions (spec §8 verbosity), both off by default and both # passed through the Scrub() below — read it before enabling either: # "prompt_summaries": "true" — the first substantial prompt of a session # is sent as its summary (UserPromptSubmit case). # "reply_summaries": "true" — the agent's closing message replaces the # summary at each turn end (Stop case). # # This is the line-for-line counterpart of meathook-hook.sh; the two must stay # behaviourally identical, and a test pins that they emit the same wire body # for the same input. Wire-up: every hook invokes this script with the hook # name as argument 1 and the hook JSON on stdin (settings-snippet-windows.json). # Targets Windows PowerShell 5.1 — no dependencies beyond git. param([string]$Hook = '') $ErrorActionPreference = 'Stop' # 5.1 negotiates TLS 1.0 by default on older builds, which meathook.ai refuses. if ($PSVersionTable.PSEdition -eq 'Desktop') { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } $Stdin = [Console]::In.ReadToEnd() # Config: env vars beat the per-worktree file, which beats the global file. $ProjDir = $env:CLAUDE_PROJECT_DIR if (-not $ProjDir) { $ProjDir = $PWD.Path } $HomeDir = $env:USERPROFILE if (-not $HomeDir) { $HomeDir = $HOME } function Read-Conf([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return $null } try { return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json) } catch { return $null } } $ProjConf = Read-Conf (Join-Path $ProjDir '.meathook.json') $UserConf = Read-Conf (Join-Path $HomeDir '.meathook\config.json') function Cfg([string]$Key) { foreach ($c in @($ProjConf, $UserConf)) { if ($c -and $c.PSObject.Properties.Name -contains $Key) { $v = [string]$c.$Key if ($v) { return $v } } } return '' } $Url = $env:MEATHOOK_URL; if (-not $Url) { $Url = Cfg 'url' } $Token = $env:MEATHOOK_TOKEN; if (-not $Token) { $Token = Cfg 'token' } if (-not $Url -or -not $Token) { exit 0 } # not configured: never break the agent # A real parser, unlike the shell version's first-match grep — so a nested # tool_input cannot smuggle in a fake session_id; only genuine top-level keys # are ever read. try { $In = $Stdin | ConvertFrom-Json } catch { exit 0 } function Jget([string]$Key) { if ($In -and $In.PSObject.Properties.Name -contains $Key) { return [string]$In.$Key } return '' } $SessionId = Jget 'session_id' if (-not $SessionId) { exit 0 } # The complete transformation applied to any opt-in text fragment before it # can leave the machine (§8): escape sequences and whitespace runs become # single spaces, quotes and backslashes are dropped, unbroken token-shaped # runs of 28+ chars are redacted, 140-char cap. function Scrub([string]$Text) { if (-not $Text) { return '' } $s = $Text -replace '\\[nrt]', ' ' -replace '[\r\n\t]', ' ' $s = $s -replace '["\\]', '' $s = $s -replace '[A-Za-z0-9+/_-]{28,}', '[redacted]' $s = $s -replace ' +', ' ' if ($s.Length -gt 140) { $s = $s.Substring(0, 140) } return $s } $StateDir = Join-Path $HomeDir '.meathook\state' New-Item -ItemType Directory -Force -Path $StateDir | Out-Null $State = Join-Path $StateDir $SessionId function Drop([string]$Suffix) { Remove-Item -LiteralPath "$State$Suffix" -Force -ErrorAction SilentlyContinue } $Model = '' $Type = '' $Payload = '{}' switch ($Hook) { 'SessionStart' { $Type = 'session_start' $Model = Jget 'model' Drop '.blocked'; Drop '.last'; Drop '.count' Get-ChildItem -LiteralPath $StateDir -File -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } | Remove-Item -Force -ErrorAction SilentlyContinue } { $_ -in 'PostToolUse', 'SubagentStop' } { if (Test-Path -LiteralPath "$State.blocked") { # No permission_resolved hook exists; resumed activity proves it (§4). Drop '.blocked' $Type = 'permission_resolved' } else { # Coalesce the heartbeat: at most one activity event per 30 s (§3). # Not Get-Date -UFormat %s: that returns a culture-formatted string # and misparses where the decimal separator is a comma. $now = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $last = 0 if (Test-Path -LiteralPath "$State.last") { [int]::TryParse((Get-Content -LiteralPath "$State.last" -Raw).Trim(), [ref]$last) | Out-Null } $count = 0 if (Test-Path -LiteralPath "$State.count") { [int]::TryParse((Get-Content -LiteralPath "$State.count" -Raw).Trim(), [ref]$count) | Out-Null } if (($now - $last) -lt 30) { Set-Content -LiteralPath "$State.count" -Value ($count + 1) -NoNewline exit 0 } Set-Content -LiteralPath "$State.last" -Value $now -NoNewline Drop '.count' $Type = 'activity' $Payload = '{"count":' + ($count + 1) + '}' } } 'PostToolUseFailure' { Drop '.blocked' $Type = 'tool_error' $Payload = '{"tool":"' + ((Jget 'tool_name') -replace '["\\]', '') + '"}' } 'UserPromptSubmit' { # OPT-IN ONLY (§8): prompt text is default-excluded. When enabled, the # FIRST substantial prompt of a session becomes its summary via Scrub(); # later prompts never overwrite it. First prompts state the task, # follow-ups are conversation ("yes", "try again") that would clobber it. if ((Cfg 'prompt_summaries') -ne 'true') { exit 0 } if (Test-Path -LiteralPath "$State.summary") { exit 0 } $summary = Scrub (Jget 'prompt') if ($summary.Length -lt 8) { exit 0 } # "hi" must not claim the slot New-Item -ItemType File -Force -Path "$State.summary" | Out-Null $Type = 'activity' $Payload = '{"summary":"' + $summary + '"}' } 'Notification' { $nt = Jget 'notification_type' if (-not $nt) { # Older harness versions: only the human-readable message exists. $msg = Jget 'message' if ($msg -like '*permission*') { $nt = 'permission_prompt' } elseif ($msg -like '*waiting*') { $nt = 'idle_prompt' } } switch ($nt) { 'permission_prompt' { New-Item -ItemType File -Force -Path "$State.blocked" | Out-Null; $Type = 'permission_requested' } 'idle_prompt' { $Type = 'notification' } default { exit 0 } } } 'Stop' { Drop '.blocked' $Type = 'stop' # OPT-IN ONLY (§8): model output is default-excluded. When enabled, the # agent's closing message — its own account of what it just did or needs # — replaces the summary, through the same Scrub(). if ((Cfg 'reply_summaries') -eq 'true') { $summary = Scrub (Jget 'last_assistant_message') if ($summary.Length -ge 8) { $Payload = '{"summary":"' + $summary + '"}' } } } 'SessionEnd' { $Type = 'session_end' Drop '.blocked'; Drop '.last'; Drop '.count' } default { exit 0 } } # Labels. Sanitised so they can be interpolated into JSON verbatim. function Clean([string]$s) { return ($s -replace '["\\]', '') } function GitLine([string[]]$GitArgs) { try { $out = & git -C $ProjDir @GitArgs 2>$null if ($LASTEXITCODE -eq 0 -and $out) { return ([string]($out | Select-Object -First 1)).Trim() } } catch { } return '' } $Machine = Cfg 'machine' if (-not $Machine) { $Machine = $env:COMPUTERNAME } if (-not $Machine) { $Machine = [System.Net.Dns]::GetHostName() } $TopLevel = GitLine @('rev-parse', '--show-toplevel') $Branch = GitLine @('rev-parse', '--abbrev-ref', 'HEAD') $Repo = GitLine @('remote', 'get-url', 'origin') if ($Repo) { $Repo = $Repo -replace '\.git$', '' if ($Repo -match '([^/:]+/[^/]+)$') { $Repo = $Matches[1] } } $Worktree = '' if ($TopLevel) { $Worktree = Split-Path -Leaf $TopLevel } $Project = Cfg 'project' if (-not $Project -and $Repo) { $Project = $Repo.Split('/')[-1] } if (-not $Project) { $Project = Split-Path -Leaf $ProjDir } # Stable across restarts: resume keeps the harness session id (§3). $sha = [System.Security.Cryptography.SHA256]::Create() $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("$Machine|$ProjDir|$SessionId")) $SessionKey = (($bytes | ForEach-Object { $_.ToString('x2') }) -join '').Substring(0, 32) # What meathook-reap.ps1 reads: the sessions this machine believes are open, # and the labels to close them under. Written on every event, not just # SessionStart, so a session that had hooks wired mid-flight is reapable too, # and so the file's mtime tracks the session rather than ageing out under the # seven-day sweep above. No hook fires when a terminal is closed, which is the # whole reason the reaper exists (§11). if ($Type -eq 'session_end') { Drop '.open' } else { Set-Content -LiteralPath "$State.open" -Value "$SessionKey`n$Project`n" -NoNewline } function Field([string]$Name, [string]$Value) { if ($Value) { return '"' + $Name + '":"' + (Clean $Value) + '",' } return '' } $Body = '{"v":1,' + '"event_id":"' + [guid]::NewGuid().ToString() + '",' + '"session_key":"' + $SessionKey + '",' + '"occurred_at":"' + (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + '",' + (Field 'machine' $Machine) + (Field 'project' $Project) + (Field 'repo' $Repo) + (Field 'worktree' $Worktree) + (Field 'branch' $Branch) + (Field 'model' $Model) + '"harness":"claude-code","type":"' + $Type + '","payload":' + $Payload + '}' try { Invoke-RestMethod -Method Post -Uri "$Url/v1/events" -TimeoutSec 3 ` -Headers @{ Authorization = "Bearer $Token" } ` -ContentType 'application/json' -Body ([Text.Encoding]::UTF8.GetBytes($Body)) | Out-Null } catch { } # a board that is down must never break the agent exit 0