diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e05f804..6296af6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ jobs: - name: shellcheck plain shell scripts run: | shellcheck install.sh home/*.sh + # ~/.local/bin shims. These shadow real binaries on $PATH (gh, git), + # so they are the most consequential shell in the repo — they were + # previously outside the linter's reach entirely. + shellcheck home/dot_local/bin/* shellcheck --shell=bash home/dot_claude/modify_settings.json # Acceptance gate from the migration design: a throwaway container applies the diff --git a/home/.chezmoiignore b/home/.chezmoiignore index c958331..0ac2613 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -28,6 +28,18 @@ Library/** .claude/** {{ end }} +# Agent-only credential shims. `gh` and `git` here SHADOW the real binaries on +# $PATH: gh injects a per-org fine-grained PAT, git refuses force-push and +# branch-delete. On a machine with no ~/.config/github-pats that would break +# github.com authentication outright, so they must never land on a non-agent +# host. git-credential-github-org is inert without them but is gated the same +# way for coherence. +{{ if not .agent }} +.local/bin/gh +.local/bin/git +.local/bin/git-credential-github-org +{{ end }} + # cmux is a macOS app; its dock.json (with ssh -t commands run FROM the Mac) only # applies there. Ignore on non-Mac so agent/Linux hosts don't get a useless copy. {{ if ne .chezmoi.os "darwin" }} diff --git a/home/dot_local/bin/executable_claude-memory-backup b/home/dot_local/bin/executable_claude-memory-backup index f976260..bf6fb6b 100644 --- a/home/dot_local/bin/executable_claude-memory-backup +++ b/home/dot_local/bin/executable_claude-memory-backup @@ -15,7 +15,8 @@ PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:/opt/homebrew/bin:/usr/loca export PATH # multiple machines push to this repo — sync before mirroring -cd "$WORK" && git pull --rebase -q origin main 2>/dev/null || true +cd "$WORK" +git pull --rebase -q origin main 2>/dev/null || true found=0 for d in "$SRC"/*/memory; do diff --git a/home/dot_local/bin/executable_gh b/home/dot_local/bin/executable_gh new file mode 100755 index 0000000..90293ad --- /dev/null +++ b/home/dot_local/bin/executable_gh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# gh — hand the GitHub CLI the same per-org PAT that git gets from +# git-credential-github-org. Shadows the real gh because ~/.local/bin precedes +# both the mise shims and /usr/bin on $PATH. +# +# Adapted from Ben's github-per-org-auth/gh. Norm's changes vs upstream: +# 1. AUDIT: one JSONL line per invocation to $NORM_GH_AUDIT. Never the token. +# 2. NO DEFAULT OWNER. Upstream falls back to a hardcoded `clickfunnels2` for +# repo-less calls (`gh api /user`, `gh search`), so a call with no repo +# context silently carries a work-org credential. Upstream's own comment +# says it "runs unauthenticated rather than guessing", which the code then +# contradicts. We do what the comment says. +# 3. REAL_GH is resolved by scanning $PATH first, not a hardcoded list. +# Upstream's list starts at /usr/bin/gh, which on this box would bypass the +# mise-managed gh that would otherwise run and silently change versions. +# 4. Owner validation, and support for POSITIONAL owner/repo (`gh repo view +# owner/repo`) which upstream never checks — despite its own README using +# exactly that as the verification step. +set -euo pipefail + +TOKEN_DIR="${GITHUB_PAT_DIR:-$HOME/.config/github-pats}" +AUDIT_LOG="${NORM_GH_AUDIT:-$HOME/.local/state/norm-gh-audit.jsonl}" + +# ── Audit ─────────────────────────────────────────────────────────────────── +# Fail-OPEN but LOUD, same reasoning as the credential helper: a record is not a +# gate, but a silent gap is indistinguishable from "nothing happened". +_audit() { + local event="$1" owner="${2:-}" token_file="${3:-}" + shift 3 2>/dev/null || true + local argv_json="[]" first=1 a + for a in "$@"; do + a="${a//\\/\\\\}"; a="${a//\"/\\\"}" + if [ $first -eq 1 ]; then argv_json="[\"$a\""; first=0; else argv_json="$argv_json,\"$a\""; fi + done + [ $first -eq 0 ] && argv_json="$argv_json]" + local line + printf -v line '{"ts":"%s","tool":"gh","event":"%s","owner":"%s","token_file":"%s","argv":%s,"pid":%d,"cwd":"%s"}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$event" "$owner" "$token_file" "$argv_json" "$$" "$PWD" 2>/dev/null || return 0 + # Create with 0600 BEFORE the first append. Relying on the ambient umask gave + # a world-readable 0644 log — the same defect I flagged in someone else's + # vault writer, so it gets fixed here rather than excused. + if ! { mkdir -p "$(dirname "$AUDIT_LOG")" 2>/dev/null \ + && { [ -e "$AUDIT_LOG" ] || { : >"$AUDIT_LOG" && chmod 0600 "$AUDIT_LOG"; } ; } 2>/dev/null \ + && printf '%s' "$line" >>"$AUDIT_LOG" 2>/dev/null; }; then + printf 'gh (wrapper): WARNING audit write failed (%s) — continuing\n' "$AUDIT_LOG" >&2 + fi +} + +# ── Locate the real gh ────────────────────────────────────────────────────── +# Scan $PATH in order so we run whatever gh would have run without this +# wrapper, but reject any candidate that could send control back to us. +# +# ⚠️ Learned the hard way. A first version scanned $PATH with only a +# `readlink -f == self` guard and produced an INFINITE EXEC LOOP on this box: +# the next $PATH entry was a mise shim, and for a tool mise is NOT actively +# managing, that shim just re-resolves the command through $PATH — finding this +# wrapper again. The two exec each other until "Argument list too long". The +# self-guard cannot catch it, because the shim resolves to the `mise` binary, +# not to us: the loop is INDIRECT. +# +# The fix is the predicate, not the ordering. Reject a candidate if it is us, if +# it lives under a `shims/` directory, or if it resolves to a binary not named +# `gh` (a real gh always resolves to something called gh; a punt-through shim +# does not). The concrete system paths remain as a fallback. +self="$(readlink -f "${BASH_SOURCE[0]}")" + +_is_usable_gh() { + local cand="$1" resolved + [[ -x "$cand" && ! -d "$cand" ]] || return 1 + resolved="$(readlink -f "$cand" 2>/dev/null)" || return 1 + [[ "$resolved" == "$self" ]] && return 1 # us + [[ "$cand" == */shims/* ]] && return 1 # version-manager shim + [[ "$(basename "$resolved")" != "gh" ]] && return 1 # punts elsewhere (e.g. -> mise) + return 0 +} + +REAL_GH="" +IFS=':' read -r -a _path_dirs <<<"$PATH" +for d in "${_path_dirs[@]}"; do + [ -n "$d" ] || continue + _is_usable_gh "$d/gh" && { REAL_GH="$d/gh"; break; } +done +if [[ -z "$REAL_GH" ]]; then + for cand in /usr/bin/gh /usr/local/bin/gh /opt/homebrew/bin/gh; do + _is_usable_gh "$cand" && { REAL_GH="$cand"; break; } + done +fi +if [[ -z "$REAL_GH" ]]; then + echo "gh (wrapper): cannot find the real gh binary — only this wrapper is on PATH." >&2 + _audit error "" "" "$@" + exit 127 +fi + +# ── When NOT to inject ────────────────────────────────────────────────────── +# A token already in the env means the caller chose one deliberately (e.g. a +# brokered `broker-run --cred GH_TOKEN`). Injecting over it would silently swap +# in the narrow per-org token. Same precedence rule as the git helper. +if [[ -n "${GH_TOKEN:-}${GITHUB_TOKEN:-}" ]]; then + _audit stood_down "" "" "$@" + exec "$REAL_GH" "$@" +fi + +# gh's own auth commands manage credentials themselves and refuse to run with a +# token in the environment. Leave that path alone. +# `git-credential` is included because the pre-existing gitconfig may still list +# `!gh auth git-credential` as a helper; routing it through owner resolution +# achieves nothing and just adds a no_owner line per git operation. +if [[ "${1:-}" == "auth" && "${2:-}" =~ ^(login|logout|refresh|setup-git|status|token|git-credential)$ ]]; then + _audit auth_passthrough "" "" "$@" + exec "$REAL_GH" "$@" +fi + +# ── Resolve the repo owner ────────────────────────────────────────────────── +owner_from_spec() { + local spec="${1:-}" + spec="${spec#https://github.com/}" + spec="${spec#git@github.com:}" + spec="${spec#github.com/}" + printf '%s' "${spec%%/*}" +} + +owner="" +args=("$@") + +# 1. Explicit --repo / -R wins — it is what gh itself uses. +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[i]}" in + -R|--repo) owner="$(owner_from_spec "${args[i+1]:-}")"; break ;; + --repo=*) owner="$(owner_from_spec "${args[i]#--repo=}")"; break ;; + -R?*) owner="$(owner_from_spec "${args[i]#-R}")"; break ;; + esac +done + +# 2. gh's own GH_REPO env var. +if [[ -z "$owner" && -n "${GH_REPO:-}" ]]; then + owner="$(owner_from_spec "$GH_REPO")" +fi + +# 3. An owner embedded in a `gh api` path. +if [[ -z "$owner" && "${1:-}" == "api" ]]; then + for arg in "$@"; do + [[ "$arg" == -* ]] && continue # don't match flag values + if [[ "$arg" =~ (^|/)(repos|orgs|users)/([^/]+) ]]; then + owner="${BASH_REMATCH[3]}"; break + fi + done +fi + +# 4. POSITIONAL owner/repo — upstream never checks this, so `gh repo view +# owner/repo` (the README's own verification command) fell through to the +# default token. Restricted to the subcommands that actually take a repo +# positionally, so a path argument like src/index.ts can't be mistaken for one. +if [[ -z "$owner" && "${1:-}" == "repo" ]]; then + case "${2:-}" in + view|clone|fork|sync|rename|delete|set-default|deploy-key|license|gitignore) + for ((i = 2; i < ${#args[@]}; i++)); do + a="${args[i]}" + [[ "$a" == -* ]] && continue + if [[ "$a" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then + owner="$(owner_from_spec "$a")"; break + fi + done ;; + esac +fi + +# 5. Fall back to the current repo's origin remote — what gh resolves against +# anyway when no --repo is given. +if [[ -z "$owner" ]]; then + remote="$(git remote get-url origin 2>/dev/null || true)" + [[ -n "$remote" ]] && owner="$(owner_from_spec "$remote")" +fi + +# 6. Still nothing. Upstream defaults to a hardcoded org here; we do NOT. +# A repo-less call carrying a credential the caller didn't choose is exactly +# the class of mistake the ambient path has no audit trail for. Run +# unauthenticated and let it fail visibly. +if [[ -z "$owner" ]]; then + _audit no_owner "" "" "$@" + exec "$REAL_GH" "$@" +fi + +owner="$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')" + +if [[ ! "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then + _audit rejected "$owner" "" "$@" + exec "$REAL_GH" "$@" +fi + +# ── Load the token ────────────────────────────────────────────────────────── +token_file="$TOKEN_DIR/$owner" +if [[ ! -r "$token_file" ]]; then + for candidate in "$TOKEN_DIR"/*; do + [[ -f "$candidate" ]] || continue + if [[ "$(printf '%s' "${candidate##*/}" | tr '[:upper:]' '[:lower:]')" == "$owner" ]]; then + token_file="$candidate"; break + fi + done +fi + +if [[ -r "$token_file" ]]; then + token="$(<"$token_file")" + token="${token//[$'\r\n\t ']/}" + if [[ -n "$token" ]]; then + _audit issued "$owner" "${token_file##*/}" "$@" + export GH_TOKEN="$token" + exec "$REAL_GH" "$@" + fi +fi + +# No token for this owner: run unauthenticated rather than guessing with another +# org's PAT. +# +# ⚠️ Verified by execution 2026-08-01, and NOT what upstream's comment claims: +# `gh` does not degrade to anonymous access the way `git` does. With no token it +# refuses outright — "To get started with GitHub CLI, please run: gh auth login" +# — even for a PUBLIC repo. So for an owner with no token file, gh is simply +# unusable via this path and the work has to go through the broker. `git` is the +# asymmetric one: it clones public repos fine with no credential. +# The audit line is what distinguishes this from a real "repo not found". +_audit no_token "$owner" "" "$@" +exec "$REAL_GH" "$@" diff --git a/home/dot_local/bin/executable_git b/home/dot_local/bin/executable_git new file mode 100755 index 0000000..9050232 --- /dev/null +++ b/home/dot_local/bin/executable_git @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# git — thin wrapper that refuses history-destroying pushes and tells Nate. +# +# ⚠️ WHAT THIS IS, PRECISELY: an ACCIDENT CATCHER. It is NOT a security control +# and must never be described as one in a doc, a PR, or a note to anyone. +# The thing being restricted is the thing enforcing the restriction: anything +# running as norm can call /usr/bin/git directly, use the REST API, or set +# NORM_ALLOW_FORCE=1. It stops a mistake — a reflexive `push -f`, a stray +# `--delete` — and it makes the attempt visible. That is the whole claim. +# +# It exists because the broker's hard-deny on force-push and branch-delete does +# NOT apply to the ambient per-org PAT path (verified 2026-08-01: a brokered +# --delete is denied, an ambient one succeeds). Branch protection covers default +# branches; nothing covered feature branches. This covers the mistake case, and +# the notification covers the rest by making it loud rather than silent. +# +# DESIGN RULE, deliberately opposite to a security gate: FAIL OPEN TO REAL GIT. +# git is on the critical path of nearly everything here. Breaking every git +# invocation would be far worse than missing one force-push, so anything +# unexpected delegates to the real binary rather than erroring. No `set -e`. + +AUDIT_LOG="${NORM_GH_AUDIT:-$HOME/.local/state/norm-gh-audit.jsonl}" + +# ── Resolve the real git ──────────────────────────────────────────────────── +# Same lesson as the gh wrapper: never accept a version-manager shim, or we +# exec each other until "Argument list too long". git is not shimmed on this +# host today, but the predicate costs nothing and the failure mode is ugly. +_self="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null)" +_usable() { + local c="$1" r + [ -x "$c" ] && [ ! -d "$c" ] || return 1 + r="$(readlink -f "$c" 2>/dev/null)" || return 1 + [ "$r" = "$_self" ] && return 1 + case "$c" in */shims/*) return 1 ;; esac + [ "$(basename "$r")" = "git" ] || return 1 + return 0 +} +# $PATH order first (so we run whatever git would have run), hardcoded paths as +# the fallback — same shape as the gh wrapper. Resolving hardcoded-first looks +# safer but isn't: it silently ignores $PATH, which also made this wrapper +# untestable (a fake git on a test $PATH was never reached). +REAL_GIT="" +IFS=':' read -r -a _dirs <<<"$PATH" +for d in "${_dirs[@]}"; do + [ -n "$d" ] && _usable "$d/git" && { REAL_GIT="$d/git"; break; } +done +if [ -z "$REAL_GIT" ]; then + for c in /usr/bin/git /usr/local/bin/git /opt/homebrew/bin/git; do + _usable "$c" && { REAL_GIT="$c"; break; } + done +fi +# Cannot find a real git: get out of the way rather than break the world. +[ -z "$REAL_GIT" ] && { echo "git (norm wrapper): no real git found; refusing to interfere." >&2; exit 127; } + +# ── Fast path: anything that is not a push is none of our business ────────── +# Scan only for the FIRST non-option token, so `git -C /x push` is still seen as +# a push while `git commit -f`-style flags elsewhere are ignored entirely. +_subcmd="" +_i=1 +while [ $_i -le $# ]; do + _a="${!_i}" + case "$_a" in + -C|--git-dir|--work-tree|--namespace|-c|--exec-path) _i=$((_i+2)); continue ;; + -*) _i=$((_i+1)); continue ;; + *) _subcmd="$_a"; break ;; + esac +done +[ "$_subcmd" = "push" ] || exec "$REAL_GIT" "$@" + +# ── Classify the push ────────────────────────────────────────────────────── +_reason="" +for _a in "$@"; do + case "$_a" in + --force|--force-with-lease|--force-with-lease=*|--force-if-includes) + _reason="non-fast-forward push ($_a)" ; break ;; + --delete|--mirror|--prune) + _reason="remote ref deletion ($_a)" ; break ;; + # Short-option clusters: -f, -fu, -df ... but NOT long options. + -[!-]*) + case "$_a" in + *f*) _reason="non-fast-forward push ($_a contains -f)" ; break ;; + *d*) _reason="remote ref deletion ($_a contains -d)" ; break ;; + esac ;; + # Refspecs: leading '+' forces; a leading ':' with no source deletes. + +*) _reason="non-fast-forward push (refspec $_a)" ; break ;; + :*) _reason="remote ref deletion (refspec $_a)" ; break ;; + esac +done + +# Not destructive -> straight through. +[ -z "$_reason" ] && exec "$REAL_GIT" "$@" + +# ── Record + notify ──────────────────────────────────────────────────────── +_audit() { # $1 = event, $2 = detail + local line argv_json="[]" first=1 a + for a in "$@"; do :; done + for a in "${_ORIG_ARGV[@]}"; do + a="${a//\\/\\\\}"; a="${a//\"/\\\"}" + if [ $first -eq 1 ]; then argv_json="[\"$a\""; first=0; else argv_json="$argv_json,\"$a\""; fi + done + [ $first -eq 0 ] && argv_json="$argv_json]" + printf -v line '{"ts":"%s","tool":"git-guard","event":"%s","detail":"%s","argv":%s,"pid":%d,"cwd":"%s"}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" "$argv_json" "$$" "$PWD" 2>/dev/null || return 0 + { mkdir -p "$(dirname "$AUDIT_LOG")" 2>/dev/null \ + && { [ -e "$AUDIT_LOG" ] || { : >"$AUDIT_LOG" && chmod 0600 "$AUDIT_LOG"; } ; } 2>/dev/null \ + && printf '%s' "$line" >>"$AUDIT_LOG" 2>/dev/null; } \ + || printf 'git (norm wrapper): WARNING audit write failed\n' >&2 +} +_ORIG_ARGV=("$@") + +_notify() { # $1 = html message. Never allowed to block or fail the caller. + command -v broker-run >/dev/null 2>&1 || return 0 + printf '%s' "$1" | timeout 20 broker-run --notify >/dev/null 2>&1 || \ + printf 'git (norm wrapper): notification failed (message still in the audit log)\n' >&2 +} + +_where="$(${REAL_GIT} rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PWD")" +_remote="$(${REAL_GIT} remote get-url origin 2>/dev/null | sed -E 's#.*github.com[:/]##; s#\.git$##')" + +if [ -n "${NORM_ALLOW_FORCE:-}" ]; then + _audit override_used "$_reason" + _notify "⚠️ **norm used NORM_ALLOW_FORCE** +${_reason} +repo: ${_remote:-$_where} +cmd: \`git $*\` +This was a deliberate override of the force-push guard and it went through." + exec "$REAL_GIT" "$@" +fi + +_audit blocked "$_reason" +_notify "🛑 **norm blocked a destructive git push** +${_reason} +repo: ${_remote:-$_where} +cmd: \`git $*\` +Refused by the client-side guard. Re-run with NORM_ALLOW_FORCE=1 if it was intended." + +cat >&2 </dev/null || return 0 + # Create with 0600 BEFORE the first append — the ambient umask would otherwise + # leave a world-readable 0644 log of every credentialed operation. + if ! { mkdir -p "$(dirname "$AUDIT_LOG")" 2>/dev/null \ + && { [ -e "$AUDIT_LOG" ] || { : >"$AUDIT_LOG" && chmod 0600 "$AUDIT_LOG"; } ; } 2>/dev/null \ + && printf '%s' "$line" >>"$AUDIT_LOG" 2>/dev/null; }; then + printf 'git-credential-github-org: WARNING audit write failed (%s) — credential still issued\n' \ + "$AUDIT_LOG" >&2 + fi +} + +# Only "get" produces output. store/erase are no-ops: the token files are the +# source of truth, so there is nothing to cache or invalidate. +[[ "${1:-}" == "get" ]] || exit 0 + +# ── Stand down when the proxy/broker already injected a credential ────────── +# Git consults credential helpers BEFORE falling back to GIT_ASKPASS. An +# unconditional answer here would shadow a deliberately-chosen, approval-gated +# token. Non-empty $GITHUB_TOKEN means "this invocation already went through the +# broker and was granted a specific credential" — defer to it. +# Logged, because "why did it use the other token" is a real debugging question. +if [[ -n "${GITHUB_TOKEN:-}" ]]; then + _audit stood_down "" "" "GITHUB_TOKEN already set" + exit 0 +fi + +# Parse git's request: key=value lines terminated by a blank line. +host="" ; wire_path="" +while IFS= read -r line; do + [[ -z "$line" ]] && break + case "$line" in + host=*) host="${line#host=}" ;; + path=*) wire_path="${line#path=}" ;; + esac +done + +[[ "$host" == "github.com" ]] || { _audit skipped "" "" "host=$host"; exit 0; } + +# path looks like "clickfunnels2/clicky.git" — first segment is the owner. +owner="${wire_path%%/*}" +[[ -n "$owner" ]] || { _audit skipped "" "" "no path (is credential.useHttpPath set?)"; exit 0; } +owner="$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')" + +# Norm's change vs upstream: reject anything that isn't a plausible owner, so a +# crafted remote path can't reach outside $TOKEN_DIR (upstream lets ".." through +# to `$TOKEN_DIR/..`, which is harmless today only by accident). +if [[ ! "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then + _audit rejected "$owner" "" "owner failed validation" + exit 0 +fi + +token_file="$TOKEN_DIR/$owner" + +# Tolerate a differently-cased FILENAME. A silent miss looks identical to "the +# token is broken", which costs far more debugging time than this loop costs. +if [[ ! -r "$token_file" ]]; then + for candidate in "$TOKEN_DIR"/*; do + [[ -f "$candidate" ]] || continue + if [[ "$(printf '%s' "${candidate##*/}" | tr '[:upper:]' '[:lower:]')" == "$owner" ]]; then + token_file="$candidate" + break + fi + done +fi + +if [[ ! -r "$token_file" ]]; then + # Exit silently and let git proceed unauthenticated — correct for public + # third-party repos. On a PRIVATE repo this surfaces as a 404, not an auth + # error, which is why this line is audited: the log is the only place that + # distinguishes "no token for this owner" from "repo doesn't exist". + _audit no_token "$owner" "" "no readable token file" + exit 0 +fi + +token="$(<"$token_file")" +token="${token//[$'\r\n\t ']/}" +if [[ -z "$token" ]]; then + _audit no_token "$owner" "${token_file##*/}" "token file empty" + exit 0 +fi + +_audit issued "$owner" "${token_file##*/}" "" + +# GitHub ignores the username when the password is a valid PAT; x-access-token +# is the conventional placeholder. +printf 'username=x-access-token\n' +printf 'password=%s\n' "$token"