Hooks/scripts/Block-RemoteMutation.ps1

<#
.SYNOPSIS
    PreToolUse hook that blocks remote-mutating and irreversible commands.
.DESCRIPTION
    Reads the VS Code PreToolUse hook payload from standard input and inspects
    any tool input that carries executable command text for operations the
    Copilot Atelier house rules forbid without explicit per-turn authorization:
    pushing to a remote, bypassing repository hooks, discarding work with a hard
    reset or forced clean, and mutating pull requests, issues, releases,
    repositories, workflows, secrets, or the GitHub API through the GitHub CLI.
 
    On a match the reason is written to standard error and the script exits
    with 2, which VS Code treats as a blocking error and shows to the model.
    Every other tool call exits 0.
 
    This is a best-effort guardrail, not a containment boundary. It matches
    patterns in a command string, so an obfuscated or indirectly invoked push
    can evade it. It removes the accidental path; branch protection and a
    server-side policy remain the real enforcement.
 
    Set COPILOT_ATELIER_ALLOW_REMOTE=1 in the environment to authorize a remote
    mutation. The hook then allows the command and records the override on
    standard error.
.PARAMETER InputJson
    Hook payload as JSON. Defaults to reading standard input. Tests pass the
    payload directly so they do not depend on redirected input.
.NOTES
    Exit codes follow the VS Code hook contract: 0 allows, 2 blocks, and any
    other value is a non-blocking warning.
#>


[CmdletBinding()]
param(
    [Parameter()]
    [AllowEmptyString()]
    [AllowNull()]
    [string]$InputJson
)

# First match wins; the most consequential rule is listed first. Each git rule
# anchors on the subcommand position so a branch name, commit message, or
# --grep value that merely contains the word does not trip it.
$gitOption = '(?:\s+(?:-c\s+\S+|--?[\w-]+(?:=\S+)?))*'
$blockedOperation = [ordered]@{
    'pushes to a git remote' = "(?i)\bgit\b$gitOption\s+push\b"
    'bypasses repository hooks' = '(?i)\bgit\b[^\r\n]*\s--no-verify\b'
    'discards work with a hard reset' = "(?i)\bgit\b$gitOption\s+reset\b[^\r\n]*?\s--hard\b"
    'force-deletes untracked files' = "(?i)\bgit\b$gitOption\s+clean\b(?![^\r\n]*\s-[a-z]*n)[^\r\n]*\s-[a-z]*f"
    'mutates a GitHub remote resource' = '(?i)\bgh\s+(?:pr\s+(?:create|merge|close|comment|review|edit|ready|reopen)|issue\s+(?:create|close|comment|edit|reopen)|release\s+(?:create|delete|edit|upload)|repo\s+(?:create|delete|archive|rename|edit)|workflow\s+(?:run|enable|disable)|secret\s+(?:set|delete)|cache\s+delete)\b'
    'calls a mutating GitHub API endpoint' = '(?i)\bgh\s+api\b[^\r\n]*(?:--method\s+(?:post|put|patch|delete)\b|\s-X\s+(?:post|put|patch|delete)\b|\bmutation\b)'
}

# Field names that carry executable command text. Gating on the presence of one
# of these is tool-agnostic; gating on the tool name misses executors whose name
# lacks a shell keyword, and every miss would fail open.
$commandField = @('command', 'commandLine', 'cmd', 'script', 'args', 'arguments')

function Get-CommandText {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()]
        [AllowNull()]
        [object]$InputObject,

        [Parameter()]
        [int]$Depth = 0
    )

    if ($null -eq $InputObject -or $InputObject -is [string] -or $Depth -gt 4) {
        return ''
    }

    $collected = [System.Collections.Generic.List[string]]::new()

    foreach ($property in $InputObject.PSObject.Properties) {
        $value = $property.Value

        if ($property.Name -in $script:commandField) {
            if ($value -is [string]) {
                $collected.Add($value)
            } elseif ($value -is [array]) {
                $collected.Add((@($value) -join ' '))
            }
        }

        foreach ($child in @($value)) {
            if ($child -is [psobject] -and $child.PSObject.Properties.Name.Count -gt 0 -and $child -isnot [string] -and $child -isnot [ValueType]) {
                $nested = Get-CommandText -InputObject $child -Depth ($Depth + 1)
                if (-not [string]::IsNullOrWhiteSpace($nested)) {
                    $collected.Add($nested)
                }
            }
        }
    }

    return ($collected -join ' ')
}

if ([string]::IsNullOrEmpty($InputJson)) {
    # Decode explicitly: Windows PowerShell would otherwise use the console input
    # encoding, which mangles non-ASCII payloads that pwsh reads as UTF-8.
    $reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [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('Block-RemoteMutation: hook payload is not valid JSON; allowing the tool call.')
    exit 1
}

$commandText = Get-CommandText -InputObject $payload.tool_input
if ([string]::IsNullOrWhiteSpace($commandText)) {
    exit 0
}

# Fold shell line continuations so a split command cannot hide the subcommand.
$commandText = $commandText -replace '[`^\\]\r?\n\s*', ' '

foreach ($operation in $blockedOperation.GetEnumerator()) {
    if ($commandText -notmatch $operation.Value) {
        continue
    }

    if ($env:COPILOT_ATELIER_ALLOW_REMOTE -eq '1') {
        [Console]::Error.WriteLine(
            "Block-RemoteMutation: allowed by COPILOT_ATELIER_ALLOW_REMOTE - command $($operation.Key)."
        )
        exit 0
    }

    [Console]::Error.WriteLine(
        "Blocked by Copilot Atelier: this command $($operation.Key), which the house rules forbid " +
        'without explicit per-turn authorization from the user. Ask the user to confirm, then ' +
        'set COPILOT_ATELIER_ALLOW_REMOTE=1 for that command. Do not rewrite the command to evade this check.'
    )
    exit 2
}

exit 0