com.github.copilot/hooks/scripts/Add-RemoteGrant.ps1
|
<#
.SYNOPSIS UserPromptSubmit hook that records a Remote grant when the user's own prompt carries a Grant marker. .DESCRIPTION Block-RemoteMutation blocks every push and every mutating GitHub CLI call. A hook cannot ask the user instead: the Copilot Agent Host approves a hook ask itself under Allow all and chat.tools.global.autoApprove, so the guard would turn into a silent allow there (Decision 0016, revision 2026-10-12). The authorization therefore travels in the user's prompt. A prompt that contains [[allow-remote: git push, gh pr create]] as plain text makes this hook write a Remote grant for that session: each operation the marker names may run once, an operation followed by x2 twice (at most 20). The guard consumes the grant. The grant ends with the user's next prompt, which this hook always begins by removing the previous grant of the session, and after 15 minutes at the latest. A marker inside an inline code span or a fenced code block is ignored, so quoting the documentation grants nothing. Operations the guard does not list are ignored, and the destructive local blocks (git reset --hard, git clean -f, --no-verify) are not listed, so no marker lifts them. The grant file is plain JSON under the per-user application data folder: schema 1, the session key, issuedAt and expiresAt in Unix seconds, the SHA-256 of the prompt, and the operations with their remaining counts. An agent that can write files could forge one, so a Remote grant prevents accidents; it is not a containment boundary. Nothing is written to standard output, so a host reads no decision from this hook, and every path exits 0: exit 2 would block the user's prompt in VS Code, and a grant that cannot be written simply leaves the guard blocking. .PARAMETER InputJson Hook payload as JSON. Defaults to reading standard input. Tests pass the payload directly so they do not depend on redirected input. .PARAMETER GrantRoot Directory holding the grant files. Defaults to CopilotAtelier/grants under the per-user application data folder. Tests override it to stay off the real profile; the hook itself never passes it. .PARAMETER LifetimeMinute How long a grant lasts, in minutes. Defaults to 15 and is clamped to 1 through 60; a value that is not a number falls back to 15 rather than being rejected, because a rejected parameter would exit 1. The hook never passes it; tests lower it. .NOTES The list of grantable operations and the file name rules are duplicated in Block-RemoteMutation.ps1, which reads what this script writes: VS Code launches each hook by its own path, so a shared helper would need the same fragile path probing that hooks.json already carries. Change both together; tests/RemoteMutationGrant.Tests.ps1 compares them. #> [CmdletBinding()] param( [Parameter()] [AllowEmptyString()] [AllowNull()] [string]$InputJson, [Parameter()] [AllowEmptyString()] [AllowNull()] [string]$GrantRoot, [Parameter()] [string]$LifetimeMinute = '15' ) # Every operation a Grant marker may name. Keep identical to Block-RemoteMutation.ps1. $grantableOperation = @( 'git push' 'git push --force' 'gh pr create' 'gh pr merge' 'gh pr close' 'gh pr comment' 'gh pr review' 'gh pr edit' 'gh pr ready' 'gh pr reopen' 'gh issue create' 'gh issue close' 'gh issue comment' 'gh issue edit' 'gh issue reopen' 'gh release create' 'gh release delete' 'gh release edit' 'gh release upload' 'gh repo create' 'gh repo delete' 'gh repo archive' 'gh repo rename' 'gh repo edit' 'gh workflow run' 'gh workflow enable' 'gh workflow disable' 'gh secret set' 'gh secret delete' 'gh cache delete' 'gh api' ) # Compared ordinally: a culture-sensitive -in treats a zero-width space or a soft hyphen as nothing, so # [[allow-remote: git<ZWSP> push]] would be stored as a phrase that no command ever matches. $grantableOperationSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$grantableOperation, [System.StringComparer]::Ordinal) $maximumUsePerOperation = 20 $staleAfterDay = 1 $matchTimeout = [TimeSpan]::FromSeconds(2) # [[allow-remote: <operation>, <operation> x2, ...]] on one line, in any case. $markerPattern = '(?i)\[\[\s*allow-remote\s*:\s*([^\[\]\r\n]{1,400})\]\]' # Parse and clamp rather than validate: a rejected parameter exits 1, which hosts # treat as a warning on every prompt. $lifetimeValue = 15.0 $isNumber = [double]::TryParse( $LifetimeMinute, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$lifetimeValue ) if (-not $isNumber) { $lifetimeValue = 15 } if (-not ($lifetimeValue -ge 1)) { $lifetimeValue = 1 } elseif ($lifetimeValue -gt 60) { $lifetimeValue = 60 } $lifetimeSecond = [long][Math]::Round($lifetimeValue * 60) function Get-GrantKey { <# The file name part for a session. Duplicated in Block-RemoteMutation.ps1. The payload supplies the id, so it becomes a path component only when it is already a safe file name in lower case (a file system may not tell SESSX from sessx); any other id becomes h- and its SHA-256, so two ids never share a grant file and no character that could traverse a directory survives. An id with no usable character at all gets no key. -creplace, because -replace ignores case and so lets U+0130 and U+212A through the class. #> [CmdletBinding()] [OutputType([string])] param( [Parameter()] [AllowNull()] [AllowEmptyString()] [string]$SessionId ) $usable = $SessionId -creplace '[^A-Za-z0-9._-]', '' if ([string]::IsNullOrEmpty($usable)) { return '' } if ($SessionId -cmatch '\A[a-z0-9_-]{1,64}\z') { return $SessionId } $sha = [System.Security.Cryptography.SHA256]::Create() try { $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($SessionId)) } finally { $sha.Dispose() } return 'h-' + [BitConverter]::ToString($hash).Replace('-', '').ToLowerInvariant() } function Hide-CodeText { <# Blanks the parts of a prompt that Markdown renders as code, so a marker quoted from the documentation grants nothing. An unclosed fence runs to the end of the prompt: whatever follows it counts as quoted. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) $result = $Text foreach ($pattern in @('(?s)```.*?(?:```|\z)', '(?s)~~~.*?(?:~~~|\z)', '``[^\r\n]*?``', '`[^`\r\n]*`')) { $result = [regex]::Replace($result, $pattern, ' ', [System.Text.RegularExpressions.RegexOptions]::None, $script:matchTimeout) } return $result } function Get-GrantOperation { <# The operations a prompt asks for, in the order it first names them, with their counts. Empty when the prompt carries no valid marker. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Prompt ) $operation = [ordered]@{} if ($Prompt.IndexOf('allow-remote', [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { return $operation } $markerText = [System.Collections.Generic.List[string]]::new() try { $text = Hide-CodeText -Text $Prompt # Match and NextMatch rather than Matches: a MatchCollection matches lazily, so a # timeout would surface in the loop below, outside this try. $markerRegex = [regex]::new($script:markerPattern, [System.Text.RegularExpressions.RegexOptions]::None, $script:matchTimeout) $current = $markerRegex.Match($text) while ($current.Success) { $markerText.Add($current.Groups[1].Value) $current = $current.NextMatch() } } catch [System.Text.RegularExpressions.RegexMatchTimeoutException] { # A prompt built to stall the matcher gets no grant. return [ordered]@{} } foreach ($markerBody in $markerText) { foreach ($item in $markerBody.Split(',')) { $name = ($item.Trim().ToLowerInvariant() -replace '\s+', ' ') $count = 1 $counted = [regex]::Match($name, '^(?<phrase>.+?) x(?<count>\d{1,3})$') if ($counted.Success) { $name = $counted.Groups['phrase'].Value $count = [int]$counted.Groups['count'].Value } if ($count -lt 1 -or -not $script:grantableOperationSet.Contains($name)) { continue } $already = 0 if ($operation.Contains($name)) { $already = $operation[$name] } $operation[$name] = [Math]::Min($script:maximumUsePerOperation, $already + $count) } } return $operation } function Revoke-GrantFile { <# Removes a grant file. The guard holds a file it is consuming exclusively for a moment, so a sharing violation is retried for about a second. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [string]$Path ) for ($attempt = 0; $attempt -lt 10; $attempt++) { try { if ([System.IO.File]::Exists($Path)) { [System.IO.File]::Delete($Path) } return $true } catch [System.IO.IOException] { [System.Threading.Thread]::Sleep(100) } catch [System.UnauthorizedAccessException] { [System.Threading.Thread]::Sleep(100) } } return $false } function Write-GrantFile { <# Writes the grant next to its final name and moves it into place, so a reader sees the old file, the new file, or no file, never half of one. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Json ) $directory = [System.IO.Path]::GetDirectoryName($Path) $null = [System.IO.Directory]::CreateDirectory($directory) $temporary = '{0}.{1}.tmp' -f $Path, [guid]::NewGuid().ToString('N') [System.IO.File]::WriteAllText($temporary, $Json, [System.Text.UTF8Encoding]::new($false)) for ($attempt = 0; $attempt -lt 10; $attempt++) { try { if ([System.IO.File]::Exists($Path)) { [System.IO.File]::Delete($Path) } [System.IO.File]::Move($temporary, $Path) return $true } catch [System.IO.IOException] { [System.Threading.Thread]::Sleep(100) } catch [System.UnauthorizedAccessException] { [System.Threading.Thread]::Sleep(100) } } try { [System.IO.File]::Delete($temporary) } catch { Write-Verbose -Message 'The temporary grant file could not be removed.' } return $false } function Clear-StaleGrantFile { <# Grant files of finished sessions: a day is far past any grant's lifetime. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Root ) if (-not [System.IO.Directory]::Exists($Root)) { return } $cutoff = [DateTime]::UtcNow.AddDays(-$script:staleAfterDay) foreach ($file in [System.IO.Directory]::GetFiles($Root, 'grant-*')) { try { if ([System.IO.File]::GetLastWriteTimeUtc($file) -lt $cutoff) { [System.IO.File]::Delete($file) } } catch { Write-Verbose -Message 'A stale grant file could not be removed.' } } } if ([string]::IsNullOrEmpty($InputJson)) { # Decode explicitly: Windows PowerShell would otherwise use the console input # encoding, which mangles non-ASCII prompts that pwsh reads as UTF-8. $reader = [System.IO.StreamReader]::new([Console]::OpenStandardInput(), [System.Text.UTF8Encoding]::new($false)) try { $InputJson = $reader.ReadToEnd() } finally { $reader.Dispose() } } if ([string]::IsNullOrWhiteSpace($InputJson)) { exit 0 } try { $payload = $InputJson | ConvertFrom-Json -ErrorAction Stop } catch { [Console]::Error.WriteLine('Add-RemoteGrant: hook payload is not valid JSON; recording no grant.') exit 0 } if ($payload -isnot [pscustomobject]) { exit 0 } try { $sessionId = '' foreach ($name in 'session_id', 'sessionId') { if ($payload.PSObject.Properties[$name] -and $payload.$name -is [string]) { $sessionId = [string]$payload.$name break } } $key = Get-GrantKey -SessionId $sessionId if ([string]::IsNullOrEmpty($key)) { [Console]::Error.WriteLine('Add-RemoteGrant: the payload names no usable session; recording no grant.') exit 0 } $prompt = '' if ($payload.PSObject.Properties['prompt'] -and $payload.prompt -is [string]) { $prompt = [string]$payload.prompt } $root = $GrantRoot if ([string]::IsNullOrWhiteSpace($root)) { $profileRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($profileRoot)) { [Console]::Error.WriteLine('Add-RemoteGrant: no per-user application data folder; recording no grant.') exit 0 } $root = [System.IO.Path]::Combine($profileRoot, 'CopilotAtelier', 'grants') } Clear-StaleGrantFile -Root $root $path = [System.IO.Path]::Combine($root, "grant-$key.json") $operation = Get-GrantOperation -Prompt $prompt # The previous prompt's grant ends here, whether or not this prompt asks for a new one. if ($operation.Count -eq 0) { if (-not (Revoke-GrantFile -Path $path)) { [Console]::Error.WriteLine('Add-RemoteGrant: the previous grant could not be removed; it lapses at its expiry.') } exit 0 } $sha = [System.Security.Cryptography.SHA256]::Create() try { $digest = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($prompt)) } finally { $sha.Dispose() } $issuedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $grant = [ordered]@{ schema = 1 sessionKey = $key issuedAt = $issuedAt expiresAt = $issuedAt + $lifetimeSecond promptSha256 = [BitConverter]::ToString($digest).Replace('-', '').ToLowerInvariant() operations = $operation } if (Write-GrantFile -Path $path -Json ($grant | ConvertTo-Json -Depth 4)) { $summary = foreach ($name in $operation.Keys) { if ($operation[$name] -gt 1) { '{0} x{1}' -f $name, $operation[$name] } else { $name } } [Console]::Error.WriteLine(('Add-RemoteGrant: recorded a Remote grant for this session: {0} (lasts {1} minutes or until your next prompt).' -f ($summary -join ', '), ($lifetimeSecond / 60))) } else { [Console]::Error.WriteLine('Add-RemoteGrant: the grant file could not be written; the guard keeps blocking.') } } catch { [Console]::Error.WriteLine("Add-RemoteGrant: recording a grant failed: $_") } exit 0 |