#!/usr/bin/env bash # The personal half of the repository. # # user/ is somebody's actual agent instructions and SSH host aliases, tracked in # a repository other people are meant to clone. That is only defensible under # three rules, and this pins all three: # # 1. Nothing is linked on a machine that did not say yes. A stranger who runs # ./install and answers the default gets their own ~/.claude/CLAUDE.md left # exactly where it was. # 2. Nothing already in a destination is destroyed. It moves to config/old/, # the same promise link-dotfiles makes. # 3. No secrets. The directory is world-readable to anyone who finds the # repository, so a private key or token committed here is a disclosure and # not a mistake to be caught in review. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" linker="$repo_dir/setup/scripts/link-user" manifest="$repo_dir/user/manifest" interview="$repo_dir/setup/scripts/interview" installer="$repo_dir/install" findings=() note() { findings+=("$1"); } [[ -x "$linker" ]] || { printf 'user content contract: %s is not executable\n' "$linker" >&2; exit 1; } [[ -r "$manifest" ]] || { printf 'user content contract: no manifest at %s\n' "$manifest" >&2; exit 1; } # ── 3. No secrets ─────────────────────────────────────────────────────────── # # Checked first, because it is the one failure that cannot be undone by fixing # the code: a key that reached a commit is a key that must be rotated. if grep -rlqE 'BEGIN [A-Z ]*PRIVATE KEY' "$repo_dir/user" 2>/dev/null; then note 'a private key is committed under user/' fi if grep -rlqE 'sk-ant-[A-Za-z0-9]|ghp_[A-Za-z0-9]{20}|xox[baprs]-[A-Za-z0-9]' "$repo_dir/user" 2>/dev/null; then note 'something that looks like an API token is committed under user/' fi while read -r candidate; do note "user/ carries a credentials file: ${candidate#"$repo_dir"/}" done < <(find "$repo_dir/user" -type f \( -name 'id_*' -o -name '*.pem' -o -name '.credentials.json' \) 2>/dev/null) # ── Every manifest line is usable ─────────────────────────────────────────── while read -r kind source destination; do [[ -e "$repo_dir/user/$source" ]] \ || note "the manifest points at $source, which is not in user/" [[ "$kind" == link || "$kind" == linkdir || "$kind" == copy ]] \ || note "the manifest uses an unknown kind: $kind" [[ "$destination" == '~/'* ]] \ || note "the manifest destination $destination is not under the home directory" done < <(grep -vE '^\s*(#|$)' "$manifest") # ── The behaviour, against a fake home ────────────────────────────────────── work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # A checkout of its own, so the test never links anything into the real home and # never moves a real file into the real config/old. checkout="$work/Panama" mkdir -p "$checkout/setup/scripts" "$checkout/user/agents" cp "$linker" "$checkout/setup/scripts/link-user" printf 'tracked instructions\n' >"$checkout/user/agents/AGENTS.md" mkdir -p "$checkout/user/agents/skills/example" printf 'a skill\n' >"$checkout/user/agents/skills/example/SKILL.md" printf 'copied once\n' >"$checkout/user/plain.txt" cat >"$checkout/user/manifest" <<'FIXTURE' # a comment, and a blank line follow link agents/AGENTS.md ~/.claude/CLAUDE.md link agents/skills ~/.agents/skills linkdir agents/skills ~/.claude/skills copy plain.txt ~/.config/plain.txt link missing.txt ~/.config/missing.txt FIXTURE home="$work/home" run() { HOME="$home" XDG_STATE_HOME="$work/state" PANAMA_PATH="$checkout" \ "$checkout/setup/scripts/link-user" "$@" >"$work/log" 2>&1 } # ── 1. Silence unless asked ───────────────────────────────────────────────── mkdir -p "$home/.claude" printf 'somebody else instructions\n' >"$home/.claude/CLAUDE.md" run [[ "$(cat "$home/.claude/CLAUDE.md")" == "somebody else instructions" ]] \ || note 'personal content was linked on a machine that was never asked' [[ -e "$home/.agents/skills" ]] \ && note 'skills were linked on a machine that was never asked' PANAMA_USER_CONTENT=no run [[ "$(cat "$home/.claude/CLAUDE.md")" == "somebody else instructions" ]] \ || note 'personal content was linked on a machine that answered no' # ── 2. Saying yes links, and keeps what was there ─────────────────────────── # ~/.claude/skills is shared now: link-skills has already made it a real # directory and linked Panama's own skills into it. A linkdir entry has to land # beside those rather than replace the directory holding them. mkdir -p "$home/.claude/skills" ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped" PANAMA_USER_CONTENT=yes run [[ -L "$home/.claude/CLAUDE.md" ]] \ || note 'CLAUDE.md was not replaced with a symlink into the checkout' [[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \ || note 'the CLAUDE.md link does not resolve to the tracked file' [[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \ || note 'the skills directory was not linked as a directory' [[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \ || note 'a linkdir entry replaced its destination directory with a symlink' [[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \ || note 'a linkdir entry did not link each child into the destination' [[ -L "$home/.claude/skills/shipped" ]] \ || note 'a linkdir entry removed what another stage had linked into the destination' [[ -f "$home/.config/plain.txt" && ! -L "$home/.config/plain.txt" ]] \ || note 'a copy entry was linked rather than copied' if ! grep -rq 'somebody else instructions' "$checkout/config/old" 2>/dev/null; then note 'the file that was already there was not preserved in config/old' fi # A source the manifest names but the checkout does not have is skipped, not # fatal: half a manifest applied is better than none, and the log says which. [[ -e "$home/.config/missing.txt" ]] \ && note 'a manifest entry with no source produced a destination anyway' grep -q 'missing.txt' "$work/log" \ || note 'a skipped manifest entry was not reported' # ── The answer sticks ─────────────────────────────────────────────────────── # # So that `panama upgrade` on a configured machine relinks without an interview, # and a machine that said no stays quiet forever. printf 'edited by hand\n' >"$home/.config/plain.txt" rm -f "$home/.claude/CLAUDE.md" run # no PANAMA_USER_CONTENT this time [[ -L "$home/.claude/CLAUDE.md" ]] \ || note 'the recorded answer was not remembered, so an upgrade would not relink' [[ "$(cat "$home/.config/plain.txt")" == "edited by hand" ]] \ || note 'a copy entry overwrote a file the machine had edited' # Relinking twice must not bury the previous run's symlink in config/old. before="$(find "$checkout/config/old" -type f 2>/dev/null | wc -l)" run after="$(find "$checkout/config/old" -type f 2>/dev/null | wc -l)" [[ "$before" == "$after" ]] \ || note 'relinking backed up its own symlink, so config/old grows every run' # ── Wiring ────────────────────────────────────────────────────────────────── grep -q 'link-user' "$installer" \ || note 'link-user is not in the installer STAGES list, so it never runs' grep -q 'PANAMA_USER_CONTENT' "$installer" \ || note 'the installer does not export the personal-content answer' grep -q 'PANAMA_USER_CONTENT' "$interview" \ || note 'the interview never asks about personal content' # Order matters: link-user must land the tracked espanso identity before # setup-identity would seed one from the interview answers. python3 - "$installer" <<'PY' || note 'link-user does not run before setup-identity' import re, sys line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES=")) stages = re.findall(r"[\w-]+", line) if stages.index("link-user") > stages.index("setup-identity"): raise SystemExit(1) PY if (( ${#findings[@]} > 0 )); then printf 'user content contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'user content contract: PASS\n'