Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/copilot-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions home/dot_config/powershell/aliases.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

# Copilot CLI
'Connect-CopilotSsh'
'Invoke-Copilot'

# Winget utilities
'Test-WingetUpdates'
Expand Down
Original file line number Diff line number Diff line change
@@ -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=<list>` 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
}
119 changes: 119 additions & 0 deletions tests/powershell/Copilot.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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'
}
}