diff --git a/README.md b/README.md
index 956351c..c40c89d 100644
--- a/README.md
+++ b/README.md
@@ -84,57 +84,70 @@ Every rule has a stable ID (`K22-SDLC-12`, `K22-CODE-03`, …), a level (**MUST*
## Set up your machine
-You need git with commit signing, the GitHub CLI, PowerShell 7 (for the conformance
-checker), and the four security tools the hooks and CI use. Language toolchains come
-from the [language profiles](standards/coding/README.md#language-profiles).
+One script sets up a workstation on Windows, macOS, or Linux, and checks it afterwards
+([workstation standard](standards/workstation.md)). It installs the tools, configures
+git to sign every commit with an SSH key **held in 1Password** (no key files on disk),
+and checks disk encryption, firewall, malware protection, and stray private keys. It
+is safe to run again at any time.
-### Windows
+**Before you run it:** install [1Password](https://1password.com/downloads/), turn on
+*Settings → Developer → Use the SSH agent*, and create (or import) an SSH key item for
+GitHub. Set your git name and a verified GitHub email:
+`git config --global user.name "…"` and `git config --global user.email "…"`.
+
+### Windows (PowerShell)
```powershell
-winget install --id Git.Git -e
-winget install --id GitHub.cli -e
-winget install --id Microsoft.PowerShell -e
-winget install --id astral-sh.uv -e
-winget install --id Gitleaks.Gitleaks -e
-winget install --id AquaSecurity.Trivy -e
-winget install --id rhysd.actionlint -e
-uv tool install zizmor
-uv tool install pre-commit
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.ps1)))
```
-### macOS
+### macOS and Linux (Terminal)
```bash
-brew install git gh uv gitleaks trivy actionlint zizmor pre-commit
-brew install --cask powershell
+curl -fsSL https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.sh | bash
```
-### Linux
+The bootstrap installs PowerShell 7 (and Homebrew on macOS/Linux) if needed, then runs
+[`setup/Install-Workstation.ps1`](setup/Install-Workstation.ps1).
-Install `git`, `gh`, `gitleaks`, `trivy`, `actionlint`, and PowerShell from your
-distribution or each project's releases, then `uv tool install zizmor pre-commit`.
+### Options
-### Configure git (once per machine)
+Add them after the command (Windows), or after `bash -s --` (macOS/Linux):
-Every commit on `main` must be signed ([`K22-SDLC-14`](policies/sdlc.md#build)) and
-carry a DCO sign-off ([`K22-SDLC-13`](policies/sdlc.md#build)).
+| Option | Effect |
+| --- | --- |
+| `-Languages dotnet,java,python,rust,go,swift,shell,terraform` | Also install those toolchains |
+| `-SigningKey GitHub` | Sign with the 1Password SSH key whose name contains "GitHub" (otherwise it asks) |
+| `-RegisterSigningKey` | Upload that key to GitHub as a *signing* key |
+| `-CheckOnly` | Change nothing; report PASS / WARN / FAIL |
+| `-WhatIf` | Show every change it would make |
+
+```powershell
+# Windows: .NET and Java developer, register the signing key with GitHub
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.ps1))) -Languages dotnet,java -SigningKey GitHub -RegisterSigningKey
+```
```bash
-# Sign commits and tags with your SSH key
-git config --global gpg.format ssh
-git config --global user.signingkey ~/.ssh/id_ed25519.pub
-git config --global commit.gpgsign true
-git config --global tag.gpgsign true
-
-# Register the same key on GitHub as a *signing* key (separate from authentication)
-gh ssh-key add ~/.ssh/id_ed25519.pub --type signing --title "commit signing"
-
-# Sign off every commit: use `git commit -s`, or this alias
-git config --global alias.cs "commit -s"
+# macOS: check an existing machine without changing anything
+curl -fsSL https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.sh | bash -s -- -CheckOnly
```
-The email in `user.email` must be a verified email on your GitHub account, or commits
-will not show as *Verified*.
+### Prefer to read it first?
+
+Download, read, then run. Replace `main` with a release tag for a pinned version.
+
+```powershell
+irm https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/Install-Workstation.ps1 -OutFile Install-Workstation.ps1
+notepad Install-Workstation.ps1
+pwsh -File ./Install-Workstation.ps1 -WhatIf
+```
+
+### After it runs
+
+- Open a new terminal so newly installed tools are on your `PATH`.
+- Sign off every commit with `git commit -s` (or the alias it adds: `git cs`).
+- In each repository, enable the hooks once:
+ `pre-commit install --hook-type pre-commit --hook-type commit-msg`.
## Set up your IDE
diff --git a/policies/README.md b/policies/README.md
index 5b0c548..984f8b2 100644
--- a/policies/README.md
+++ b/policies/README.md
@@ -59,6 +59,7 @@ K22--
| `TEST` | [standards/testing.md](../standards/testing.md) | Test levels, coverage, test hygiene |
| `DEP` | [standards/dependencies.md](../standards/dependencies.md) | Selecting, pinning, and updating dependencies |
| `REL` | [standards/releases.md](../standards/releases.md) | Versioning, signing, provenance, SBOM, support |
+| `WS` | [standards/workstation.md](../standards/workstation.md) | Developer machine setup, 1Password keys, machine baseline |
| `CODE` | [standards/coding/](../standards/coding/README.md) | Cross-language coding rules |
| `CODE-` | `standards/coding/.md` | One profile per language, e.g. `K22-CODE-CS-03` |
diff --git a/policies/security-program.md b/policies/security-program.md
index 43010ec..97079ff 100644
--- a/policies/security-program.md
+++ b/policies/security-program.md
@@ -54,8 +54,9 @@ not needed. The review is recorded as a closed issue in this repository.
## Secrets and credentials
-**K22-SEC-10 (MUST)** Secrets are stored only in: a password manager, the operating
-system's credential store, or GitHub encrypted secrets bound to a protected environment.
+**K22-SEC-10 (MUST)** Secrets are stored only in: 1Password (KofTwentyTwo's password
+manager), the operating system's credential store (for an application's own user
+credentials), or GitHub encrypted secrets bound to a protected environment.
They never appear in source, history, logs, CI output, issue or pull request text,
command-line arguments, or prompts to AI tools.
*Why:* each of those places is copied, cached, or published. *Verified by:* secret
@@ -78,12 +79,15 @@ is revoked first, then replaced, then the service's access logs are checked for
and the event is handled as an incident.
*Why:* removing a secret from a file does not un-leak it. *Verified by:* incident record.
-**K22-SEC-14 (MUST)** Signing keys are held by hardware or a managed signing service:
-commit-signing keys in a hardware-backed or password-manager SSH agent, release signing
-through a cloud HSM-backed service (Azure Trusted Signing for Windows, Apple Developer ID
-for macOS). Private signing keys are never stored as files on disk or in CI secrets.
-*Why:* a stolen signing key lets an attacker ship malware as you. *Verified by:* signing
-configuration review.
+**K22-SEC-14 (MUST)** Private keys never exist as files. SSH keys (authentication,
+commit signing, deploy keys) are generated in 1Password and used through its SSH agent
+([workstation standard](../standards/workstation.md#keys-and-secrets-live-in-1password));
+release signing goes through a cloud HSM-backed service (Azure Trusted Signing for
+Windows, Apple Developer ID for macOS). Private keys are never stored on disk or in CI
+secrets.
+*Why:* a stolen signing key lets an attacker ship malware as you, and a key file can be
+copied silently. *Verified by:* `setup/Install-Workstation.ps1 -CheckOnly` (private keys
+on disk, git signing through 1Password); signing configuration review.
## Development environment
diff --git a/setup/Install-Workstation.ps1 b/setup/Install-Workstation.ps1
new file mode 100644
index 0000000..1398fcf
--- /dev/null
+++ b/setup/Install-Workstation.ps1
@@ -0,0 +1,715 @@
+#Requires -Version 7.2
+<#
+.SYNOPSIS
+ Sets up (or checks) a KofTwentyTwo development workstation on Windows, macOS, or Linux.
+
+.DESCRIPTION
+ Implements the workstation standard (standards/workstation.md, K22-WS-*):
+
+ 1. Installs the core tools every KofTwentyTwo repository needs (git, GitHub CLI, uv,
+ Node.js, gitleaks, Trivy, OSV-Scanner, actionlint, zizmor, pre-commit, 1Password
+ and its CLI), plus optional language toolchains.
+ 2. Configures git to sign every commit and tag with an SSH key held in 1Password and
+ served by the 1Password SSH agent, so no private key exists on disk (K22-SEC-14).
+ 3. Checks the machine against the security baseline (disk encryption, firewall,
+ malware protection, stray private keys) and reports PASS / WARN / FAIL.
+
+ The script is idempotent: run it again at any time to repair or update a machine.
+ It never generates keys and never reads secrets; keys are created in 1Password.
+ Supports -WhatIf to show what would change.
+
+.PARAMETER Languages
+ Optional language toolchains to install: dotnet, java, python, rust, go, swift, shell,
+ terraform.
+
+.PARAMETER SigningKey
+ Part of the name of the 1Password SSH key to sign commits with, for example "GitHub".
+ When omitted and the agent holds several keys, the script asks.
+
+.PARAMETER RegisterSigningKey
+ Also upload the signing key's public half to GitHub as a signing key (gh CLI).
+
+.PARAMETER CheckOnly
+ Change nothing; only report the workstation's state.
+
+.PARAMETER SkipGitConfig
+ Install tools but leave the global git configuration alone.
+
+.EXAMPLE
+ ./Install-Workstation.ps1 -Languages dotnet,java -SigningKey GitHub -RegisterSigningKey
+
+.EXAMPLE
+ ./Install-Workstation.ps1 -CheckOnly
+#>
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive installer: colored console output is its user interface.')]
+[CmdletBinding(SupportsShouldProcess)]
+param(
+ # Validated after splitting: `pwsh -File` (used by the bootstraps) passes "dotnet,java"
+ # as a single string.
+ [string[]] $Languages = @(),
+
+ [string] $SigningKey,
+
+ [switch] $RegisterSigningKey,
+
+ [switch] $CheckOnly,
+
+ [switch] $SkipGitConfig
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$supportedLanguages = @('dotnet', 'java', 'python', 'rust', 'go', 'swift', 'shell', 'terraform')
+$Languages = @($Languages | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ })
+$unknown = @($Languages | Where-Object { $_ -notin $supportedLanguages })
+if ($unknown.Count -gt 0)
+{
+ throw "Unknown language(s): $($unknown -join ', '). Supported: $($supportedLanguages -join ', ')."
+}
+
+
+
+#################################################################################
+# Tool catalog: what to install, how to detect it, and the package id per OS. #
+# Winget ids are verified against the winget community source; Brew entries #
+# marked Cask are macOS-only (Homebrew casks do not exist on Linux). #
+#################################################################################
+$script:Catalog = @(
+ @{ Group = 'core'; Name = 'git'; Command = 'git'; Winget = 'Git.Git'; Brew = 'git' }
+ @{ Group = 'core'; Name = 'GitHub CLI'; Command = 'gh'; Winget = 'GitHub.cli'; Brew = 'gh' }
+ @{ Group = 'core'; Name = 'uv'; Command = 'uv'; Winget = 'astral-sh.uv'; Brew = 'uv' }
+ @{ Group = 'core'; Name = 'Node.js LTS'; Command = 'node'; Winget = 'OpenJS.NodeJS.LTS'; Brew = 'node' }
+ @{ Group = 'core'; Name = 'gitleaks'; Command = 'gitleaks'; Winget = 'Gitleaks.Gitleaks'; Brew = 'gitleaks' }
+ @{ Group = 'core'; Name = 'Trivy'; Command = 'trivy'; Winget = 'AquaSecurity.Trivy'; Brew = 'trivy' }
+ @{ Group = 'core'; Name = 'OSV-Scanner'; Command = 'osv-scanner'; Winget = 'Google.OSVScanner'; Brew = 'osv-scanner' }
+ @{ Group = 'core'; Name = 'actionlint'; Command = 'actionlint'; Winget = 'rhysd.actionlint'; Brew = 'actionlint' }
+ @{ Group = 'core'; Name = '1Password'; Command = '1Password'; MacApp = '/Applications/1Password.app'; Winget = 'AgileBits.1Password'; Brew = '1password'; Cask = $true }
+ @{ Group = 'core'; Name = '1Password CLI'; Command = 'op'; Winget = 'AgileBits.1Password.CLI'; Brew = '1password-cli'; Cask = $true }
+ @{ Group = 'dotnet'; Name = '.NET SDK 10'; Command = 'dotnet'; Winget = 'Microsoft.DotNet.SDK.10'; Brew = 'dotnet-sdk'; Cask = $true }
+ @{ Group = 'java'; Name = 'Temurin JDK 21'; Command = 'java'; Winget = 'EclipseAdoptium.Temurin.21.JDK'; Brew = 'temurin@21'; Cask = $true }
+ @{ Group = 'rust'; Name = 'rustup'; Command = 'rustup'; Winget = 'Rustlang.Rustup'; Brew = 'rustup' }
+ @{ Group = 'go'; Name = 'Go'; Command = 'go'; Winget = 'GoLang.Go'; Brew = 'go' }
+ @{ Group = 'swift'; Name = 'SwiftLint'; Command = 'swiftlint'; Winget = $null; Brew = 'swiftlint' }
+ @{ Group = 'swift'; Name = 'swift-format'; Command = 'swift-format'; Winget = $null; Brew = 'swift-format' }
+ @{ Group = 'shell'; Name = 'ShellCheck'; Command = 'shellcheck'; Winget = 'koalaman.shellcheck'; Brew = 'shellcheck' }
+ @{ Group = 'shell'; Name = 'shfmt'; Command = 'shfmt'; Winget = 'mvdan.shfmt'; Brew = 'shfmt' }
+ @{ Group = 'terraform'; Name = 'OpenTofu'; Command = 'tofu'; Winget = 'OpenTofu.Tofu'; Brew = 'opentofu' }
+ @{ Group = 'terraform'; Name = 'TFLint'; Command = 'tflint'; Winget = 'TerraformLinters.tflint'; Brew = 'tflint' }
+)
+
+# Python tools installed with `uv tool install`, isolated from any project.
+$script:UvTools = @('zizmor', 'pre-commit')
+
+$script:Results = [System.Collections.Generic.List[object]]::new()
+
+
+
+<#
+.SYNOPSIS
+ Records one check result and prints it as a colored line.
+#>
+function Add-Result
+{
+ param(
+ [Parameter(Mandatory)] [ValidateSet('PASS', 'WARN', 'FAIL', 'INFO')] [string] $Status,
+ [Parameter(Mandatory)] [string] $Item,
+ [string] $Detail = ''
+ )
+
+ $script:Results.Add([pscustomobject]@{ Status = $Status; Item = $Item; Detail = $Detail })
+ $color = @{ PASS = 'Green'; WARN = 'Yellow'; FAIL = 'Red'; INFO = 'Cyan' }[$Status]
+ Write-Host ('{0,-5} {1,-34} {2}' -f $Status, $Item, $Detail) -ForegroundColor $color
+}
+
+
+
+<#
+.SYNOPSIS
+ Prints a section heading.
+#>
+function Write-Section
+{
+ param([Parameter(Mandatory)] [string] $Title)
+
+ Write-Host ''
+ Write-Host "== $Title ==" -ForegroundColor White
+}
+
+
+
+<#
+.SYNOPSIS
+ Reloads PATH from the registry so tools installed by winget are found in this session.
+#>
+function Import-MachinePath
+{
+ if ($IsWindows)
+ {
+ $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine')
+ $user = [Environment]::GetEnvironmentVariable('Path', 'User')
+ $env:Path = "$machine;$user;$env:LOCALAPPDATA\Microsoft\WinGet\Links"
+ }
+}
+
+
+
+<#
+.SYNOPSIS
+ True when the named command is on PATH.
+#>
+function Test-Command
+{
+ param([Parameter(Mandatory)] [string] $Name)
+
+ return $null -ne (Get-Command -Name $Name -ErrorAction SilentlyContinue)
+}
+
+
+
+<#
+.SYNOPSIS
+ True when a catalog tool is present: its command on PATH, or its app bundle on macOS.
+#>
+function Test-ToolInstalled
+{
+ param([Parameter(Mandatory)] [hashtable] $Tool)
+
+ if ($IsMacOS -and $Tool.ContainsKey('MacApp') -and (Test-Path -LiteralPath $Tool.MacApp))
+ {
+ return $true
+ }
+ return [bool] ($Tool.Command -and (Test-Command -Name $Tool.Command))
+}
+
+
+
+<#
+.SYNOPSIS
+ Installs one catalog entry with the platform's package manager, unless already present.
+#>
+function Install-CatalogTool
+{
+ [CmdletBinding(SupportsShouldProcess)]
+ param([Parameter(Mandatory)] [hashtable] $Tool)
+
+ if (Test-ToolInstalled -Tool $Tool)
+ {
+ Add-Result -Status PASS -Item $Tool.Name -Detail 'already installed'
+ return
+ }
+
+ if ($IsWindows)
+ {
+ if (-not $Tool.Winget)
+ {
+ Add-Result -Status INFO -Item $Tool.Name -Detail 'not available on Windows; skipped'
+ return
+ }
+ if ($PSCmdlet.ShouldProcess($Tool.Name, "winget install $($Tool.Winget)"))
+ {
+ & winget install --id $Tool.Winget --exact --source winget --silent --accept-package-agreements --accept-source-agreements --disable-interactivity | Out-Host
+ Import-MachinePath
+ }
+ }
+ else
+ {
+ $cask = $Tool.ContainsKey('Cask') -and $Tool.Cask
+ if ($cask -and $IsLinux)
+ {
+ Add-Result -Status WARN -Item $Tool.Name -Detail 'install manually on Linux (see standards/workstation.md)'
+ return
+ }
+ $arguments = if ($cask) { @('install', '--cask', $Tool.Brew) } else { @('install', $Tool.Brew) }
+ if ($PSCmdlet.ShouldProcess($Tool.Name, "brew $($arguments -join ' ')"))
+ {
+ & brew @arguments | Out-Host
+ }
+ }
+
+ if ($WhatIfPreference)
+ {
+ return
+ }
+ if ($Tool.Command -and -not (Test-ToolInstalled -Tool $Tool))
+ {
+ Add-Result -Status FAIL -Item $Tool.Name -Detail 'install did not put it on PATH; open a new terminal and re-run'
+ return
+ }
+ Add-Result -Status PASS -Item $Tool.Name -Detail 'installed'
+}
+
+
+
+<#
+.SYNOPSIS
+ Installs the uv-managed Python tools (zizmor, pre-commit), upgrading them if present.
+#>
+function Install-UvTool
+{
+ [CmdletBinding(SupportsShouldProcess)]
+ param()
+
+ if (-not (Test-Command -Name 'uv'))
+ {
+ Add-Result -Status FAIL -Item 'uv tools' -Detail 'uv is missing'
+ return
+ }
+ foreach ($name in $script:UvTools)
+ {
+ if ($PSCmdlet.ShouldProcess($name, 'uv tool install --upgrade'))
+ {
+ & uv tool install --upgrade $name 2>&1 | Out-Null
+ Add-Result -Status PASS -Item $name -Detail 'installed with uv'
+ }
+ }
+}
+
+
+
+<#
+.SYNOPSIS
+ Path of 1Password's SSH signing helper (op-ssh-sign) on this OS, or $null.
+#>
+function Get-OnePasswordSigner
+{
+ $candidates = if ($IsWindows)
+ {
+ # Direct installer (per user or per machine), then the Microsoft Store (MSIX) alias.
+ @(
+ "$env:LOCALAPPDATA\1Password\app\8\op-ssh-sign.exe"
+ "$env:ProgramFiles\1Password\app\8\op-ssh-sign.exe"
+ "$env:LOCALAPPDATA\Microsoft\WindowsApps\op-ssh-sign.exe"
+ )
+ }
+ elseif ($IsMacOS)
+ {
+ @('/Applications/1Password.app/Contents/MacOS/op-ssh-sign')
+ }
+ else
+ {
+ @('/opt/1Password/op-ssh-sign')
+ }
+ return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
+}
+
+
+
+<#
+.SYNOPSIS
+ The socket of the 1Password SSH agent on macOS and Linux (Windows uses a named pipe).
+#>
+function Get-OnePasswordAgentSocket
+{
+ if ($IsMacOS)
+ {
+ return Join-Path $HOME 'Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock'
+ }
+ if ($IsLinux)
+ {
+ return Join-Path $HOME '.1password/agent.sock'
+ }
+ return $null
+}
+
+
+
+<#
+.SYNOPSIS
+ Public keys the SSH agent offers, as objects with Line, Type, and Comment.
+#>
+function Get-AgentKey
+{
+ # Read-only: never inherit -WhatIf, which would leak into the cmdlets used here.
+ $WhatIfPreference = $false
+
+ $sshAdd = if ($IsWindows) { "$env:SystemRoot\System32\OpenSSH\ssh-add.exe" } else { 'ssh-add' }
+ $socket = Get-OnePasswordAgentSocket
+ if ($socket -and (Test-Path -LiteralPath $socket))
+ {
+ $env:SSH_AUTH_SOCK = $socket
+ }
+
+ $lines = & $sshAdd -L 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $lines)
+ {
+ return @()
+ }
+ return @($lines | ForEach-Object {
+ $parts = $_ -split ' ', 3
+ [pscustomobject]@{ Line = "$($parts[0]) $($parts[1])"; Type = $parts[0]; Comment = if ($parts.Count -gt 2) { $parts[2] } else { '' } }
+ })
+}
+
+
+
+<#
+.SYNOPSIS
+ Chooses the commit-signing key: by -SigningKey name, the only key, or by asking.
+#>
+function Select-SigningKey
+{
+ param(
+ [Parameter(Mandatory)] [object[]] $Keys,
+ [string] $Name
+ )
+
+ $candidates = @($Keys | Where-Object { $_.Type -like 'ssh-ed25519*' -or $_.Type -like 'ecdsa*' -or $_.Type -like 'sk-*' })
+ if ($Name)
+ {
+ $candidates = @($candidates | Where-Object { $_.Comment -like "*$Name*" })
+ }
+ if ($candidates.Count -eq 1)
+ {
+ return $candidates[0]
+ }
+ if ($candidates.Count -eq 0)
+ {
+ return $null
+ }
+
+ Write-Host 'Several keys are available. Which one signs your commits?'
+ for ($index = 0; $index -lt $candidates.Count; $index++)
+ {
+ Write-Host (' [{0}] {1}' -f ($index + 1), $candidates[$index].Comment)
+ }
+ $choice = Read-Host 'Number'
+ $number = 0
+ if (-not [int]::TryParse($choice, [ref] $number) -or $number -lt 1 -or $number -gt $candidates.Count)
+ {
+ throw 'No signing key selected.'
+ }
+ return $candidates[$number - 1]
+}
+
+
+
+<#
+.SYNOPSIS
+ Points ssh (macOS/Linux) at the 1Password agent through ~/.ssh/config, keeping a backup.
+#>
+function Set-SshAgentConfig
+{
+ [CmdletBinding(SupportsShouldProcess)]
+ param()
+
+ $socket = Get-OnePasswordAgentSocket
+ if (-not $socket)
+ {
+ return
+ }
+ $sshDirectory = Join-Path $HOME '.ssh'
+ $config = Join-Path $sshDirectory 'config'
+ $existing = if (Test-Path -LiteralPath $config) { Get-Content -LiteralPath $config -Raw } else { '' }
+ if ($existing -match '(?im)^\s*IdentityAgent\s')
+ {
+ Add-Result -Status PASS -Item 'ssh IdentityAgent' -Detail 'already configured in ~/.ssh/config'
+ return
+ }
+ if ($PSCmdlet.ShouldProcess($config, 'add Host * IdentityAgent for 1Password'))
+ {
+ New-Item -ItemType Directory -Force -Path $sshDirectory | Out-Null
+ if ($existing)
+ {
+ Copy-Item -LiteralPath $config -Destination "$config.k22-backup" -Force
+ }
+ $block = "# Added by KofTwentyTwo Install-Workstation: SSH keys live in 1Password (K22-SEC-14).`nHost *`n IdentityAgent `"$socket`"`n`n"
+ Set-Content -LiteralPath $config -Value ($block + $existing) -NoNewline
+ Add-Result -Status PASS -Item 'ssh IdentityAgent' -Detail 'pointed at the 1Password agent'
+ }
+}
+
+
+
+<#
+.SYNOPSIS
+ Sets one global git configuration value, honoring -WhatIf.
+#>
+function Set-GitGlobal
+{
+ [CmdletBinding(SupportsShouldProcess)]
+ param(
+ [Parameter(Mandatory)] [string] $Key,
+ [Parameter(Mandatory)] [string] $Value
+ )
+
+ if ($PSCmdlet.ShouldProcess("git config --global $Key", $Value))
+ {
+ & git config --global $Key $Value
+ }
+}
+
+
+
+<#
+.SYNOPSIS
+ Configures git for KofTwentyTwo: SSH signing through 1Password, DCO alias, sane defaults.
+#>
+function Set-GitConfiguration
+{
+ [CmdletBinding(SupportsShouldProcess)]
+ param(
+ [string] $SigningKeyName,
+ [switch] $Register
+ )
+
+ $email = & git config --global user.email
+ $name = & git config --global user.name
+ if (-not $email -or -not $name)
+ {
+ Add-Result -Status FAIL -Item 'git identity' -Detail 'set it first: git config --global user.name "..." ; git config --global user.email "..." (a verified GitHub email)'
+ return
+ }
+
+ $signer = Get-OnePasswordSigner
+ if (-not $signer)
+ {
+ Add-Result -Status FAIL -Item '1Password signer' -Detail 'op-ssh-sign not found: install 1Password and enable its SSH agent'
+ return
+ }
+
+ $keys = @(Get-AgentKey)
+ if ($keys.Count -eq 0)
+ {
+ Add-Result -Status FAIL -Item '1Password SSH agent' -Detail 'no keys offered: enable Settings > Developer > SSH agent and create an SSH key item'
+ return
+ }
+ $key = Select-SigningKey -Keys $keys -Name $SigningKeyName
+ if (-not $key)
+ {
+ Add-Result -Status FAIL -Item 'signing key' -Detail "no Ed25519/ECDSA key matching '$SigningKeyName' in the agent"
+ return
+ }
+
+ Set-GitGlobal -Key 'gpg.format' -Value 'ssh'
+ Set-GitGlobal -Key 'gpg.ssh.program' -Value ($signer -replace '\\', '/')
+ Set-GitGlobal -Key 'user.signingkey' -Value $key.Line
+ Set-GitGlobal -Key 'commit.gpgsign' -Value 'true'
+ Set-GitGlobal -Key 'tag.gpgsign' -Value 'true'
+ Set-GitGlobal -Key 'init.defaultBranch' -Value 'main'
+ Set-GitGlobal -Key 'fetch.prune' -Value 'true'
+ # Deliberately NOT set globally: core.autocrlf and pull.rebase. They change behavior in
+ # every repository on the machine, including ones that are not KofTwentyTwo's; line
+ # endings are governed per repository by .gitattributes (K22-REPO-03).
+ Set-GitGlobal -Key 'alias.cs' -Value 'commit -s'
+ if ($IsWindows)
+ {
+ # Windows OpenSSH talks to the 1Password agent's named pipe; Git's bundled ssh does not.
+ Set-GitGlobal -Key 'core.sshCommand' -Value 'C:/Windows/System32/OpenSSH/ssh.exe'
+ }
+
+ # Lets `git log --show-signature` verify your own commits locally.
+ $signersFile = Join-Path $HOME '.config/git/allowed_signers'
+ if ($PSCmdlet.ShouldProcess($signersFile, 'write allowed signers'))
+ {
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $signersFile) | Out-Null
+ Set-Content -LiteralPath $signersFile -Value "$email namespaces=`"git`" $($key.Line)"
+ Set-GitGlobal -Key 'gpg.ssh.allowedSignersFile' -Value ($signersFile -replace '\\', '/')
+ }
+ Add-Result -Status PASS -Item 'git signing' -Detail "signs with '$($key.Comment)' via 1Password"
+
+ if ($Register)
+ {
+ Register-GitHubSigningKey -Key $key
+ }
+}
+
+
+
+<#
+.SYNOPSIS
+ Uploads the public signing key to GitHub as a signing key, if not already there.
+#>
+function Register-GitHubSigningKey
+{
+ [CmdletBinding(SupportsShouldProcess)]
+ param([Parameter(Mandatory)] [object] $Key)
+
+ $existing = & gh api user/ssh_signing_keys --jq '.[].key' 2>$null
+ if ($LASTEXITCODE -eq 0 -and ($existing -contains $Key.Line))
+ {
+ Add-Result -Status PASS -Item 'GitHub signing key' -Detail 'already registered'
+ return
+ }
+ if ($PSCmdlet.ShouldProcess('GitHub', 'add SSH signing key'))
+ {
+ & gh auth refresh --hostname github.com --scopes admin:ssh_signing_key | Out-Host
+ $file = Join-Path ([System.IO.Path]::GetTempPath()) 'k22-signing-key.pub'
+ Set-Content -LiteralPath $file -Value $Key.Line
+ try
+ {
+ & gh ssh-key add $file --type signing --title "$([Environment]::MachineName) commit signing" | Out-Host
+ }
+ finally
+ {
+ Remove-Item -LiteralPath $file -Force
+ }
+ Add-Result -Status PASS -Item 'GitHub signing key' -Detail 'registered'
+ }
+}
+
+
+
+<#
+.SYNOPSIS
+ Reports the machine security baseline of K22-SEC-20 and stray private keys (K22-SEC-14).
+#>
+function Test-SecurityBaseline
+{
+ # Read-only: never inherit -WhatIf, which would leak into the cmdlets used here.
+ $WhatIfPreference = $false
+
+ if ($IsWindows)
+ {
+ # Readable without elevation: 1 = BitLocker on, 3 = encrypting.
+ $protection = (New-Object -ComObject Shell.Application).NameSpace("$env:SystemDrive\").Self.ExtendedProperty('System.Volume.BitLockerProtection')
+ if ($protection -in 1, 3) { Add-Result -Status PASS -Item 'Disk encryption' -Detail 'BitLocker on system drive' }
+ else { Add-Result -Status FAIL -Item 'Disk encryption' -Detail 'BitLocker is off on the system drive' }
+
+ $profiles = @(Get-NetFirewallProfile -ErrorAction SilentlyContinue)
+ if ($profiles.Count -gt 0 -and -not ($profiles | Where-Object { -not $_.Enabled })) { Add-Result -Status PASS -Item 'Firewall' -Detail 'all profiles enabled' }
+ else { Add-Result -Status FAIL -Item 'Firewall' -Detail 'a Windows Firewall profile is disabled' }
+
+ try
+ {
+ # CIM instead of Get-MpComputerStatus: importing the Defender module would pick up -WhatIf.
+ $defender = Get-CimInstance -Namespace 'root/Microsoft/Windows/Defender' -ClassName 'MSFT_MpComputerStatus'
+ if ($defender.RealTimeProtectionEnabled) { Add-Result -Status PASS -Item 'Malware protection' -Detail 'Defender real-time protection on' }
+ else { Add-Result -Status WARN -Item 'Malware protection' -Detail 'Defender real-time protection off (fine if another product is active)' }
+ }
+ catch
+ {
+ Add-Result -Status WARN -Item 'Malware protection' -Detail 'could not read Defender status'
+ }
+ }
+ elseif ($IsMacOS)
+ {
+ $fileVault = & fdesetup isactive 2>$null
+ if ("$fileVault" -eq 'true') { Add-Result -Status PASS -Item 'Disk encryption' -Detail 'FileVault on' }
+ else { Add-Result -Status FAIL -Item 'Disk encryption' -Detail 'FileVault is off' }
+
+ $firewall = & /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>$null
+ if ("$firewall" -match 'enabled') { Add-Result -Status PASS -Item 'Firewall' -Detail 'application firewall on' }
+ else { Add-Result -Status FAIL -Item 'Firewall' -Detail 'application firewall is off' }
+ }
+ else
+ {
+ $crypt = & lsblk -o TYPE --noheadings 2>$null
+ if ("$crypt" -match 'crypt') { Add-Result -Status PASS -Item 'Disk encryption' -Detail 'LUKS volume present' }
+ else { Add-Result -Status WARN -Item 'Disk encryption' -Detail 'no LUKS volume found; confirm full-disk encryption' }
+ Add-Result -Status INFO -Item 'Firewall' -Detail 'confirm ufw/firewalld is enabled'
+ }
+
+ $sshDirectory = Join-Path $HOME '.ssh'
+ $privateKeys = @()
+ if (Test-Path -LiteralPath $sshDirectory)
+ {
+ $privateKeys = @(Get-ChildItem -LiteralPath $sshDirectory -File | Where-Object {
+ $_.Extension -ne '.pub' -and ((Get-Content -LiteralPath $_.FullName -TotalCount 1 -ErrorAction SilentlyContinue) -match 'PRIVATE KEY')
+ })
+ }
+ if ($privateKeys.Count -eq 0) { Add-Result -Status PASS -Item 'Private keys on disk' -Detail 'none in ~/.ssh' }
+ else { Add-Result -Status WARN -Item 'Private keys on disk' -Detail ("move into 1Password, then delete: {0}" -f (($privateKeys | ForEach-Object Name) -join ', ')) }
+}
+
+
+
+<#
+.SYNOPSIS
+ Reports whether git is configured for 1Password signing (used by -CheckOnly).
+#>
+function Test-GitConfiguration
+{
+ # Read-only: never inherit -WhatIf, which would leak into the cmdlets used here.
+ $WhatIfPreference = $false
+
+ $format = & git config --global gpg.format
+ $program = & git config --global gpg.ssh.program
+ $sign = & git config --global commit.gpgsign
+ if ($format -eq 'ssh' -and $sign -eq 'true' -and "$program" -match 'op-ssh-sign')
+ {
+ Add-Result -Status PASS -Item 'git signing' -Detail 'SSH signing through 1Password'
+ }
+ elseif ($format -eq 'ssh' -and $sign -eq 'true')
+ {
+ Add-Result -Status WARN -Item 'git signing' -Detail "signs with SSH but not through op-ssh-sign ($program)"
+ }
+ else
+ {
+ Add-Result -Status FAIL -Item 'git signing' -Detail 'commits are not signed by default'
+ }
+ if (@(Get-AgentKey).Count -gt 0) { Add-Result -Status PASS -Item 'SSH agent' -Detail 'agent reachable and offering keys' }
+ else { Add-Result -Status FAIL -Item 'SSH agent' -Detail 'no agent or no keys' }
+}
+
+
+
+#################################################################################
+# Main #
+#################################################################################
+$platform = if ($IsWindows) { 'Windows' } elseif ($IsMacOS) { 'macOS' } else { 'Linux' }
+Write-Host "KofTwentyTwo workstation setup ($platform)" -ForegroundColor White
+if (-not $IsWindows -and -not (Test-Command -Name 'brew'))
+{
+ throw 'Homebrew is required on macOS and Linux; run setup/bootstrap.sh, which installs it.'
+}
+if ($IsWindows -and -not (Test-Command -Name 'winget'))
+{
+ throw 'winget (App Installer) is required; install it from the Microsoft Store.'
+}
+
+$groups = @('core') + $Languages
+Write-Section -Title 'Tools'
+foreach ($tool in $script:Catalog | Where-Object { $_.Group -in $groups })
+{
+ if ($CheckOnly)
+ {
+ if (Test-ToolInstalled -Tool $tool) { Add-Result -Status PASS -Item $tool.Name -Detail 'installed' }
+ else { Add-Result -Status FAIL -Item $tool.Name -Detail 'missing' }
+ }
+ else
+ {
+ Install-CatalogTool -Tool $tool
+ }
+}
+if ($CheckOnly)
+{
+ foreach ($name in $script:UvTools)
+ {
+ if (Test-Command -Name $name) { Add-Result -Status PASS -Item $name -Detail 'installed' }
+ else { Add-Result -Status FAIL -Item $name -Detail 'missing' }
+ }
+}
+else
+{
+ Install-UvTool
+ if ('shell' -in $Languages -and $PSCmdlet.ShouldProcess('PSScriptAnalyzer', 'Install-PSResource'))
+ {
+ Install-PSResource -Name PSScriptAnalyzer -Scope CurrentUser -TrustRepository -Quiet
+ Add-Result -Status PASS -Item 'PSScriptAnalyzer' -Detail 'installed'
+ }
+}
+
+Write-Section -Title 'Git and 1Password'
+if ($CheckOnly)
+{
+ Test-GitConfiguration
+}
+elseif (-not $SkipGitConfig)
+{
+ Set-SshAgentConfig
+ Set-GitConfiguration -SigningKeyName $SigningKey -Register:$RegisterSigningKey
+}
+
+Write-Section -Title 'Security baseline (K22-SEC-20)'
+Test-SecurityBaseline
+
+$failed = @($script:Results | Where-Object { $_.Status -eq 'FAIL' }).Count
+$warned = @($script:Results | Where-Object { $_.Status -eq 'WARN' }).Count
+Write-Host ''
+Write-Host ("Done: {0} failed, {1} warnings." -f $failed, $warned) -ForegroundColor ($failed -gt 0 ? 'Red' : 'Green')
+if (-not $CheckOnly -and $failed -eq 0)
+{
+ Write-Host 'Open a new terminal so newly installed tools are on PATH.'
+}
+exit ($failed -gt 0 ? 1 : 0)
diff --git a/setup/bootstrap.ps1 b/setup/bootstrap.ps1
new file mode 100644
index 0000000..1a5fbc0
--- /dev/null
+++ b/setup/bootstrap.ps1
@@ -0,0 +1,47 @@
+<#
+.SYNOPSIS
+ Windows bootstrap: installs PowerShell 7 if needed, then runs Install-Workstation.ps1.
+
+.DESCRIPTION
+ Runs in the Windows PowerShell 5.1 that ships with Windows, so it works on a brand-new
+ machine. It downloads Install-Workstation.ps1 from the same ref of
+ KofTwentyTwo/standards and runs it in PowerShell 7, passing through any arguments.
+
+.PARAMETER Ref
+ Branch, tag, or commit of KofTwentyTwo/standards to use. Pin a release tag for a
+ reproducible setup.
+
+.EXAMPLE
+ & ([scriptblock]::Create((Invoke-RestMethod https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.ps1))) -Languages dotnet
+#>
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive bootstrap: progress messages for the person running it.')]
+[CmdletBinding()]
+param(
+ [string] $Ref = 'main',
+
+ [Parameter(ValueFromRemainingArguments)]
+ [object[]] $WorkstationArguments = @()
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if (-not (Get-Command -Name winget -ErrorAction SilentlyContinue))
+{
+ throw 'winget (App Installer) is required. Install "App Installer" from the Microsoft Store, then run this again.'
+}
+
+if (-not (Get-Command -Name pwsh -ErrorAction SilentlyContinue))
+{
+ Write-Host 'Installing PowerShell 7...'
+ & winget install --id Microsoft.PowerShell --exact --source winget --silent --accept-package-agreements --accept-source-agreements --disable-interactivity
+ $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User')
+}
+
+$script = Join-Path ([System.IO.Path]::GetTempPath()) 'Install-Workstation.ps1'
+$url = "https://raw.githubusercontent.com/KofTwentyTwo/standards/$Ref/setup/Install-Workstation.ps1"
+Write-Host "Downloading $url"
+Invoke-WebRequest -Uri $url -OutFile $script -UseBasicParsing
+
+& pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File $script @WorkstationArguments
+exit $LASTEXITCODE
diff --git a/setup/bootstrap.sh b/setup/bootstrap.sh
new file mode 100644
index 0000000..7848242
--- /dev/null
+++ b/setup/bootstrap.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# macOS and Linux bootstrap for a KofTwentyTwo workstation (standards/workstation.md).
+# Installs Homebrew and PowerShell 7 if needed, then runs Install-Workstation.ps1 from
+# the same ref of KofTwentyTwo/standards, passing through every argument.
+#
+# curl -fsSL https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.sh | bash -s -- -Languages dotnet
+#
+# Set K22_REF to a release tag for a reproducible setup.
+set -euo pipefail
+
+ref="${K22_REF:-main}"
+os="$(uname -s)"
+
+if ! command -v brew > /dev/null 2>&1; then
+ echo "Installing Homebrew..."
+ # Captured first so a failed download stops the script (set -e) instead of running nothing.
+ installer="$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+ NONINTERACTIVE=1 /bin/bash -c "${installer}"
+ for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do
+ if [[ -x "${candidate}" ]]; then
+ environment="$("${candidate}" shellenv)"
+ eval "${environment}"
+ break
+ fi
+ done
+fi
+
+if ! command -v pwsh > /dev/null 2>&1; then
+ echo "Installing PowerShell 7..."
+ if [[ "${os}" == "Darwin" ]]; then
+ brew install --cask powershell
+ else
+ echo "Install PowerShell 7 for your distribution, then run this again:" >&2
+ echo " https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-linux" >&2
+ exit 1
+ fi
+fi
+
+script="$(mktemp -t install-workstation.XXXXXX).ps1"
+trap 'rm -f "${script}"' EXIT
+url="https://raw.githubusercontent.com/KofTwentyTwo/standards/${ref}/setup/Install-Workstation.ps1"
+echo "Downloading ${url}"
+curl -fsSL "${url}" -o "${script}"
+
+pwsh -NoLogo -NoProfile -File "${script}" "$@"
diff --git a/standards/workstation.md b/standards/workstation.md
new file mode 100644
index 0000000..478cba5
--- /dev/null
+++ b/standards/workstation.md
@@ -0,0 +1,75 @@
+# Workstation standard
+
+How a machine used to write, sign, or release KofTwentyTwo software is set up. The
+[`setup/`](../setup) scripts implement this standard on Windows, macOS, and Linux and
+check a machine against it; running them is the expected way to comply.
+
+## Setup
+
+**K22-WS-01 (MUST)** A development machine is set up, and re-checked after major
+changes and at the annual access review, with `setup/Install-Workstation.ps1`
+(through the bootstrap for its OS). A machine that cannot run it meets each requirement
+below by hand.
+*Why:* one repeatable setup instead of a checklist from memory. *Verified by:*
+`Install-Workstation.ps1 -CheckOnly`.
+
+**K22-WS-02 (MUST)** These tools are installed and kept current: git, GitHub CLI,
+PowerShell 7, uv, Node.js LTS, gitleaks, Trivy, OSV-Scanner, actionlint, zizmor,
+pre-commit, 1Password, and the 1Password CLI, plus the toolchain of every language
+profile the maintainer works in.
+*Why:* the local hooks and checks are the same ones CI runs
+([`K22-CI-03`](ci-cd.md#required-checks)). *Verified by:* `-CheckOnly`.
+
+## Keys and secrets live in 1Password
+
+1Password is KofTwentyTwo's password manager and SSH agent. Keys are **created in
+1Password** and never exist as files.
+
+**K22-WS-10 (MUST)** Every SSH key (GitHub authentication, commit signing, deploy keys,
+server access) is generated inside 1Password and used through the 1Password SSH agent.
+No private key is stored in `~/.ssh` or anywhere else on disk; a key found on disk is
+moved into 1Password (import it, or replace it with a new key) and the file deleted.
+*Why:* a key file can be copied by any process running as the user, by backups, and by
+malware; a key in 1Password is released only through the agent, with the user's
+approval. *Verified by:* `-CheckOnly` (private keys on disk). *Maps to:*
+[`K22-SEC-14`](../policies/security-program.md#secrets-and-credentials).
+
+**K22-WS-11 (MUST)** Commits and tags are signed with an SSH key from 1Password
+(`gpg.format = ssh`, `gpg.ssh.program` = 1Password's `op-ssh-sign`), and that key is
+registered on GitHub as a **signing** key.
+*Why:* *Verified* commits ([`K22-SDLC-14`](../policies/sdlc.md#build)) without a signing
+key on disk. *Verified by:* `-CheckOnly` (git signing); GitHub shows the commit as
+Verified.
+
+**K22-WS-12 (MUST)** Secrets a tool needs at run time (API tokens, registry keys) are
+read from 1Password when needed, with `op run`, `op read`, or `op://` references in an
+environment file, never stored in shell profiles, `.env` files, or tool configuration.
+*Why:* nothing to leak from a dotfile or a screen share. *Verified by:* gitleaks; review.
+
+**K22-WS-13 (SHOULD)** The 1Password SSH agent is configured to ask for approval per
+application, and 1Password locks with the operating system.
+*Why:* a malicious process cannot silently use a key. *Verified by:* annual review.
+
+## Machine security baseline
+
+**K22-WS-20 (MUST)** The machine meets the [security program](../policies/security-program.md#development-environment)
+baseline: full-disk encryption (BitLocker, FileVault, or LUKS), firewall on, malware
+protection on (Windows), automatic OS updates, and a screen lock.
+*Why:* a lost or compromised workstation must not expose source, sessions, or keys.
+*Verified by:* `-CheckOnly` (disk encryption, firewall, malware protection).
+
+## Running the setup
+
+| OS | Command |
+| --- | --- |
+| Windows | `& ([scriptblock]::Create((irm https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.ps1)))` |
+| macOS, Linux | `curl -fsSL https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.sh \| bash` |
+
+Add arguments after the command, for example `-Languages dotnet,java -SigningKey GitHub
+-RegisterSigningKey`. See the [README](../README.md#set-up-your-machine) for the
+options, the inspect-before-running variant, and pinning to a release.
+
+On Linux the script installs tools with Homebrew; install 1Password and PowerShell 7
+from their official Linux packages first
+([1Password](https://support.1password.com/install-linux/),
+[PowerShell](https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-linux)).