diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f26d7a4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+# === Crosslink managed (do not edit between markers) ===
+# .crosslink/ — machine-local state (never commit)
+.crosslink/issues.db
+.crosslink/issues.db-wal
+.crosslink/issues.db-shm
+.crosslink/agent.json
+.crosslink/session.json
+.crosslink/daemon.pid
+.crosslink/daemon.log
+.crosslink/last_test_run
+.crosslink/keys/
+.crosslink/.hub-cache/
+.crosslink/.knowledge-cache/
+.crosslink/.cache/
+.crosslink/hook-config.local.json
+.crosslink/integrations/
+.crosslink/rules.local/
+
+# .crosslink/ — DO track these (project-level policy):
+# .crosslink/hook-config.json — shared team configuration
+# .crosslink/rules/ — project coding standards
+# .crosslink/.gitignore — inner gitignore for agent files
+
+# .claude/ — auto-generated by crosslink init (not project source)
+.claude/hooks/
+.claude/commands/
+.claude/mcp/
+
+# .claude/ — DO track these (if manually configured):
+# .claude/settings.json — Claude Code project settings
+# .claude/settings.local.json is per-developer, ignore separately if needed
+# === End crosslink managed ===
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..a6e0a22
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,40 @@
+## Shell Script Development Standards (v2.0)
+
+If you're going to write shell scripts, at least try to make them look like a professional wrote them. The following standards are non-negotiable for `git-harden`.
+
+### 1. The Header: No More `sh` From the 80s
+Use `bash` via `env` for portability. We need modern features like arrays and local scoping.
+```bash
+#!/usr/bin/env bash
+set -o errexit # -e: Abort on nonzero exitstatus
+set -o nounset # -u: Abort on unbound variable
+set -o pipefail # Don't hide errors within pipes
+IFS=$'\n\t' # Stop splitting on spaces like a maniac
+```
+
+### 2. Scoping & Immutability (Functional-ish)
+- **Global Constants:** Always `readonly`. Use `UPPER_CASE`.
+- **Functions:** Every variable MUST be `local`. No global state soup.
+- **Returns:** Use `return` for status codes, `echo` to "return" data via command substitution.
+- **Early Returns:** Guard clauses are your friend. Flatten the control flow. If I see more than 3 levels of indentation, I'm quitting.
+
+### 3. Syntax & Safety
+- **Conditionals:** Always use `[[ ... ]]`, not `[ ... ]`. It's safer and less likely to blow up on empty strings.
+- **Arithmetic:** Use `(( ... ))` for numeric comparisons and math.
+- **Subshells:** Use `$(...)`, never backticks. It's not 1985.
+- **Quoting:** Quote EVERYTHING. `"${var}"`, not `$var`. No exceptions.
+- **Tool Checks:** Use `command -v tool_name` to check for dependencies. `which` is for people who don't care about portability.
+
+### 4. Logging & Error Handling
+- **Die Early:** Use a `die()` function for fatal errors.
+- **Stderr:** All logging (info, warn, error) goes to `stderr` (`>&2`). `stdout` is reserved for data/results.
+- **XDG Compliance:** Respect `${XDG_CONFIG_HOME:-$HOME/.config}`. Don't just dump files in `$HOME`.
+- **Temp Files:** Use `mktemp -t` or `mktemp -d`. Clean them up using a `trap`.
+
+### 5. Portability (The macOS/Linux Divide)
+- Avoid `sed -i` (it's different on macOS and Linux). Use a temporary file and `mv`.
+- Use `printf` instead of `echo -e` or `echo -n`.
+- Test on both `bash` 3.2 (macOS default) and 5.x (modern Linux).
+
+### 6. Verification
+- All scripts MUST pass `shellcheck`. If it's yellow or red, it's garbage. Fix it.
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/git-harden.sh b/git-harden.sh
new file mode 100755
index 0000000..a117466
--- /dev/null
+++ b/git-harden.sh
@@ -0,0 +1,956 @@
+#!/usr/bin/env bash
+# git-harden.sh — Audit and harden global git configuration
+# Usage: git-harden.sh [--audit] [-y] [--help]
+
+set -o errexit
+set -o nounset
+set -o pipefail
+IFS=$'\n\t'
+
+# ------------------------------------------------------------------------------
+# Constants
+# ------------------------------------------------------------------------------
+readonly VERSION="1.0.0"
+readonly BACKUP_DIR="${HOME}/.config/git"
+readonly HOOKS_DIR="${HOME}/.config/git/hooks"
+readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers"
+readonly SSH_DIR="${HOME}/.ssh"
+readonly SSH_CONFIG="${SSH_DIR}/config"
+
+# Color codes (empty if not a terminal)
+if [ -t 2 ]; then
+ readonly RED='\033[0;31m'
+ readonly GREEN='\033[0;32m'
+ readonly YELLOW='\033[0;33m'
+ readonly BLUE='\033[0;34m'
+ readonly BOLD='\033[1m'
+ readonly RESET='\033[0m'
+else
+ readonly RED=''
+ readonly GREEN=''
+ readonly YELLOW=''
+ readonly BLUE=''
+ readonly BOLD=''
+ readonly RESET=''
+fi
+
+# Mode flags (mutable — set by parse_args)
+AUTO_YES=false
+AUDIT_ONLY=false
+PLATFORM=""
+
+# Audit counters
+AUDIT_OK=0
+AUDIT_WARN=0
+AUDIT_MISS=0
+
+# Whether signing key was found
+SIGNING_KEY_FOUND=false
+export SIGNING_KEY_PATH="" # private key path; exported for hook/subshell use
+SIGNING_PUB_PATH=""
+
+# Credential helper detected for this platform
+DETECTED_CRED_HELPER=""
+
+# Optional tool availability
+HAS_YKMAN=false
+HAS_FIDO2_TOKEN=false
+
+# ------------------------------------------------------------------------------
+# Helpers
+# ------------------------------------------------------------------------------
+
+die() {
+ printf '%bError:%b %s\n' "$RED" "$RESET" "$1" >&2
+ exit 1
+}
+
+print_ok() {
+ printf '%b[OK]%b %s\n' "$GREEN" "$RESET" "$1" >&2
+ AUDIT_OK=$((AUDIT_OK + 1))
+}
+
+print_warn() {
+ printf '%b[WARN]%b %s\n' "$YELLOW" "$RESET" "$1" >&2
+ AUDIT_WARN=$((AUDIT_WARN + 1))
+}
+
+print_miss() {
+ printf '%b[MISS]%b %s\n' "$RED" "$RESET" "$1" >&2
+ AUDIT_MISS=$((AUDIT_MISS + 1))
+}
+
+print_info() {
+ printf '%b[INFO]%b %s\n' "$BLUE" "$RESET" "$1" >&2
+}
+
+print_header() {
+ printf '\n%b── %s ──%b\n' "$BOLD" "$1" "$RESET" >&2
+}
+
+prompt_yn() {
+ local prompt="$1"
+ local default="${2:-y}"
+
+ if [ "$AUTO_YES" = true ]; then
+ return 0
+ fi
+
+ local yn_hint
+ if [ "$default" = "y" ]; then
+ yn_hint="[Y/n]"
+ else
+ yn_hint="[y/N]"
+ fi
+
+ local answer
+ printf '%s %s ' "$prompt" "$yn_hint" >&2
+ read -r answer &2 <<'EOF'
+Usage: git-harden.sh [OPTIONS]
+
+Audit and harden your global git configuration.
+
+Options:
+ --audit Run audit only (no changes), exit 0 if all OK, 2 if issues found
+ -y, --yes Auto-apply all recommended settings (no prompts)
+ --help, -h Show this help message
+ --version Show version
+
+Exit codes:
+ 0 All settings OK, or changes successfully applied
+ 1 Error (missing dependencies, etc.)
+ 2 Audit found issues (--audit mode only)
+EOF
+}
+
+# ------------------------------------------------------------------------------
+# Platform detection
+# ------------------------------------------------------------------------------
+
+detect_platform() {
+ local uname_out
+ uname_out="$(uname -s)"
+ case "$uname_out" in
+ Darwin*) PLATFORM="macos" ;;
+ Linux*) PLATFORM="linux" ;;
+ *) die "Unsupported platform: $uname_out" ;;
+ esac
+}
+
+# Compare version strings: returns 0 if $1 >= $2
+version_gte() {
+ local IFS_SAVE="$IFS"
+ IFS='.'
+ # shellcheck disable=SC2086
+ set -- $1 $2
+ IFS="$IFS_SAVE"
+ local a1="${1:-0}" a2="${2:-0}" a3="${3:-0}"
+ local b1="${4:-0}" b2="${5:-0}" b3="${6:-0}"
+
+ if [ "$a1" -gt "$b1" ] 2>/dev/null; then return 0; fi
+ if [ "$a1" -lt "$b1" ] 2>/dev/null; then return 1; fi
+ if [ "$a2" -gt "$b2" ] 2>/dev/null; then return 0; fi
+ if [ "$a2" -lt "$b2" ] 2>/dev/null; then return 1; fi
+ if [ "$a3" -ge "$b3" ] 2>/dev/null; then return 0; fi
+ return 1
+}
+
+check_dependencies() {
+ # git required
+ if ! command -v git >/dev/null 2>&1; then
+ die "git is not installed"
+ fi
+
+ local git_version
+ git_version="$(git --version | sed 's/[^0-9.]//g')"
+ if ! version_gte "$git_version" "2.34.0"; then
+ die "git >= 2.34.0 required (found $git_version)"
+ fi
+
+ # ssh-keygen required
+ if ! command -v ssh-keygen >/dev/null 2>&1; then
+ die "ssh-keygen is not installed"
+ fi
+
+ # Optional: ykman
+ if command -v ykman >/dev/null 2>&1; then
+ HAS_YKMAN=true
+ fi
+
+ # Optional: fido2-token
+ if command -v fido2-token >/dev/null 2>&1; then
+ HAS_FIDO2_TOKEN=true
+ fi
+
+ # Detect credential helper
+ detect_credential_helper
+}
+
+detect_credential_helper() {
+ case "$PLATFORM" in
+ macos)
+ DETECTED_CRED_HELPER="osxkeychain"
+ ;;
+ linux)
+ # Try to find libsecret credential helper
+ local libsecret_path=""
+ for path in \
+ /usr/lib/git-core/git-credential-libsecret \
+ /usr/libexec/git-core/git-credential-libsecret \
+ /usr/lib/git/git-credential-libsecret; do
+ if [ -x "$path" ]; then
+ libsecret_path="$path"
+ break
+ fi
+ done
+
+ if [ -n "$libsecret_path" ]; then
+ DETECTED_CRED_HELPER="$libsecret_path"
+ else
+ DETECTED_CRED_HELPER="cache --timeout=3600"
+ fi
+ ;;
+ esac
+}
+
+# ------------------------------------------------------------------------------
+# Audit functions
+# ------------------------------------------------------------------------------
+
+# Check a single git config key against expected value.
+# Returns: prints status, updates counters.
+audit_git_setting() {
+ local key="$1"
+ local expected="$2"
+ local label="${3:-$key}"
+
+ local current
+ current="$(git config --global --get "$key" 2>/dev/null || true)"
+
+ if [ -z "$current" ]; then
+ print_miss "$label (expected: $expected)"
+ elif [ "$current" = "$expected" ]; then
+ print_ok "$label = $current"
+ else
+ print_warn "$label = $current (expected: $expected)"
+ fi
+}
+
+audit_git_config() {
+ print_header "Object Integrity"
+ audit_git_setting "transfer.fsckObjects" "true"
+ audit_git_setting "fetch.fsckObjects" "true"
+ audit_git_setting "receive.fsckObjects" "true"
+
+ print_header "Protocol Restrictions"
+ audit_git_setting "protocol.allow" "never"
+ audit_git_setting "protocol.https.allow" "always"
+ audit_git_setting "protocol.ssh.allow" "always"
+ audit_git_setting "protocol.file.allow" "user"
+ audit_git_setting "protocol.git.allow" "never"
+ audit_git_setting "protocol.ext.allow" "never"
+
+ print_header "Filesystem Protection"
+ audit_git_setting "core.protectNTFS" "true"
+ audit_git_setting "core.protectHFS" "true"
+ audit_git_setting "core.fsmonitor" "false"
+
+ print_header "Hook Control"
+ # shellcheck disable=SC2088 # Intentional: git config stores literal ~
+ audit_git_setting "core.hooksPath" "~/.config/git/hooks"
+
+ print_header "Repository Safety"
+ audit_git_setting "safe.bareRepository" "explicit"
+ audit_git_setting "submodule.recurse" "false"
+
+ print_header "Pull/Merge Hardening"
+ audit_git_setting "pull.ff" "only"
+ audit_git_setting "merge.ff" "only"
+
+ # AC-15: warn if pull.rebase is set (conflicts with pull.ff=only)
+ local pull_rebase
+ pull_rebase="$(git config --global --get pull.rebase 2>/dev/null || true)"
+ if [ -n "$pull_rebase" ]; then
+ print_warn "pull.rebase = $pull_rebase (conflicts with pull.ff=only — consider unsetting)"
+ fi
+
+ print_header "Transport Security"
+ # url..insteadOf needs special handling
+ local instead_of
+ instead_of="$(git config --global --get 'url.https://.insteadOf' 2>/dev/null || true)"
+ if [ -z "$instead_of" ]; then
+ print_miss "url.\"https://\".insteadOf (expected: http://)"
+ elif [ "$instead_of" = "http://" ]; then
+ print_ok "url.\"https://\".insteadOf = http://"
+ else
+ print_warn "url.\"https://\".insteadOf = $instead_of (expected: http://)"
+ fi
+
+ audit_git_setting "http.sslVerify" "true"
+
+ print_header "Credential Storage"
+ local cred_current
+ cred_current="$(git config --global --get credential.helper 2>/dev/null || true)"
+ if [ -z "$cred_current" ]; then
+ print_miss "credential.helper (expected: $DETECTED_CRED_HELPER)"
+ elif [ "$cred_current" = "store" ]; then
+ print_warn "credential.helper = store (INSECURE: stores passwords in plaintext; expected: $DETECTED_CRED_HELPER)"
+ elif [ "$cred_current" = "$DETECTED_CRED_HELPER" ]; then
+ print_ok "credential.helper = $cred_current"
+ else
+ # Non-store, non-recommended — could be user's custom helper
+ print_warn "credential.helper = $cred_current (expected: $DETECTED_CRED_HELPER)"
+ fi
+
+ print_header "Visibility"
+ audit_git_setting "log.showSignature" "true"
+}
+
+audit_signing() {
+ print_header "Signing Configuration"
+
+ audit_git_setting "gpg.format" "ssh"
+ # shellcheck disable=SC2088 # Intentional: git config stores literal ~
+ audit_git_setting "gpg.ssh.allowedSignersFile" "~/.config/git/allowed_signers"
+
+ # Check signing key
+ local signing_key
+ signing_key="$(git config --global --get user.signingkey 2>/dev/null || true)"
+ if [ -z "$signing_key" ]; then
+ print_miss "user.signingkey (no signing key configured)"
+ else
+ # Verify the key file exists
+ local expanded_key
+ expanded_key="${signing_key/#\~/$HOME}"
+ if [ -f "$expanded_key" ]; then
+ print_ok "user.signingkey = $signing_key"
+ else
+ # Key might be an inline key (starts with ssh-)
+ case "$signing_key" in
+ ssh-*|ecdsa-*|sk-*)
+ print_ok "user.signingkey = (inline key)"
+ ;;
+ *)
+ print_warn "user.signingkey = $signing_key (file not found)"
+ ;;
+ esac
+ fi
+ fi
+
+ audit_git_setting "commit.gpgsign" "true"
+ audit_git_setting "tag.gpgsign" "true"
+ audit_git_setting "tag.forceSignAnnotated" "true"
+}
+
+audit_ssh_directive() {
+ local directive="$1"
+ local expected="$2"
+
+ local current
+ current="$(grep -i "^[[:space:]]*${directive}[[:space:]]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^ ]*[[:space:]]*//' || true)"
+
+ if [ -z "$current" ]; then
+ print_miss "SSH: $directive (expected: $expected)"
+ elif [ "$current" = "$expected" ]; then
+ print_ok "SSH: $directive = $current"
+ else
+ print_warn "SSH: $directive = $current (expected: $expected)"
+ fi
+}
+
+audit_ssh_config() {
+ print_header "SSH Configuration"
+
+ if [ ! -f "$SSH_CONFIG" ]; then
+ print_miss "$SSH_CONFIG does not exist"
+ return
+ fi
+
+ audit_ssh_directive "StrictHostKeyChecking" "accept-new"
+ audit_ssh_directive "HashKnownHosts" "yes"
+ audit_ssh_directive "IdentitiesOnly" "yes"
+ audit_ssh_directive "AddKeysToAgent" "yes"
+ audit_ssh_directive "PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com"
+}
+
+print_audit_report() {
+ print_header "Audit Summary"
+ printf '%b %d OK / %d WARN / %d MISS%b\n' \
+ "$BOLD" "$AUDIT_OK" "$AUDIT_WARN" "$AUDIT_MISS" "$RESET" >&2
+
+ if [ $((AUDIT_WARN + AUDIT_MISS)) -gt 0 ]; then
+ return 2
+ fi
+ return 0
+}
+
+# ------------------------------------------------------------------------------
+# Apply functions
+# ------------------------------------------------------------------------------
+
+backup_git_config() {
+ local config_file="${HOME}/.gitconfig"
+ local xdg_config="${HOME}/.config/git/config"
+
+ mkdir -p "$BACKUP_DIR"
+
+ local timestamp
+ timestamp="$(date +%Y%m%d-%H%M%S)"
+ local backup_file="${BACKUP_DIR}/pre-harden-backup-${timestamp}.txt"
+
+ {
+ echo "# git-harden.sh backup — $timestamp"
+ echo "# Global git config snapshot"
+ echo ""
+ if [ -f "$config_file" ]; then
+ echo "## ~/.gitconfig"
+ cat "$config_file"
+ echo ""
+ fi
+ if [ -f "$xdg_config" ]; then
+ echo "## ~/.config/git/config"
+ cat "$xdg_config"
+ echo ""
+ fi
+ echo "## git config --global --list"
+ git config --global --list 2>/dev/null || echo "(no global config)"
+ } > "$backup_file"
+
+ print_info "Config backed up to $backup_file"
+}
+
+apply_git_setting() {
+ local key="$1"
+ local value="$2"
+ local label="${3:-$key}"
+
+ local current
+ current="$(git config --global --get "$key" 2>/dev/null || true)"
+
+ if [ "$current" = "$value" ]; then
+ return 0
+ fi
+
+ if prompt_yn "Set $label = $value?"; then
+ git config --global "$key" "$value"
+ print_info "Set $label = $value"
+ fi
+}
+
+apply_git_config() {
+ print_header "Applying Git Config Hardening"
+
+ # Object integrity
+ apply_git_setting "transfer.fsckObjects" "true"
+ apply_git_setting "fetch.fsckObjects" "true"
+ apply_git_setting "receive.fsckObjects" "true"
+
+ # Protocol restrictions
+ apply_git_setting "protocol.allow" "never"
+ apply_git_setting "protocol.https.allow" "always"
+ apply_git_setting "protocol.ssh.allow" "always"
+ apply_git_setting "protocol.file.allow" "user"
+ apply_git_setting "protocol.git.allow" "never"
+ apply_git_setting "protocol.ext.allow" "never"
+
+ # Filesystem protection
+ apply_git_setting "core.protectNTFS" "true"
+ apply_git_setting "core.protectHFS" "true"
+ apply_git_setting "core.fsmonitor" "false"
+
+ # Hook control
+ mkdir -p "$HOOKS_DIR"
+ # shellcheck disable=SC2088 # Intentional: git config stores literal ~
+ apply_git_setting "core.hooksPath" "~/.config/git/hooks"
+
+ # Repository safety
+ apply_git_setting "safe.bareRepository" "explicit"
+ apply_git_setting "submodule.recurse" "false"
+
+ # Pull/merge hardening
+ apply_git_setting "pull.ff" "only"
+ apply_git_setting "merge.ff" "only"
+
+ # Transport security
+ local instead_of
+ instead_of="$(git config --global --get 'url.https://.insteadOf' 2>/dev/null || true)"
+ if [ "$instead_of" != "http://" ]; then
+ if prompt_yn "Set url.\"https://\".insteadOf = http://?"; then
+ git config --global 'url.https://.insteadOf' 'http://'
+ print_info "Set url.\"https://\".insteadOf = http://"
+ fi
+ fi
+
+ apply_git_setting "http.sslVerify" "true"
+
+ # Credential storage
+ local cred_current
+ cred_current="$(git config --global --get credential.helper 2>/dev/null || true)"
+ if [ "$cred_current" != "$DETECTED_CRED_HELPER" ]; then
+ local cred_prompt="Set credential.helper = $DETECTED_CRED_HELPER?"
+ if [ "$cred_current" = "store" ]; then
+ cred_prompt="Replace INSECURE credential.helper=store with $DETECTED_CRED_HELPER?"
+ fi
+ if prompt_yn "$cred_prompt"; then
+ git config --global credential.helper "$DETECTED_CRED_HELPER"
+ print_info "Set credential.helper = $DETECTED_CRED_HELPER"
+ fi
+ fi
+
+ # Visibility
+ apply_git_setting "log.showSignature" "true"
+}
+
+apply_signing_config() {
+ print_header "Signing Configuration"
+
+ # Always safe to set format and allowed signers
+ apply_git_setting "gpg.format" "ssh"
+ # shellcheck disable=SC2088 # Intentional: git config stores literal ~
+ apply_git_setting "gpg.ssh.allowedSignersFile" "~/.config/git/allowed_signers"
+
+ # Detect existing signing key
+ detect_existing_keys
+
+ if [ "$AUTO_YES" = true ]; then
+ # In -y mode: only enable signing if key exists
+ if [ "$SIGNING_KEY_FOUND" = true ] && [ -n "$SIGNING_PUB_PATH" ] && [ -f "$SIGNING_PUB_PATH" ]; then
+ git config --global user.signingkey "$SIGNING_PUB_PATH"
+ print_info "Set user.signingkey = $SIGNING_PUB_PATH"
+ apply_git_setting "commit.gpgsign" "true"
+ apply_git_setting "tag.gpgsign" "true"
+ apply_git_setting "tag.forceSignAnnotated" "true"
+ setup_allowed_signers
+ else
+ print_info "No SSH signing key found. Skipping commit.gpgsign and tag.gpgsign."
+ print_info "Run git-harden.sh interactively (without -y) to set up signing."
+ fi
+ else
+ # Interactive mode: run the wizard
+ signing_wizard
+ fi
+}
+
+detect_existing_keys() {
+ SIGNING_KEY_FOUND=false
+ SIGNING_KEY_PATH=""
+ SIGNING_PUB_PATH=""
+
+ # Check if a signing key is already configured
+ local configured_key
+ configured_key="$(git config --global --get user.signingkey 2>/dev/null || true)"
+ if [ -n "$configured_key" ]; then
+ local expanded_key
+ expanded_key="${configured_key/#\~/$HOME}"
+ if [ -f "$expanded_key" ]; then
+ SIGNING_KEY_FOUND=true
+ SIGNING_PUB_PATH="$expanded_key"
+ # Derive private key path (remove .pub suffix if present)
+ SIGNING_KEY_PATH="${expanded_key%.pub}"
+ return
+ fi
+ fi
+
+ # Check common ed25519 key locations (sk first, then software)
+ local priv_path pub_path
+ for key_type in id_ed25519_sk id_ed25519; do
+ priv_path="${SSH_DIR}/${key_type}"
+ pub_path="${priv_path}.pub"
+ if [ -f "$pub_path" ]; then
+ SIGNING_KEY_FOUND=true
+ SIGNING_KEY_PATH="$priv_path"
+ SIGNING_PUB_PATH="$pub_path"
+ return
+ fi
+ done
+
+ # Check IdentityFile directives in ~/.ssh/config for custom-named keys
+ if [ -f "$SSH_CONFIG" ]; then
+ local identity_path
+ while IFS= read -r identity_path; do
+ # Expand tilde safely
+ identity_path="${identity_path/#\~/$HOME}"
+ # Strip leading/trailing whitespace
+ identity_path="$(printf '%s' "$identity_path" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
+ [ -z "$identity_path" ] && continue
+
+ pub_path="${identity_path}.pub"
+ if [ -f "$pub_path" ]; then
+ # Only use ed25519 or ed25519-sk keys for signing
+ local key_type_str
+ key_type_str="$(head -1 "$pub_path" 2>/dev/null || true)"
+ case "$key_type_str" in
+ ssh-ed25519*|sk-ssh-ed25519*)
+ SIGNING_KEY_FOUND=true
+ SIGNING_KEY_PATH="$identity_path"
+ SIGNING_PUB_PATH="$pub_path"
+ return
+ ;;
+ esac
+ fi
+ done </dev/null | sed 's/^[[:space:]]*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]*//')
+EOF
+ fi
+}
+
+detect_fido2_hardware() {
+ if [ "$HAS_YKMAN" = true ]; then
+ if ykman info >/dev/null 2>&1; then
+ return 0
+ fi
+ fi
+ if [ "$HAS_FIDO2_TOKEN" = true ]; then
+ if fido2-token -L 2>/dev/null | grep -q .; then
+ return 0
+ fi
+ fi
+ return 1
+}
+
+signing_wizard() {
+ print_header "SSH Signing Setup Wizard"
+
+ if [ "$SIGNING_KEY_FOUND" = true ]; then
+ printf ' Found existing key: %s\n' "$SIGNING_PUB_PATH" >&2
+ if prompt_yn "Use this key for git signing?"; then
+ git config --global user.signingkey "$SIGNING_PUB_PATH"
+ print_info "Set user.signingkey = $SIGNING_PUB_PATH"
+ apply_git_setting "commit.gpgsign" "true"
+ apply_git_setting "tag.gpgsign" "true"
+ apply_git_setting "tag.forceSignAnnotated" "true"
+ setup_allowed_signers
+ return
+ fi
+ fi
+
+ # Offer key generation options
+ local has_fido2=false
+ if detect_fido2_hardware; then
+ has_fido2=true
+ fi
+
+ printf '\n Signing key options:\n' >&2
+ printf ' 1) Generate a new ed25519 SSH key (software)\n' >&2
+ if [ "$has_fido2" = true ]; then
+ printf ' 2) Generate a new ed25519-sk SSH key (FIDO2 hardware key)\n' >&2
+ fi
+ printf ' s) Skip signing setup\n' >&2
+
+ local choice
+ printf '\n Choose [1%s/s]: ' "$(if [ "$has_fido2" = true ]; then printf '/2'; fi)" >&2
+ read -r choice &2
+
+ local email
+ email="$(git config --global --get user.email 2>/dev/null || true)"
+ if [ -z "$email" ]; then
+ printf ' Enter email for key comment: ' >&2
+ read -r email &2
+
+ local email
+ email="$(git config --global --get user.email 2>/dev/null || true)"
+ if [ -z "$email" ]; then
+ printf ' Enter email for key comment: ' >&2
+ read -r email /dev/null || true)"
+ if [ -z "$email" ]; then
+ print_warn "user.email not set — cannot create allowed_signers entry"
+ return
+ fi
+
+ mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")"
+
+ local pub_key
+ pub_key="$(cat "$SIGNING_PUB_PATH")"
+
+ # Check if this entry already exists
+ if [ -f "$ALLOWED_SIGNERS_FILE" ]; then
+ if grep -qF "$pub_key" "$ALLOWED_SIGNERS_FILE" 2>/dev/null; then
+ print_info "Signing key already in allowed_signers"
+ return
+ fi
+ fi
+
+ printf '%s %s\n' "$email" "$pub_key" >> "$ALLOWED_SIGNERS_FILE"
+ print_info "Added signing key to $ALLOWED_SIGNERS_FILE"
+}
+
+# ------------------------------------------------------------------------------
+# SSH config hardening
+# ------------------------------------------------------------------------------
+
+apply_ssh_directive() {
+ local directive="$1"
+ local value="$2"
+
+ # Check if directive already exists with correct value (case-insensitive directive match)
+ local current
+ current="$(grep -i "^[[:space:]]*${directive}[[:space:]]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^ ]*[[:space:]]*//' || true)"
+
+ if [ "$current" = "$value" ]; then
+ return
+ fi
+
+ if [ -n "$current" ]; then
+ # Directive exists but with wrong value
+ if prompt_yn "Update SSH directive: $directive $current -> $value?"; then
+ # Use temp file to avoid sed -i portability issues
+ local tmpfile
+ tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")"
+ # Replace first occurrence of the directive (case-insensitive)
+ local replaced=false
+ while IFS= read -r line || [ -n "$line" ]; do
+ if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]]"; then
+ printf '%s %s\n' "$directive" "$value"
+ replaced=true
+ else
+ printf '%s\n' "$line"
+ fi
+ done < "$SSH_CONFIG" > "$tmpfile"
+ mv "$tmpfile" "$SSH_CONFIG"
+ chmod 600 "$SSH_CONFIG"
+ print_info "Updated $directive = $value in $SSH_CONFIG"
+ fi
+ else
+ # Directive missing entirely
+ if prompt_yn "Add SSH directive: $directive $value?"; then
+ printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG"
+ print_info "Added $directive $value to $SSH_CONFIG"
+ fi
+ fi
+}
+
+apply_ssh_config() {
+ print_header "SSH Config Hardening"
+
+ # Ensure ~/.ssh/ exists with correct permissions
+ if [ ! -d "$SSH_DIR" ]; then
+ mkdir -p "$SSH_DIR"
+ chmod 700 "$SSH_DIR"
+ print_info "Created $SSH_DIR with mode 700"
+ fi
+
+ # Ensure ~/.ssh/config exists with correct permissions
+ if [ ! -f "$SSH_CONFIG" ]; then
+ touch "$SSH_CONFIG"
+ chmod 600 "$SSH_CONFIG"
+ print_info "Created $SSH_CONFIG with mode 600"
+ fi
+
+ apply_ssh_directive "StrictHostKeyChecking" "accept-new"
+ apply_ssh_directive "HashKnownHosts" "yes"
+ apply_ssh_directive "IdentitiesOnly" "yes"
+ apply_ssh_directive "AddKeysToAgent" "yes"
+ apply_ssh_directive "PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com"
+}
+
+# ------------------------------------------------------------------------------
+# Admin recommendations
+# ------------------------------------------------------------------------------
+
+print_admin_recommendations() {
+ print_header "Admin / Org-Level Recommendations"
+ printf ' These are informational and cannot be applied by this script:\n\n' >&2
+ printf ' • Enable branch protection rules on main/master branches\n' >&2
+ printf ' • Enable GitHub vigilant mode (Settings → SSH and GPG keys → Flag unsigned commits)\n' >&2
+ printf ' • Restrict force-pushes (disable or limit to admins)\n' >&2
+ printf ' • Rotate personal access tokens regularly; prefer fine-grained tokens\n' >&2
+ printf ' • Use short-lived credentials (GitHub App tokens, OIDC) in CI/CD\n' >&2
+ printf ' • Require signed commits via branch protection (Require signed commits)\n' >&2
+ printf ' • Audit deploy keys and service account access quarterly\n' >&2
+ printf ' • If using hook frameworks (husky, lefthook, pre-commit), pin versions and review changes\n' >&2
+ printf '\n' >&2
+}
+
+# ------------------------------------------------------------------------------
+# Main
+# ------------------------------------------------------------------------------
+
+main() {
+ parse_args "$@"
+ detect_platform
+ check_dependencies
+
+ # --- Audit phase ---
+ AUDIT_OK=0
+ AUDIT_WARN=0
+ AUDIT_MISS=0
+
+ audit_git_config
+ audit_signing
+ audit_ssh_config
+
+ local audit_exit=0
+ print_audit_report || audit_exit=$?
+
+ if [ "$AUDIT_ONLY" = true ]; then
+ exit "$audit_exit"
+ fi
+
+ # If everything is already OK, nothing to do
+ if [ "$audit_exit" -eq 0 ]; then
+ print_info "All settings already match recommendations. Nothing to do."
+ print_admin_recommendations
+ exit 0
+ fi
+
+ # --- Apply phase ---
+ if [ "$AUTO_YES" = false ]; then
+ printf '\n' >&2
+ if ! prompt_yn "Proceed with hardening?"; then
+ print_info "Aborted."
+ exit 0
+ fi
+ fi
+
+ backup_git_config
+ apply_git_config
+ apply_signing_config
+ apply_ssh_config
+ print_admin_recommendations
+
+ print_info "Hardening complete. Re-run with --audit to verify."
+}
+
+main "$@"