diff --git a/docs/copilot-cli.md b/docs/copilot-cli.md index 20843bbc..ad4cebc0 100644 --- a/docs/copilot-cli.md +++ b/docs/copilot-cli.md @@ -182,6 +182,37 @@ second, independent guard. The same reasoning applies to `gh` and `GH_TOKEN`. [opcopilot]: https://www.1password.dev/cli/shell-plugins/github-copilot +## Pre-approving safe file writes (PowerShell) + +In a trusted, git-tracked repo, Copilot CLI's per-edit "accept" prompt is a +lot of clicking — git already provides the safety net (`/diff`, `/rewind`, +`git restore`). PowerShell sessions get a `copilot` function (aliased over +the real CLI, defined in +[`Copilot.ps1`](../home/dot_config/powershell/modules/DotfilesHelpers/Public/Copilot.ps1)) +that runs the CLI with `--allow-tool=write`, pre-approving file create/edit +**only**. Shell (`shell`/`bash`) tools stay gated, and MCP tools (e.g. +WorkIQ) are unaffected, so it remains least-privilege. + +```powershell +copilot # runs the CLI with --allow-tool=write +copilot -Raw # bypasses the wrapper; every tool call prompts +``` + +The pre-approved tool list is configurable via `$env:COPILOT_ALLOW_TOOLS` +(default `write`), so it can be widened — e.g. to also pre-approve read-only +git commands — or narrowed without editing the function: + +```powershell +$env:COPILOT_ALLOW_TOOLS = 'write,shell(git status),shell(git diff)' +``` + +Never set it to `--allow-all-tools` / `--yolo` equivalents — that grants +shell and URL access too, defeating the purpose of an allow-list. +`Invoke-Copilot` resolves and calls the real `copilot` executable on `PATH` +directly (never itself, avoiding recursion). Note this PowerShell wrapper is +separate from the [1Password shell plugin](#local-authentication-with-the-1password-shell-plugin) +above, which currently only wraps `copilot` in bash/zsh/fish. + ## Security notes - The tokens live only in 1Password (at rest), transiently in the helper's diff --git a/home/dot_config/powershell/aliases.ps1 b/home/dot_config/powershell/aliases.ps1 index 055db39d..fb3fb41a 100644 --- a/home/dot_config/powershell/aliases.ps1 +++ b/home/dot_config/powershell/aliases.ps1 @@ -48,6 +48,10 @@ function pubkey { Get-Content ~/.ssh/id_rsa.pub | Set-Clipboard; Write-Host '=> Set-Alias -Name copilot-ssh -Value Connect-CopilotSsh Set-Alias -Name copilot_ssh -Value Connect-CopilotSsh +# Copilot CLI - pre-approve safe file writes (see docs/copilot-cli.md). +# Use 'copilot -Raw' (or the resolved executable directly) to bypass this. +Set-Alias -Name copilot -Value Invoke-Copilot + # Chezmoi shortcuts Set-Alias -Name chezmoi-up -Value Update-Chezmoi Set-Alias -Name chezmoi_up -Value Update-Chezmoi diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 b/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 index f7184ba2..7a4a8a51 100644 --- a/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 +++ b/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 @@ -28,6 +28,7 @@ # Copilot CLI 'Connect-CopilotSsh' + 'Invoke-Copilot' # Winget utilities 'Test-WingetUpdates' diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/Public/Copilot.ps1 b/home/dot_config/powershell/modules/DotfilesHelpers/Public/Copilot.ps1 new file mode 100644 index 00000000..3e4187e1 --- /dev/null +++ b/home/dot_config/powershell/modules/DotfilesHelpers/Public/Copilot.ps1 @@ -0,0 +1,61 @@ +# GitHub Copilot CLI interactive wrapper +# +# Invoke-Copilot (aliased 'copilot') - runs the real Copilot CLI with +# non-shell file-modifying tools pre-approved, so trusted, git-tracked repos +# stop prompting on every create/edit. Shell commands and MCP tools are left +# gated, so this stays least-privilege. See docs/copilot-cli.md. + +function Invoke-Copilot { + <# + .SYNOPSIS + Runs the GitHub Copilot CLI with safe file-write tools pre-approved. + .DESCRIPTION + Resolves the real `copilot` executable on PATH (never itself, to + avoid infinite recursion when this function is aliased to `copilot`) + and runs it with `--allow-tool=` prepended. The allow-list + defaults to `write` (file create/edit only) and is configurable via + $env:COPILOT_ALLOW_TOOLS, so it can be widened (e.g. to also + pre-approve read-only `git status`/`git diff`) or narrowed without + editing this function. Shell/bash tools and MCP tools stay gated + unless explicitly added to the allow-list. + + Use -Raw (or call the resolved executable directly, e.g. + `copilot.exe ...`) to run the unwrapped CLI when every prompt should + be shown. + .PARAMETER Raw + Skip the --allow-tool pre-approval and run the real CLI unmodified. + .PARAMETER Arguments + Arguments passed through to the Copilot CLI. + .EXAMPLE + copilot + Runs Copilot CLI with file writes pre-approved. + .EXAMPLE + copilot -Raw + Runs the unwrapped CLI; every tool call prompts as usual. + .EXAMPLE + $env:COPILOT_ALLOW_TOOLS = 'write,shell(git status),shell(git diff)' + copilot + Widens the pre-approved tools to include read-only git commands. + #> + [CmdletBinding()] + param( + [switch]$Raw, + + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + $cli = Get-Command -Name copilot -CommandType Application -All -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $cli) { + throw "GitHub Copilot CLI ('copilot') was not found on PATH. Install it before using this wrapper." + } + + if ($Raw) { + & $cli.Source @Arguments + return + } + + $allowTools = if ($env:COPILOT_ALLOW_TOOLS) { $env:COPILOT_ALLOW_TOOLS } else { 'write' } + & $cli.Source "--allow-tool=$allowTools" @Arguments +} diff --git a/tests/powershell/Copilot.Tests.ps1 b/tests/powershell/Copilot.Tests.ps1 new file mode 100644 index 00000000..0ea30aab --- /dev/null +++ b/tests/powershell/Copilot.Tests.ps1 @@ -0,0 +1,119 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Pester tests for the Invoke-Copilot function in the DotfilesHelpers module. + +.DESCRIPTION + Tests the `copilot` wrapper that pre-approves safe file writes via + `--allow-tool`. The real `copilot` CLI is replaced with a PATH stub + (Windows .cmd / non-Windows shell script) that records its arguments, so + the behaviour is validated without a real Copilot CLI installation. +#> + +BeforeAll { + $script:RepoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + Push-Location $script:RepoRoot + + $modulePath = Join-Path $script:RepoRoot "home/dot_config/powershell/modules/DotfilesHelpers" + Get-Module DotfilesHelpers -All | Remove-Module -Force -ErrorAction SilentlyContinue + Import-Module $modulePath -Force -DisableNameChecking + + $tmpRoot = if ($env:TEMP) { $env:TEMP } else { '/tmp' } + $script:StubDir = (New-Item -ItemType Directory -Path (Join-Path $tmpRoot "copilot-tests-$(Get-Random)") -Force).FullName + $script:OrigPath = $env:PATH + $script:OrigAllowTools = $env:COPILOT_ALLOW_TOOLS + + # Writes a stub `copilot` executable that records the arguments it was + # called with into a file, so tests can assert on them. + function script:New-CopilotStub { + $outFile = Join-Path $script:StubDir 'copilot-args.txt' + if ($IsWindows -or ($PSVersionTable.PSEdition -eq 'Desktop')) { + $stubPath = Join-Path $script:StubDir 'copilot.cmd' + @" +@echo off +> "$outFile" ( + for %%a in (%*) do echo %%a +) +"@ | Set-Content -Path $stubPath -Encoding ASCII + } + else { + $stubPath = Join-Path $script:StubDir 'copilot' + @" +#!/bin/sh +: > "$outFile" +for arg in "`$@"; do + echo "`$arg" >> "$outFile" +done +"@ | Set-Content -Path $stubPath -Encoding ASCII -NoNewline + & chmod +x $stubPath + } + $env:PATH = "$script:StubDir$([IO.Path]::PathSeparator)$script:OrigPath" + return $outFile + } +} + +AfterAll { + Pop-Location + $env:PATH = $script:OrigPath + if ($null -eq $script:OrigAllowTools) { Remove-Item Env:COPILOT_ALLOW_TOOLS -ErrorAction SilentlyContinue } + else { $env:COPILOT_ALLOW_TOOLS = $script:OrigAllowTools } + if (Test-Path $script:StubDir) { + Remove-Item -Recurse -Force $script:StubDir -ErrorAction SilentlyContinue + } +} + +Describe "Invoke-Copilot availability" -Tag "Unit" { + It "Should be available as a function" { + Get-Command Invoke-Copilot -CommandType Function -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + + It "Should be aliased as 'copilot' via aliases.ps1" { + $aliasesPath = Join-Path $script:RepoRoot "home/dot_config/powershell/aliases.ps1" + $content = Get-Content -Raw -Path $aliasesPath + $content | Should -Match "Set-Alias\s+-Name\s+copilot\s+-Value\s+Invoke-Copilot" + } + + It "Should throw a clear error when the copilot CLI is not on PATH" { + try { + $env:PATH = $script:StubDir + { Invoke-Copilot } | Should -Throw -ExpectedMessage "*not found on PATH*" + } + finally { + $env:PATH = $script:OrigPath + } + } +} + +Describe "Invoke-Copilot behaviour" -Tag "Unit" { + BeforeEach { + Remove-Item Env:COPILOT_ALLOW_TOOLS -ErrorAction SilentlyContinue + } + + It "Passes --allow-tool=write by default" { + $outFile = New-CopilotStub + Invoke-Copilot + (Get-Content -Path $outFile -Raw).Contains('--allow-tool=write') | Should -BeTrue + } + + It "Respects `$env:COPILOT_ALLOW_TOOLS when set" { + $outFile = New-CopilotStub + $env:COPILOT_ALLOW_TOOLS = 'write,shell(git status),shell(git diff)' + Invoke-Copilot + (Get-Content -Path $outFile -Raw).Contains('--allow-tool=write,shell(git status),shell(git diff)') | Should -BeTrue + } + + It "Forwards extra arguments after --allow-tool" { + $outFile = New-CopilotStub + Invoke-Copilot --help + $content = Get-Content -Path $outFile + $content | Should -Contain '--help' + } + + It "Does not pass --allow-tool when -Raw is used" { + $outFile = New-CopilotStub + Invoke-Copilot -Raw --help + $content = Get-Content -Raw -Path $outFile + $content.Contains('--allow-tool') | Should -BeFalse + $content | Should -Match '--help' + } +}