# meathook enrollment, Windows edition — device code flow (spec §6.4). # # Adds this machine to a meathook board without copying a secret between # machines. The token is minted HERE, from the OS CSPRNG, and never leaves: # the server is sent only its sha256 hash, and the reply that says "approved" # carries nothing secret. So there is no value to paste into a terminal, no # value in a shell history, and none in an agent's transcript. # # 1. this script mints a token and asks the server for a short code # 2. you type that code into https:///enroll, signed in as operator # 3. you approve; the server registers the hash as an ingest token # 4. this script writes ~\.meathook\config.json and stops # # It does NOT edit your Claude Code settings — it prints that command for you # to run. Targets Windows PowerShell 5.1; no dependencies. # # Usage: powershell -ExecutionPolicy Bypass -File meathook-enroll.ps1 [-Url URL] [-Machine LABEL] [-Force] param( [string]$Url = $(if ($env:MEATHOOK_URL) { $env:MEATHOOK_URL } else { 'https://meathook.ai' }), [string]$Machine = '', [switch]$Force ) $ErrorActionPreference = 'Stop' function Fail([string]$Message) { [Console]::Error.WriteLine($Message); exit 1 } if ($PSVersionTable.PSEdition -eq 'Desktop') { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } $Url = $Url.TrimEnd('/') if (-not $Machine) { $Machine = $env:COMPUTERNAME } if (-not $Machine) { $Machine = [System.Net.Dns]::GetHostName() } if (-not ($Url -match '^https://' -or $Url -match '^http://(localhost|127\.0\.0\.1)')) { [Console]::Error.WriteLine("refusing: $Url is not https - the ingest token would travel in clear text") exit 2 } $HomeDir = $env:USERPROFILE if (-not $HomeDir) { $HomeDir = $HOME } $MeathookDir = Join-Path $HomeDir '.meathook' $Config = Join-Path $MeathookDir 'config.json' if ((Test-Path -LiteralPath $Config) -and -not $Force) { [Console]::Error.WriteLine("$Config already exists. Re-run with -Force to replace it.") exit 2 } function Sha256Hex([string]$Text) { $sha = [System.Security.Cryptography.SHA256]::Create() $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Text)) return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '') } # The token exists only in this process and, at the end, in a config file # readable by this user alone. $raw = New-Object byte[] 20 [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($raw) $Token = 'mh_ing_' + (($raw | ForEach-Object { $_.ToString('x2') }) -join '') $Hash = Sha256Hex $Token $Prefix = $Token.Substring(0, 13) $startBody = @{ token_hash = $Hash; token_prefix = $Prefix; machine = $Machine } | ConvertTo-Json -Compress try { $start = Invoke-RestMethod -Method Post -Uri "$Url/v1/enroll/device" -TimeoutSec 10 ` -ContentType 'application/json' -Body $startBody } catch { Fail "enrollment request failed: $_" } if (-not $start.user_code -or -not $start.device_code) { Fail 'enrollment request failed: unexpected reply' } Write-Host '' Write-Host " Go to: $($start.verification_uri)" Write-Host " Enter: $($start.user_code)" Write-Host " As: $Machine" Write-Host '' Write-Host 'Waiting for approval (ten minutes, Ctrl-C to give up)...' # The server tells us how often to ask and how long the code lives; don't # out-guess it, and don't hammer a rate-limited public endpoint. $interval = 3; if ($start.interval) { $interval = [int]$start.interval } $expires = 600; if ($start.expires_in) { $expires = [int]$start.expires_in } $pollBody = @{ device_code = $start.device_code } | ConvertTo-Json -Compress $waited = 0 $poll = $null $approved = $false while ($waited -lt $expires) { Start-Sleep -Seconds $interval $waited += $interval try { $poll = Invoke-RestMethod -Method Post -Uri "$Url/v1/enroll/poll" -TimeoutSec 10 ` -ContentType 'application/json' -Body $pollBody } catch { Fail "poll failed: $_" } # if/else rather than switch: `break` inside a PowerShell switch leaves the # switch, not the loop, which is a classic way to poll forever. if ($poll.status -eq 'approved') { $approved = $true; break } elseif ($poll.status -eq 'denied') { Fail 'Denied by the operator. Nothing was installed.' } elseif ($poll.status -eq 'expired') { Fail 'The code expired. Run this again.' } elseif ($poll.status -ne 'pending') { Fail "Unexpected reply: $($poll.status)" } } if (-not $approved) { Fail 'Timed out waiting for approval. Run this again.' } # The operator may have corrected the label; the token record is authoritative # for it either way (§6.1), so record what the server actually decided. $approvedMachine = $Machine if ($poll.machine) { $approvedMachine = [string]$poll.machine } # The Windows equivalent of chmod 600: break inheritance and leave exactly one # ACE, for the user who ran this. pwsh also runs on Linux and macOS, so fall # back to a real chmod there rather than leaving the token world-readable. function Restrict-ToCurrentUser([string]$Path) { $onWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or $IsWindows try { if ($onWindows) { $acl = Get-Acl -LiteralPath $Path $acl.SetAccessRuleProtection($true, $false) foreach ($rule in @($acl.Access)) { $acl.RemoveAccessRule($rule) | Out-Null } $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( $me, 'FullControl', 'Allow'))) Set-Acl -LiteralPath $Path -AclObject $acl } else { & chmod 600 $Path if ($LASTEXITCODE -ne 0) { return $false } } return $true } catch { return $false } } New-Item -ItemType Directory -Force -Path $MeathookDir | Out-Null # Lock the file down while it is still EMPTY: writing the token first would # leave it readable by anyone for as long as the permission call takes. New-Item -ItemType File -Force -Path $Config | Out-Null $restricted = Restrict-ToCurrentUser $Config $json = @" { "url": "$Url", "token": "$Token", "machine": "$approvedMachine" } "@ Set-Content -LiteralPath $Config -Value $json -Encoding UTF8 $permNote = 'this user only' if (-not $restricted) { $permNote = 'PERMISSIONS NOT RESTRICTED - tighten them yourself' Write-Warning "Could not restrict permissions on $Config. It holds your ingest token." } Invoke-WebRequest -Uri "$Url/hook.ps1" -TimeoutSec 10 -UseBasicParsing ` -OutFile (Join-Path $MeathookDir 'meathook-hook.ps1') | Out-Null Invoke-WebRequest -Uri "$Url/reap.ps1" -TimeoutSec 10 -UseBasicParsing ` -OutFile (Join-Path $MeathookDir 'meathook-reap.ps1') | Out-Null Write-Host @" Approved. This machine is "$approvedMachine" on the board. Wrote $Config ($permNote), $MeathookDir\meathook-hook.ps1 and $MeathookDir\meathook-reap.ps1. Read the hook script before you wire it in - the allowlist at the top is the complete list of what ever leaves this machine: notepad $MeathookDir\meathook-hook.ps1 Two steps are left, and both change your machine's configuration, so both are yours to run. First, register the hooks with Claude Code: `$s = "`$env:USERPROFILE\.claude\settings.json" `$new = Invoke-RestMethod -Uri $Url/claude-hooks-windows.json if (Test-Path `$s) { `$cur = Get-Content `$s -Raw | ConvertFrom-Json `$cur | Add-Member -NotePropertyName hooks -NotePropertyValue `$new.hooks -Force `$cur | ConvertTo-Json -Depth 10 | Set-Content `$s } else { New-Item -ItemType Directory -Force -Path (Split-Path `$s) | Out-Null `$new | ConvertTo-Json -Depth 10 | Set-Content `$s } Hooks load at session start, so start a new Claude Code session to appear. Second, schedule the reaper. Hooks cannot see a closed terminal - no hook fires when the window goes away - so without this, sessions you close stay on the board until the deadman timers catch them, and the ones sitting in idle or blocked never do: powershell -NoProfile -ExecutionPolicy Bypass -File $MeathookDir\meathook-reap.ps1 -Install It installs a task that runs every minute, and sends nothing at all if it cannot ask the harness what is running. See for yourself first: powershell -NoProfile -ExecutionPolicy Bypass -File $MeathookDir\meathook-reap.ps1 -DryRun "@