Skip to content

Commit 7ef4d49

Browse files
feat(ssh): per-role 1Password SSH agent allowlist
By default 1Password's SSH agent offers every key in the unlocked vault to every ssh request. On the work laptop that surfaced 7 keys, including personal keys and the personal-agent key from ADR 31 (which is in the vault as a backup but should not be offered, since claude-agent uses the on-disk + Keychain path). Add a role-aware agent.toml managed in dotfiles. config/1password/ agent.toml.<role> defines the allowlist, sshconfig.sh symlinks the file matching $DOTPICKLES_ROLE into ~/.config/1Password/ssh/agent.toml when the 1Password agent socket is present. Verified on the work laptop: ssh-add -l against the 1Password socket went from 7 keys to 3 (Gusto only). ADR 33 documents the decision and tradeoffs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a0019b2 commit 7ef4d49

7 files changed

Lines changed: 194 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ When making significant architectural changes, create a new ADR.
3333
- `~/.gitconfig.d/1password`: Generated during git config setup
3434
- `~/.ssh/config`: Managed by [sshconfig.sh](sshconfig.sh) -- edit `ssh/config.d/` instead
3535
- `~/.ssh/config.d/colima`: Generated by [sshconfig.sh](sshconfig.sh)
36+
- `~/.config/1Password/ssh/agent.toml`: Managed by [sshconfig.sh](sshconfig.sh) -- edit `config/1password/agent.toml.<role>` instead (see [ADR 0033](doc/adr/0033-1password-ssh-agent-allowlist.md))
3637
- Any symlinked files -- edit the source in `home/` or `config/` instead
3738

3839
## Working with Nerd Fonts / Special Unicode Characters

config/1password/agent.toml.work

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# 1Password SSH agent allowlist for the work role.
2+
#
3+
# 1Password's SSH agent offers every key in the unlocked vault by default.
4+
# Listing items here makes it an allowlist: only these keys are exposed to
5+
# ssh, regardless of what else lives in the vault.
6+
#
7+
# Personal keys (including the personal-agent key from ADR 0031) stay in
8+
# the vault for backup/reference but are never offered on the work laptop.
9+
#
10+
# Symlinked into ~/.config/1Password/ssh/agent.toml by sshconfig.sh based
11+
# on $DOTPICKLES_ROLE. See ADR 0033.
12+
#
13+
# Item names below are matched against the 1Password item title, not the
14+
# SSH key comment.
15+
16+
[[ssh-keys]]
17+
item = "Gusto Laptop id_ed25519"
18+
19+
[[ssh-keys]]
20+
item = "Gusto Signing Key"
21+
22+
[[ssh-keys]]
23+
item = "homebrew-gusto_deploy_key"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# 33. 1Password SSH Agent Allowlist
2+
3+
Date: 2026-05-04
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
`ssh/config.d/auth` (see [ADR 26](0026-versioned-ssh-config-with-config-d.md))
12+
points `Host *`'s `IdentityAgent` at the 1Password agent socket. Every ssh
13+
connection that doesn't have a more specific `IdentityAgent` lands there, and
14+
1Password offers up keys from the unlocked vault.
15+
16+
By default, 1Password's agent offers _every_ SSH key item in every vault the
17+
account can see. On a single-purpose machine that's fine. On a machine with
18+
both work and personal contexts, it's noisy and occasionally wrong:
19+
20+
- Servers that allow multiple keys for a user may auth with whichever one
21+
shows up first, even if it isn't the "right" one
22+
- 1Password prompts for biometric/system approval per key offer, and offering
23+
irrelevant keys means more prompts to dismiss
24+
- The personal-agent key from [ADR 31](0031-role-scoped-agent-git-identity.md)
25+
lives on disk + macOS Keychain by design, but is also stored in 1Password
26+
vault as a backup. That entry should never be _offered_ by the 1Password
27+
agent (the on-disk + Keychain path is what claude-agent uses), even though
28+
it's fine for it to live in the vault for reference.
29+
30+
On the work laptop specifically, `ssh-add -l` against the 1Password socket
31+
listed seven keys, including the personal-agent key. That's the immediate
32+
trigger.
33+
34+
## Decision
35+
36+
Manage `~/.config/1Password/ssh/agent.toml` as a role-aware symlink into the
37+
dotfiles repo. The file in the repo is an allowlist of 1Password items by
38+
title, scoped per-role.
39+
40+
### Implementation
41+
42+
**`config/1password/agent.toml.<role>`** holds the allowlist for that role.
43+
On the work laptop, only Gusto-related items are listed:
44+
45+
```toml
46+
[[ssh-keys]]
47+
item = "Gusto Laptop id_ed25519"
48+
49+
[[ssh-keys]]
50+
item = "Gusto Signing Key"
51+
52+
[[ssh-keys]]
53+
item = "homebrew-gusto_deploy_key"
54+
```
55+
56+
Listed `[[ssh-keys]]` entries are an allowlist: 1Password offers only those
57+
items, regardless of what else lives in the vault. `item` matches the
58+
1Password item title (not the SSH key comment).
59+
60+
**`sshconfig.sh`** appends a section that resolves
61+
`config/1password/agent.toml.$DOTPICKLES_ROLE` and symlinks it to
62+
`~/.config/1Password/ssh/agent.toml` if the source file exists and the
63+
1Password app dir is present. The role-aware path means a new machine joining
64+
under a different role doesn't accidentally inherit another machine's
65+
allowlist; it just skips the link until the role's file exists.
66+
67+
**`functions.sh`'s `link_directory_contents`** skip list adds
68+
`config/1password`. Without that, the auto-linker would try to symlink the
69+
directory itself to `~/.config/1password` (lowercase), which 1Password
70+
ignores -- it reads the capital-P path. Listing it as a skip alongside
71+
`config/fish` matches the existing pattern for "directory managed by its own
72+
installer."
73+
74+
### Why per-role rather than per-host
75+
76+
1Password's `agent.toml` does support `host = "..."` filters per key, which
77+
would let one file describe behavior for both work and personal hosts. But
78+
the dotfiles role system already cleanly partitions "this is a work laptop"
79+
from "this is a personal laptop," and the simplest mental model is "the
80+
allowlist is whatever the role file says." A future iteration could add
81+
host-scoping inside a role file (e.g. work laptop allows the Gusto signing
82+
key for github.com only) without changing the role-based selection.
83+
84+
### Alternatives Considered
85+
86+
1. **Single allowlist with `host =` filters covering both machines**
87+
88+
- Pros: one file describes everything
89+
- Cons: every machine's vault still has to share the same item titles;
90+
a vault rename on one machine breaks behavior on the other; harder to
91+
reason about
92+
- Rejected: role-based selection is a cleaner cut
93+
94+
2. **Delete the personal-agent key from the 1Password vault**
95+
96+
- Pros: no allowlist needed; problem disappears
97+
- Cons: loses the "key is backed up in 1Password" property, which is the
98+
reason it was added in the first place
99+
- Rejected: the user wants the key in the vault for reference
100+
101+
3. **Stop using the 1Password agent altogether on the work laptop**
102+
103+
- Pros: removes a class of identity-leak issues
104+
- Cons: defeats 1Password as the auth UX for the entire flow, including
105+
non-agent uses; significantly larger change for one annoyance
106+
- Rejected: too broad
107+
108+
## Consequences
109+
110+
### Positive
111+
112+
- 1Password offers only the keys relevant to the active role, cutting the
113+
work laptop from 7 offered keys to 3
114+
- The personal-agent key stays in 1Password vault for backup but is not
115+
offered by the 1Password agent on any machine, preserving ADR 31's
116+
"agent key auth path is on-disk + Keychain only" property
117+
- A new role file (`agent.toml.<role>`) is a one-file change to add another
118+
machine type
119+
- Existing `link` helper handles the symlink lifecycle (creation, rewriting,
120+
conflict prompts) without bespoke shell
121+
122+
### Negative
123+
124+
- One more file to keep in sync per role. When a new key is added to the
125+
vault for that role, it has to be added to the allowlist or it won't be
126+
offered. Failure mode is "key isn't found," which is loud rather than
127+
silent
128+
- 1Password item _titles_ are the matching key. Renaming an item in
129+
1Password silently breaks the allowlist until the file is updated
130+
- The capital-P quirk (`~/.config/1Password/` vs the rest of `~/.config/`'s
131+
lowercase convention) means the skip list and the explicit symlink in
132+
sshconfig.sh are necessary; a reader who doesn't know the quirk might
133+
wonder why config/1password isn't auto-linked like everything else under
134+
config/

doc/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@
3232
- [30. ssh-keychain-loading-at-login](0030-ssh-keychain-loading-at-login.md)
3333
- [31. role-scoped-agent-git-identity](0031-role-scoped-agent-git-identity.md)
3434
- [32. use-fnox-for-secrets](0032-use-fnox-for-secrets.md)
35+
- [33. 1password-ssh-agent-allowlist](0033-1password-ssh-agent-allowlist.md)

functions.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ find_targets() {
5757

5858
link_directory_contents() {
5959
local directory="$1"
60-
# Items managed by their own installer scripts (e.g. fish.sh handles config/fish)
61-
local -a skip=(config home config/fish)
60+
# Items managed by their own installer scripts (e.g. fish.sh handles config/fish,
61+
# sshconfig.sh handles config/1password's role-aware agent.toml symlink)
62+
local -a skip=(config home config/fish config/1password)
6263
for linkable in $(find_targets "${directory}"); do
6364
local should_skip=false
6465
for s in "${skip[@]}"; do

ssh/CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,19 @@ SSH settings use `ssh/config.d/` fragments with SSH's native `Include` directive
2828
./sshconfig.sh
2929
```
3030

31+
## 1Password SSH Agent Allowlist
32+
33+
`Host *` in `ssh/config.d/auth` points at the 1Password agent. By default 1Password offers every key in the unlocked vault. Per-role allowlists live at `config/1password/agent.toml.<role>` and are symlinked to `~/.config/1Password/ssh/agent.toml` by `sshconfig.sh` based on `$DOTPICKLES_ROLE`. See [ADR 0033](../doc/adr/0033-1password-ssh-agent-allowlist.md).
34+
35+
To check what 1Password is currently offering:
36+
37+
```bash
38+
SSH_AUTH_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" ssh-add -l
39+
```
40+
3141
## Important
3242

3343
- Edit `ssh/config.d/auth` and `ssh/config.d/term` for versioned changes
3444
- Never edit `~/.ssh/config` directly -- it's managed by `sshconfig.sh`
3545
- `~/.ssh/config.d/hosts` is machine-local and gitignored; recreate it per machine
46+
- Never edit `~/.config/1Password/ssh/agent.toml` directly -- edit `config/1password/agent.toml.<role>` instead

sshconfig.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@ if command_available colima; then
2424
echo "Include ~/.colima/ssh_config" > ~/.ssh/config.d/colima
2525
fi
2626

27+
# Symlink role-specific 1Password SSH agent allowlist.
28+
# 1Password reads ~/.config/1Password/ssh/agent.toml (capital P). Without an
29+
# allowlist, every key in the unlocked vault is offered to ssh on every
30+
# connection. config/1password/agent.toml.<role> defines which items are
31+
# offered. See ADR 0033.
32+
op_role="${DOTPICKLES_ROLE:-personal}"
33+
op_source="config/1password/agent.toml.${op_role}"
34+
op_target="$HOME/.config/1Password/ssh/agent.toml"
35+
op_socket="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
36+
if [ -S "$op_socket" ]; then
37+
if [ -f "$DIR/$op_source" ]; then
38+
echo "🔑 setting up 1Password SSH agent allowlist (role: $op_role)"
39+
mkdir -p "$HOME/.config/1Password/ssh"
40+
link "$op_source" "$op_target"
41+
else
42+
echo "🔑 no 1Password agent.toml for role '$op_role', skipping (expected $op_source)"
43+
fi
44+
else
45+
echo "🔑 1Password agent socket not found, skipping agent.toml setup"
46+
fi
47+
2748
# Ensure ~/.ssh/config starts with Include
2849
include_line="Include ~/.ssh/config.d/*"
2950
if [ ! -f ~/.ssh/config ]; then

0 commit comments

Comments
 (0)