Summary
The tier-1 half of the devagent OS-user boundary: the privileged, once-only,
host-specific steps a human runs and can roll back. Current pilot host: computer
(was pop-mini; flipped by the operator). The mechanism is host-agnostic — any
modern systemd host where gpg-agent is socket-activated. Repeat per host.
This issue does not restate the design — the merged doc set is the authority:
- Model, vocabulary, and the V1–V7 decisions:
dev docs/design/devagent-boundary.md
docs/design/devagent-security-boundary.md (merged, dev#186).
- Tier-2 — idempotent mechanicals (socketdir loan link + its user unit, the
agents.slice user unit, /srv/dev-shared checkout + ACLs, broker-contract
verification): owned by dev provision devagent --apply. dev provision devagent --dry-run is the single source of truth for those commands and emits the
tier-1 commands below — treat any divergence between it and this issue as a bug
in one of them.
- Tier-3 — continuous health:
dev doctor --devagent.
- App-code contract (
[project.x] user =, uid-aware tmux dispatch, the broker
op-set): dev#154.
Prerequisites
- A dedicated sign-only (
[S]-only) OpenPGP signing subkey on mt's key,
appointed as the loaned keygrip. A restricted extra-socket permits PKSIGN and
PKDECRYPT — pinning the loan to an [S]-only subkey is what excludes decryption
(V6). Default keys have no such subkey; create one (gpg --edit-key <fpr> →
addkey → sign-only) or via dev#162 dev key create. Record its fingerprint and
keygrip: gpg --list-keys --with-subkey-fingerprints --with-keygrip.
- mt's gpg-agent kept unlocked by the existing weekly preset flow
(allow-preset-passphrase + gpg-preset-passphrase <sign-subkey-keygrip>).
- Both
mt and devagent get enable-linger. /run/gnupg/shared is tmpfs
and the extra-socket only exists while mt's gpg-agent is running; without mt
lingering, a reboot breaks devagent signing until mt next logs in.
Tier-1 steps
1. devagent user + shared group
getent group devgroup || sudo groupadd devgroup
id -u devagent >/dev/null 2>&1 || \
sudo useradd -m -s /usr/sbin/nologin -G devgroup devagent # no interactive shell — v1 is loan-only, the broker builds env from scratch
sudo usermod -aG devgroup mt
sudo loginctl enable-linger devagent
sudo loginctl enable-linger mt # keeps the lender (mt's gpg-agent) alive across reboots
Verify: id devagent shows devgroup; loginctl show-user devagent and
loginctl show-user mt both show Linger=yes.
2. Relocate + group-grant the restricted extra-socket
The pilot host runs gpg-agent --supervised (systemd socket-activated) —
verified on both computer (gpg 2.4.8) and pop-mini (gpg 2.2.27). Two things
follow:
extra-socket in gpg-agent.conf is ignored in supervised mode — gpg-agent
gets pre-bound fds from systemd and never creates its own sockets. Do not add it.
/run/user/<uid> is 0700 mt:mt, logind-owned, mode hardcoded — a socket
anywhere under it is connect()-unreachable cross-uid regardless of its own mode.
The extra socket must move out of /run/user/<uid>, and on a socket-activated
host that is done in the .socket unit, not gpg-agent.conf.
Keep in ~mt/.gnupg/gpg-agent.conf only:
allow-preset-passphrase
max-cache-ttl 604800
Stated decision, not a side effect: max-cache-ttl 604800 is per-agent, not
per-key — it relaxes cache lifetime for all of mt's keys for a week. Accepted on
a single-operator host; recorded here so the choice is deliberate.
Shared directory (tmpfs — recreated each boot), setgid so anything created in
it lands devgroup — /etc/tmpfiles.d/gnupg-shared.conf:
d /run/gnupg/shared 2750 mt devgroup -
User drop-in on gpg-agent-extra.socket — relocates the socket and sets mode
natively. SocketGroup=devgroup is belt-and-braces: the socket already inherits
devgroup from the setgid 2750 dir on creation, so SocketGroup= may log a
benign "Failed to set socket group" if mt's systemd --user session predates the
usermod -aG devgroup mt above (a non-root manager can't chgrp to a group it
isn't in) — the setgid dir covers it either way. The empty ListenStream= resets
the vendor value before the new one:
# ~mt/.config/systemd/user/gpg-agent-extra.socket.d/shared.conf
[Socket]
ListenStream=
ListenStream=/run/gnupg/shared/S.gpg-agent.extra
SocketMode=0660
SocketGroup=devgroup
sudo systemd-tmpfiles --create /etc/tmpfiles.d/gnupg-shared.conf
systemctl --user daemon-reload
gpgconf --kill gpg-agent # KILL FIRST — see note
systemctl --user restart gpg-agent-extra.socket
gpg -K >/dev/null 2>&1 && echo "mt gpg ok" # sanity: agent re-activates on this call
Order matters. systemd refuses restart gpg-agent-extra.socket while its
Accept=no acceptor gpg-agent.service is active ("Socket service … already
active, refusing") — so a restart-then-kill order fails at the restart. Kill the
agent first; the .socket restart then binds the relocated /run/gnupg/shared
path, and gpg-agent re-activates on the next connection. (Killing also avoids the
separate stale-fd trap: a running agent keeps the old, now-unlinked socket fd and
never serves the relocated path — a "V2 blocked" false negative.)
If usermod -aG devgroup mt was run in this same session, log out/in (or
sudo loginctl terminate-user mt) before this step so SocketGroup= applies
cleanly — otherwise rely on the setgid dir and ignore the journal warning.
Autonomous (--daemon) hosts — non-systemd, or gpg-agent not socket-activated:
there, use extra-socket /run/gnupg/shared/S.gpg-agent.extra in gpg-agent.conf
plus an ExecStartPost=/bin/sh -c 'chgrp devgroup … && chmod 0660 …' on the
agent service. Any modern Ubuntu/Pop host is socket-activated — use the drop-in
above.
3. V2 go/no-go pre-test — before the sudoers line
Prove a different uid can actually connect() to the extra-socket. Nothing
downstream is worth wiring until this passes.
DEVAGENT_UID=$(id -u devagent)
# sudo resets the env; gpgconf needs XDG_RUNTIME_DIR to place the 0700 socketdir
sudo -u devagent env XDG_RUNTIME_DIR="/run/user/$DEVAGENT_UID" gpgconf --create-socketdir
sudo -u devagent ln -sfn /run/gnupg/shared/S.gpg-agent.extra \
"/run/user/$DEVAGENT_UID/gnupg/S.gpg-agent"
gpg --export <PRIMARY_FPR> | sudo -u devagent gpg --homedir /home/devagent/.gnupg --import
echo test | sudo -u devagent env XDG_RUNTIME_DIR="/run/user/$DEVAGENT_UID" \
gpg --homedir /home/devagent/.gnupg -u '<SIGN_SUBKEY_FPR>!' --sign > /dev/null && echo "V2 OK"
If connect() is refused: confirm ls -l /run/gnupg/shared/S.gpg-agent.extra
shows srw-rw---- mt devgroup and the dir is 2750 mt devgroup — a wrong group or
a socket still under /run/user/<uid> is the usual cause. If the drop-in took and
it still fails, socket forwarding (socat / ssh -R) is the last resort; if that
also fails the design flips to SSH signing or a per-uid key (dev#162) — stop and
escalate, do not proceed to step 5.
The socketdir symlink created here is on tmpfs and vanishes on reboot — its
permanent home is a devagent user unit created in tier-2 by dev provision.
This step only proves the mechanism.
4. Tighten mt's home
Reversible (chmod 755), so safe to apply and observe — but check first:
sysbak / rsnapshot: runs as root (ignores 700) or drops privileges? If it
drops privs it loses read access to ~mt.
- Nothing reads
~mt/.local/share/chezmoi from another identity — the pilot
clones dotfiles fresh, it does not read mt's working copy.
- Editor / LSP remote-helpers that stat
~mt.
- systemd system units with
ProtectHome= interactions.
- Confirm nothing devagent needs lives under
~mt (the extra-socket is at
/run/gnupg/shared, not ~mt).
5. Sudoers — the trust root
sudo visudo -cf /dev/stdin <<'EOF'
mt ALL=(devagent) NOPASSWD: /usr/lib/dev/agent-launch
EOF
# then install as /etc/sudoers.d/dev-agent-launch, mode 0440
Absolute path, no ARGV constraint — by design. Security rests on the broker
(agent-launch) enumerating its ops and self-validating its own argv, not on
sudoers argument matching (dev#154). Install only after step 3 passed and the
broker binary is in place (tier-2 verifies its op-set against the dev#154
contract).
Verification checklist (tier-1)
Rollback
sudo userdel -r devagent
sudo gpasswd -d mt devgroup && sudo groupdel devgroup
sudo rm -f /etc/sudoers.d/dev-agent-launch /etc/tmpfiles.d/gnupg-shared.conf
rm -rf ~mt/.config/systemd/user/gpg-agent-extra.socket.d
systemctl --user daemon-reload && systemctl --user restart gpg-agent-extra.socket # restores the vendor path /run/user/<uid>/gnupg/S.gpg-agent.extra
sudo rm -rf /run/gnupg/shared
# edit ~mt/.gnupg/gpg-agent.conf — remove allow-preset-passphrase / max-cache-ttl, then:
gpg-connect-agent reloadagent /bye
chmod 755 /home/mt # or the pre-change mode
sudo loginctl disable-linger devagent
# tier-2 artefacts (agents.slice + socketdir user units, /srv/dev-shared ACLs): dev provision devagent revert path (dev#154)
The dedicated sign-only subkey can stay on mt's key — it is harmless unused. Remove
with gpg --edit-key <fpr> → key N → delkey if desired.
Non-goals
- No key generation here — the sign-only subkey is a prerequisite (dev#162
dev key create).
- One shared
devagent; no per-project users in v1.
- No cross-host automation — one pilot host at a time (currently
computer),
repeat per host once proven.
- No
run_once_* chezmoi script for these sensitive steps on the first pass —
done by hand and reviewed; scripted only once proven safe.
Companions
dev docs/design/devagent-boundary.md + devagent-security-boundary.md —
model, vocabulary, V1–V7 (merged in dev#186).
- dev#154 — app-code scope:
user = field, uid-aware tmux dispatch, broker op-set
contract, attach-via-proxy.
- dev#162 —
dev key create (the sign-only subkey).
- dev#159 — per-role git signing identity (
GIT_CONFIG_GLOBAL); devagent's git
config is deferred to it.
- dev#129 — structured agent API; shrinks the attach/scrape surface once it lands.
- dev#151 — direct-launch process leak (
reap).
https://claude.ai/code/session_01WDcE2CAYzzJTMY6ubRmt94
Summary
The tier-1 half of the
devagentOS-user boundary: the privileged, once-only,host-specific steps a human runs and can roll back. Current pilot host:
computer(was pop-mini; flipped by the operator). The mechanism is host-agnostic — any
modern systemd host where
gpg-agentis socket-activated. Repeat per host.This issue does not restate the design — the merged doc set is the authority:
devdocs/design/devagent-boundary.mddocs/design/devagent-security-boundary.md(merged, dev#186).agents.sliceuser unit,/srv/dev-sharedcheckout + ACLs, broker-contractverification): owned by
dev provision devagent --apply.dev provision devagent --dry-runis the single source of truth for those commands and emits thetier-1 commands below — treat any divergence between it and this issue as a bug
in one of them.
dev doctor --devagent.[project.x] user =, uid-aware tmux dispatch, the brokerop-set): dev#154.
Prerequisites
[S]-only) OpenPGP signing subkey on mt's key,appointed as the loaned keygrip. A restricted
extra-socketpermits PKSIGN andPKDECRYPT — pinning the loan to an
[S]-only subkey is what excludes decryption(V6). Default keys have no such subkey; create one (
gpg --edit-key <fpr>→addkey→ sign-only) or via dev#162dev key create. Record its fingerprint andkeygrip:
gpg --list-keys --with-subkey-fingerprints --with-keygrip.(
allow-preset-passphrase+gpg-preset-passphrase <sign-subkey-keygrip>).mtanddevagentgetenable-linger./run/gnupg/sharedis tmpfsand the extra-socket only exists while mt's gpg-agent is running; without mt
lingering, a reboot breaks devagent signing until mt next logs in.
Tier-1 steps
1. devagent user + shared group
Verify:
id devagentshowsdevgroup;loginctl show-user devagentandloginctl show-user mtboth showLinger=yes.2. Relocate + group-grant the restricted extra-socket
The pilot host runs
gpg-agent --supervised(systemd socket-activated) —verified on both
computer(gpg 2.4.8) and pop-mini (gpg 2.2.27). Two thingsfollow:
extra-socketingpg-agent.confis ignored in supervised mode — gpg-agentgets pre-bound fds from systemd and never creates its own sockets. Do not add it.
/run/user/<uid>is0700 mt:mt, logind-owned, mode hardcoded — a socketanywhere under it is
connect()-unreachable cross-uid regardless of its own mode.The extra socket must move out of
/run/user/<uid>, and on a socket-activatedhost that is done in the
.socketunit, notgpg-agent.conf.Keep in
~mt/.gnupg/gpg-agent.confonly:Stated decision, not a side effect:
max-cache-ttl 604800is per-agent, notper-key — it relaxes cache lifetime for all of mt's keys for a week. Accepted on
a single-operator host; recorded here so the choice is deliberate.
Shared directory (tmpfs — recreated each boot), setgid so anything created in
it lands
devgroup—/etc/tmpfiles.d/gnupg-shared.conf:User drop-in on
gpg-agent-extra.socket— relocates the socket and sets modenatively.
SocketGroup=devgroupis belt-and-braces: the socket already inheritsdevgroupfrom the setgid2750dir on creation, soSocketGroup=may log abenign "Failed to set socket group" if mt's
systemd --usersession predates theusermod -aG devgroup mtabove (a non-root manager can'tchgrpto a group itisn't in) — the setgid dir covers it either way. The empty
ListenStream=resetsthe vendor value before the new one:
Order matters. systemd refuses
restart gpg-agent-extra.socketwhile itsAccept=noacceptorgpg-agent.serviceis active ("Socket service … alreadyactive, refusing") — so a restart-then-kill order fails at the restart. Kill the
agent first; the
.socketrestart then binds the relocated/run/gnupg/sharedpath, and gpg-agent re-activates on the next connection. (Killing also avoids the
separate stale-fd trap: a running agent keeps the old, now-unlinked socket fd and
never serves the relocated path — a "V2 blocked" false negative.)
If
usermod -aG devgroup mtwas run in this same session, log out/in (orsudo loginctl terminate-user mt) before this step soSocketGroup=appliescleanly — otherwise rely on the setgid dir and ignore the journal warning.
3. V2 go/no-go pre-test — before the sudoers line
Prove a different uid can actually
connect()to the extra-socket. Nothingdownstream is worth wiring until this passes.
If
connect()is refused: confirmls -l /run/gnupg/shared/S.gpg-agent.extrashows
srw-rw---- mt devgroupand the dir is2750 mt devgroup— a wrong group ora socket still under
/run/user/<uid>is the usual cause. If the drop-in took andit still fails, socket forwarding (
socat/ssh -R) is the last resort; if thatalso fails the design flips to SSH signing or a per-uid key (dev#162) — stop and
escalate, do not proceed to step 5.
The socketdir symlink created here is on tmpfs and vanishes on reboot — its
permanent home is a devagent user unit created in tier-2 by
dev provision.This step only proves the mechanism.
4. Tighten mt's home
Reversible (
chmod 755), so safe to apply and observe — but check first:sysbak/ rsnapshot: runs as root (ignores 700) or drops privileges? If itdrops privs it loses read access to
~mt.~mt/.local/share/chezmoifrom another identity — the pilotclones dotfiles fresh, it does not read mt's working copy.
~mt.ProtectHome=interactions.~mt(the extra-socket is at/run/gnupg/shared, not~mt).5. Sudoers — the trust root
Absolute path, no
ARGVconstraint — by design. Security rests on the broker(
agent-launch) enumerating its ops and self-validating its own argv, not onsudoers argument matching (dev#154). Install only after step 3 passed and the
broker binary is in place (tier-2 verifies its op-set against the dev#154
contract).
Verification checklist (tier-1)
id devagent→devgroup;loginctl show-user devagentand... mt→Linger=yes~mt/.gnupg/gpg-agent.confhasallow-preset-passphrase+max-cache-ttl 604800and noextra-socketline/run/gnupg/sharedis a dir, mode2750, ownermt:devgroupls -l /run/gnupg/shared/S.gpg-agent.extra→srw-rw---- mt devgroup, i.e. a socket at that path (not under/run/user/), mode0660, groupdevgroup, aftersystemctl --user restart gpg-agent-extra.socketsudo -u devagent … --signsucceeds while mt's agent has the sign-only subkey presetsudo -u devagent gpg --homedir /home/devagent/.gnupg --list-secret-keys→ emptygpg --list-keys --with-colons --with-keygrip: thesub:whose followinggrp:matches shows capabilitysonly (notse/e)ls -ld /home/mt→drwx------sudo visudo -cf /etc/sudoers.d/dev-agent-launch→ OK;sudo -n -u devagent /usr/lib/dev/agent-launchruns with no password promptRollback
The dedicated sign-only subkey can stay on mt's key — it is harmless unused. Remove
with
gpg --edit-key <fpr>→key N→delkeyif desired.Non-goals
dev key create).devagent; no per-project users in v1.computer),repeat per host once proven.
run_once_*chezmoi script for these sensitive steps on the first pass —done by hand and reviewed; scripted only once proven safe.
Companions
devdocs/design/devagent-boundary.md+devagent-security-boundary.md—model, vocabulary, V1–V7 (merged in dev#186).
user =field, uid-aware tmux dispatch, broker op-setcontract, attach-via-proxy.
dev key create(the sign-only subkey).GIT_CONFIG_GLOBAL); devagent's gitconfig is deferred to it.
reap).https://claude.ai/code/session_01WDcE2CAYzzJTMY6ubRmt94