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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
# dotfiles-413h
title: Document role-name convention + fail-loud role resolution (ADRs) and finish personal->home drift
status: completed
type: task
priority: high
created_at: 2026-06-12T23:34:48Z
updated_at: 2026-06-12T23:37:31Z
---

Captured the role-name convention as a foundational ADR and the fail-loud guard as a change, plus finished the personal->home drift in code/docs.

## Checklist

- [x] Fix install.sh: personal -> home (was still emitting the dead name)
- [x] Fix doc/architecture.md prose: personal -> home + detection/duplication note
- [x] Write ADR 0035: canonical DOTPICKLES_ROLE names (home/work/container, personal retired)
- [x] Write ADR 0036: fail-loud role resolution guard (amends 0031 + 0035)
- [x] Correct stale personal refs in ADR 0031 + amendment note
- [x] Update doc/adr/README.md index for 0035 + 0036
- [x] Detection-duplication documented as accepted cross-shell wart in 0035/0036 (not a TODO)
- [x] Prettier clean on all touched markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
# dotfiles-f8oz
title: Fix personal->home role mismatch breaking agent git identity
status: completed
type: bug
priority: high
created_at: 2026-06-12T01:53:13Z
updated_at: 2026-06-12T01:57:50Z
---

DOTPICKLES_ROLE=home had no matching claude/roles/home.jsonc (only personal.jsonc). claudeconfig.sh silently skipped the role merge, so ~/.claude/settings.json got no env block, GIT_CONFIG_GLOBAL was never set, and Claude git commits fell through to op-ssh-sign -> 1Password prompt every commit. Fixed by completing the personal->home role rename and adding a loud-fail guard.

## Checklist

- [x] git mv claude/roles/personal.jsonc -> home.jsonc
- [x] git mv home/.gitconfig.d/claude-agent-personal -> claude-agent-home
- [x] Update GIT_CONFIG_GLOBAL ref + comments in home.jsonc
- [x] Update comment in claude-agent-home (kept personal-agent identity)
- [x] Fix stale personal default in home/.zshenv
- [x] Fix stale DOTPICKLES_ROLE:personal in fish_variables (gitignored, local only)
- [x] claudeconfig.sh: default ROLE home + loud-fail guard for missing role file
- [x] Regenerate ~/.claude/settings.json (env.GIT_CONFIG_GLOBAL now present)
- [x] Relink not needed (~/.gitconfig.d is a symlinked dir)
- [x] Verified: ssh-keygen signs non-interactively, commit authored as personal-agent, no 1Password prompt

Note: local git log --show-signature shows No signature because gpg.ssh.allowedSignersFile is unset; GitHub still shows Verified via enrolled key. Optional follow-up if local verification matters.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
# dotfiles-wfq3
title: Add gpg.ssh.allowedSignersFile for local commit verification
status: completed
type: feature
priority: normal
created_at: 2026-06-12T23:16:08Z
updated_at: 2026-06-12T23:19:44Z
---

Added local SSH allowed-signers trust file so git can verify SSH-signed commits locally.

## Checklist

- [x] Create home/.gitconfig.d/allowed_signers with human + personal-agent identities
- [x] Add allowedSignersFile to home/.gitconfig.d/signing
- [x] Verify git log --show-signature reports Good signature (HEAD 7e45125 = G)
- [x] Write ADR 0034

Result: personal-agent + personal-human commits verify as G. Work-agent commits show U (No principal matched) until work identity is appended. GitHub PR-merge commits show E (GPG/RSA, not SSH). All expected.
11 changes: 8 additions & 3 deletions claude/roles/personal.jsonc → claude/roles/home.jsonc
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
{
// Personal role agent identity (see ADR 0031).
// Every Claude Code session under DOTPICKLES_ROLE=personal commits and
// Home role agent identity (see ADR 0031).
// Every Claude Code session under DOTPICKLES_ROLE=home commits and
// pushes as a dedicated agent identity instead of the user's 1Password
// GPG flow, which requires interactive approval.
//
// The dotfiles role is "home"; the agent *identity* it uses is the
// long-standing "personal-agent" (GitHub-enrolled email + key under
// ~/.ssh/agents/personal/). Those stay named "personal" to preserve the
// Verified-badge enrollment. Only the role name is "home".
//
// Prerequisite: run `bin/setup-agent-ssh-key personal` once and complete
// the printed GitHub enrollment steps.
// GIT_CONFIG_GLOBAL swaps in an agent-specific gitconfig for Claude-spawned
Expand All @@ -12,7 +17,7 @@
// ssh-keygen). Leading ~/ is expanded by claudeconfig.sh before writing
// settings.json (GIT_CONFIG_GLOBAL itself does not expand ~).
"env": {
"GIT_CONFIG_GLOBAL": "~/.gitconfig.d/claude-agent-personal",
"GIT_CONFIG_GLOBAL": "~/.gitconfig.d/claude-agent-home",
},

"sandbox": {
Expand Down
10 changes: 9 additions & 1 deletion claudeconfig.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ read_json() {
}

# Detect role (uses existing DOTPICKLES_ROLE from environment)
ROLE="${DOTPICKLES_ROLE:-personal}"
ROLE="${DOTPICKLES_ROLE:-home}"
echo "Configuring Claude Code for role: $ROLE"

# Ensure ~/.claude exists and symlink managed files
Expand Down Expand Up @@ -139,6 +139,14 @@ generate_settings() {

# --- Load active role (if not base) ---
local role_file="$DIR/claude/roles/$ROLE.jsonc"
# Loud guard: a missing role file silently drops all role-specific settings
# (env like GIT_CONFIG_GLOBAL, sandbox rules). That used to fail quietly when
# DOTPICKLES_ROLE and the role filenames drifted apart. See ADR 0031.
if [ "$ROLE" != "base" ] && [ ! -f "$role_file" ]; then
echo " ⚠️ WARNING: role '$ROLE' has no role file ($role_file)." >&2
echo " No role-specific env (e.g. GIT_CONFIG_GLOBAL) or sandbox rules will apply." >&2
echo " Check DOTPICKLES_ROLE and claude/roles/ for a name mismatch." >&2
fi
if [ -f "$role_file" ] && [ "$ROLE" != "base" ]; then
local role_json
role_json=$(read_json "$role_file")
Expand Down
17 changes: 12 additions & 5 deletions doc/adr/0031-role-scoped-agent-git-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Date: 2026-04-18

Accepted

Amended by [ADR 0035](0035-canonical-dotpickles-role-names.md) and
[ADR 0036](0036-fail-loud-role-resolution.md): the non-work role this ADR calls
"personal" was renamed to the canonical `home`. The git _identity_ keeps the
`personal-agent` name (GitHub-enrolled); only the role name changed. References
below have been updated to match.

## Context

Git commits in this repo (and everywhere else) are signed via 1Password's GPG
Expand Down Expand Up @@ -55,10 +61,11 @@ override git identity per role.
Every Claude Code session inherits its git identity from the active dotfiles
role via environment variables set in `claude/roles/<role>.jsonc`.

Under the personal role, Claude commits and pushes as
Under the home role, Claude commits and pushes as
`joshua.nichols+personal-agent@gmail.com`, signed with a dedicated ed25519
key at `~/.ssh/agents/personal/id_ed25519`, with its passphrase in macOS
Keychain. The user's interactive shells are untouched.
Keychain. The user's interactive shells are untouched. (The identity and key
keep the `personal-agent`/`personal` name; only the role is `home`.)

Under the work role, the same mechanism applied to a different identity:
`josh.nichols+agent@gusto.com` (a verified plus-address on the user's
Expand Down Expand Up @@ -91,18 +98,18 @@ distinct email to attribute commits to. The default email format is
role's identity uses a different mailbox or domain (work role uses
`josh.nichols+agent@gusto.com`).

**3. Per-role env injection.** `claude/roles/personal.jsonc` sets a single
**3. Per-role env injection.** `claude/roles/home.jsonc` sets a single
env var pointing at an agent-specific gitconfig file:

```jsonc
"env": {
"GIT_CONFIG_GLOBAL": "~/.gitconfig.d/claude-agent-personal"
"GIT_CONFIG_GLOBAL": "~/.gitconfig.d/claude-agent-home"
}
```

`GIT_CONFIG_GLOBAL` tells git to read the pointed-at file instead of
`~/.gitconfig` for every git command in the session. The target is a
symlinked dotfiles-managed file (`home/.gitconfig.d/claude-agent-personal`
symlinked dotfiles-managed file (`home/.gitconfig.d/claude-agent-home`
in the repo, surfaced via `link_directory_contents home`):

```ini
Expand Down
84 changes: 84 additions & 0 deletions doc/adr/0034-local-ssh-allowed-signers-for-commit-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 34. Local SSH allowed signers for commit verification

Date: 2026-06-12

## Status

Accepted

## Context

Commits are signed with SSH keys (`gpg.format = ssh`, see
[ADR 31](0031-role-scoped-agent-git-identity.md)). Signing only needs the
private key, so it works with no extra config. Verification is different.

Unlike GPG, SSH signing has no keyservers or web of trust. A signature only
proves "some key signed this blob"; git can't map that key back to an identity
on its own. To verify, git needs an allowed-signers file that lists which
public keys it should trust for which committer emails. Without it,
`git log --show-signature` and `git verify-commit` fail with:

```
error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification
```

So locally, every signed commit shows as unverified, including the user's own.
GitHub is unaffected: it verifies server-side against the keys enrolled on the
account and shows the Verified badge regardless. The gap is purely local
verification.

## Decision

Manage a dotfiles-tracked allowed-signers file at
`home/.gitconfig.d/allowed_signers` (surfaced as `~/.gitconfig.d/allowed_signers`
via the symlinked `~/.gitconfig.d` directory) and point
`gpg.ssh.allowedSignersFile` at it from the `signing` fragment.

Because the `signing` fragment is included by the generated `~/.gitconfig.local`
and the agent gitconfig (`claude-agent-<role>`) `[include]`s `~/.gitconfig`, both
the human and agent identities inherit the setting from one place.

The file lists one line per identity (`<email> <keytype> <key-blob>`):

- `joshua.nichols@gmail.com` -- personal human (1Password-managed key)
- `joshua.nichols+personal-agent@gmail.com` -- personal agent (ADR 31)

It is a plain data file, not a gitconfig fragment, and the dir is included
per-file (not globbed), so it is never parsed as config. Public keys are safe
to commit; the email is already present in `personal-identity`.

### Why a single fleet-wide file rather than role-scoped

Unlike the 1Password agent allowlist ([ADR 33](0033-1password-ssh-agent-allowlist.md)),
allowed-signers is additive: listing a key that never signs anything on this
host costs nothing. The same repo's history can contain commits from any role's
identity (e.g. a personal laptop checking out a branch with work-agent commits),
so one file listing every identity gives the most complete local verification.
Work-role identities (`josh.nichols+agent@gusto.com` and the work human key)
are appended to the same file when that role's keys are available.

## Consequences

### Positive

- `git log --show-signature` / `verify-commit` confirm the user's own signed
commits locally (`Good "git" signature for <email>`), no GitHub round-trip.
- One setting in the `signing` fragment covers both human and agent identities.
- No effect on signing, on the GitHub Verified badge, or on any enforcement;
git never blocks a commit, push, or merge based on this file.

### Negative

- The file is a second copy of public keys that must stay in sync with what is
enrolled on GitHub. Rotate a key and forget to update here, and local verify
breaks while GitHub keeps working, a confusing state to debug.
- Until work-role identities are added, work-agent commits in shared history
verify as `U` (good signature, `No principal matched`), not `G`.
- GPG-signed commits (e.g. GitHub PR-merge commits, signed with GitHub's RSA
key) still show as `E` under SSH verification; allowed-signers only covers SSH
signatures.

## Links

- Refines [ADR 31](0031-role-scoped-agent-git-identity.md) (agent SSH signing)
- Related [ADR 30](0030-ssh-keychain-loading-at-login.md) (silent key load)
78 changes: 78 additions & 0 deletions doc/adr/0035-canonical-dotpickles-role-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 35. Canonical DOTPICKLES_ROLE names

Date: 2026-06-12

## Status

Accepted

## Context

Role-based adaptation is the central architectural pattern of this repo (see
[architecture.md](../architecture.md)): a single `DOTPICKLES_ROLE` value selects
git identity, Brewfile additions, shell behavior, Claude settings, and SSH agent
allowlists. Despite being load-bearing for nearly every subsystem, the role
_system_ was only ever described in passing inside other ADRs ([0013](0013-claude-code-configuration-management.md),
[0031](0031-role-scoped-agent-git-identity.md), [0033](0033-1password-ssh-agent-allowlist.md)).
The set of valid role names was never written down anywhere authoritative.

That gap caused a real, long-lived failure. An early non-work role named
`personal` was later renamed to `home` in some places (`config/fish/config.fish`,
the SSH agent allowlist) but not others (`install.sh`, `home/.zshenv`,
`claudeconfig.sh`, `claude/roles/personal.jsonc`, the agent gitconfig). Because
nothing declared the canonical name, each consumer drifted independently. The
live machines emitted `home` while `claude/roles/` only had `personal.jsonc`, so
the Claude agent git identity silently never loaded and every agent commit fell
back to the interactive 1Password signing prompt. It went unnoticed for roughly
two months.

Role detection is also necessarily duplicated: `install.sh` (bash),
`config/fish/config.fish` (fish), and `home/.zshenv` (zsh) each re-implement the
same hostname check because the three shells can't share one snippet. Three
copies of a literal role string is exactly where drift creeps in.

## Decision

The canonical `DOTPICKLES_ROLE` values are:

- **`home`** -- personal machines (the default for any non-work, non-container host)
- **`work`** -- hostnames matching `josh-nichols-*`
- **`container`** -- detected at runtime inside containers (Docker/lxc)

`personal` is **retired**; it was the former name for `home`. No new code should
emit or branch on it.

Every role-keyed consumer must use exactly these names. Known consumers:

- `Brewfile.<role>`
- `claude/roles/<role>.jsonc`
- `config/1password/agent.toml.<role>` ([ADR 0033](0033-1password-ssh-agent-allowlist.md))
- the role default in `claudeconfig.sh`, `sshconfig.sh`, `install.sh`

### Role name vs agent identity name

The dotfiles _role_ is distinct from the git _identity_ a role uses. The `home`
role signs as the GitHub-enrolled `personal-agent` identity (email
`joshua.nichols+personal-agent@gmail.com`, key under `~/.ssh/agents/personal/`,
see [ADR 0031](0031-role-scoped-agent-git-identity.md)). Those keep the
`personal` name to preserve the Verified-badge enrollment. Renaming the role to
`home` does **not** rename the identity. The agent gitconfig fragment is
`claude-agent-home` (named for the role), but its contents point at the
`personal`-named identity on purpose.

## Consequences

### Positive

- One authoritative list to check a new role-keyed file against.
- The role-vs-identity distinction is recorded, so "why does `home` use a
`personal` key" has a documented answer instead of looking like a bug.

### Negative

- Detection stays duplicated across three shells; this ADR documents the
canonical names but does not (cannot easily) DRY the detection itself. The
mitigation is the fail-loud guard in [ADR 0036](0036-fail-loud-role-resolution.md),
which makes a drifted/typo'd role visible instead of silent.
- Renaming a role in future touches every consumer above at once; there is no
single rename point.
Loading
Loading