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
8 changes: 4 additions & 4 deletions docs/mode-economics.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ special-token spellings counted as ordinary text.
Coverage: **75 of 75 local `skills/*/SKILL.md` files**.
External `source.md` redirects and harness symlinks are excluded.

Measurement manifest SHA-256: `ff0639052281a5f8336b83b048cd57b403f3f3b5dad5f255093600a74ef5cd18`.
Measurement manifest SHA-256: `026fe6f701d8b3c1050c01f3a7bc192b8996ae0f5f7702290cab4c5c052806d8`.

| Skill file | Measured tokens | Source SHA-256 (first 16 characters) |
|---|---:|---|
Expand Down Expand Up @@ -160,9 +160,9 @@ Measurement manifest SHA-256: `ff0639052281a5f8336b83b048cd57b403f3f3b5dad5f2550
| [security-tracker-stats-dashboard](../skills/security-tracker-stats-dashboard/SKILL.md) | 4,897 | `b52154deb8557ba4` |
| [setup](../skills/setup/SKILL.md) | 8,724 | `82788542bb240309` |
| [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 7,950 | `3fa5d728fa080ed0` |
| [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 10,915 | `e806b0f1d0be8f03` |
| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 5,036 | `a2317a80efccf5c8` |
| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 8,149 | `4dfa606de85da6ff` |
| [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 11,197 | `c046c45d2e176827` |
| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 5,069 | `dd56cf376cd14f55` |
| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 8,409 | `3ba7f351ebfa32bd` |
| [setup-override-upstream](../skills/setup-override-upstream/SKILL.md) | 4,012 | `fb583feb56b7f77c` |
| [setup-privacy-llm](../skills/setup-privacy-llm/SKILL.md) | 2,145 | `0e27b542a1656846` |
| [setup-shared-config-sync](../skills/setup-shared-config-sync/SKILL.md) | 4,357 | `d1dfcd7cdeb5f5a6` |
Expand Down
37 changes: 37 additions & 0 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
- [Install (user-scope)](#install-user-scope-3)
- [Verify](#verify-3)
- [From your own terminal — git's program config](#from-your-own-terminal--gits-program-config)
- [Beyond git — ssh, scp, sftp and rsync you type yourself](#beyond-git--ssh-scp-sftp-and-rsync-you-type-yourself)
- [Trade-offs](#trade-offs-2)
- [Container gateway](#container-gateway)
- [Why install it](#why-install-it-1)
Expand Down Expand Up @@ -2266,6 +2267,7 @@ What this covers, and what it costs:
- Verify-only calls pass straight through: `git log --show-signature` runs `ssh-keygen -Y verify` once per commit, and none of those gets a watcher or a display probe.
- The toolkit probe runs once per signature or ssh connection, about a third of a second.
- One signature, one window. Inside an agent session (Claude Code marks its Bash with `CLAUDECODE=1`) the wrapper only runs the program: the hook armed a watcher outside the sandbox before the command started, and that one shows the window for the agent's commits. Outside a session the wrapper starts a watcher of its own. Every signing context — each agent session, each wrapped git — owns the watcher it started and can tear down no other, so two sessions signing at once, or a terminal commit beside one, cannot blind each other. What they share is the window, leased by whichever watcher reaches it first, so one touch still draws exactly one. So the hook stays necessary for the agent's own git commands, and the wrapper never doubles it.
The shim directory below does not double it either: the lookup that finds the real program skips every `PATH` entry resolving back to this script, so a wrapped git reaches `/usr/bin/ssh` and not the shim.

**Under the sandbox this needs one more grant.** Global git config is read by the git the agent runs too, and the sandbox denies reads under `~/.claude/` wholesale — so without it every sandboxed `git commit` fails at once with `fatal: cannot exec '…/gpg-touch-wrap-ssh-keygen': Operation not permitted`, before the key is asked for anything.
Allow the two wrapper files, and nothing wider:
Expand All @@ -2289,6 +2291,41 @@ The install skill proposes it with the other two grants; the failure mode and it

To undo it: `git config --global --unset gpg.ssh.program` and `git config --global --unset core.sshCommand`.

### Beyond git — ssh, scp, sftp and rsync you type yourself

Git can be told which program to call.
A bare `ssh` cannot: nothing sits between the word you type and `/usr/bin/ssh` except `PATH`.
So an `ssh host`, an `scp`, an `sftp` or an `rsync -e ssh` run straight from a terminal asks the key for its authentication touch with nothing on screen — the same silence `core.sshCommand` removed for git.

A shim directory early on `PATH` closes that gap.
It holds symlinks named for the programs themselves, all pointing at the same script:

```sh
mkdir -p ~/.claude/scripts/shims
for p in ssh scp sftp rsync; do
ln -sfn ~/.claude/scripts/gpg-touch-overlay.sh ~/.claude/scripts/shims/"$p"
done
```

Then put that directory ahead of the real ones, in the rc your shells read:

```sh
export PATH="$HOME/.claude/scripts/shims:$PATH"
```

The script answers to a key command's own name the way it already answers to `gpg-touch-wrap-<program>`, and finds the real program behind itself: the lookup walks every `PATH` match and skips the one that resolves back to the script.
Only the commands that can reach the key dispatch this way, so a symlink named anything else is refused rather than silently exec'd.
When the lookup finds nothing but the script, it exits 127 and says so — it never falls back to the bare name, which on a `PATH` holding the shim would re-exec the script forever, with the terminal hung and nothing on screen to say why.

What it costs, and what it does not double:

- Nothing extra under the sandbox. The shims resolve to `gpg-touch-overlay.sh`, which the grant above already allows, and the sandbox checks the resolved path.
- No second window when git is wrapped too, per the self-skip described above: one connection, one wrapper.
- Nothing at all inside an agent session — `CLAUDECODE=1` makes the wrapper stand aside, because the hook armed a watcher for the whole command before it started.
- A wider reach than a shell alias, deliberately. A `PATH` entry is seen by scripts and Makefiles, which is where an unattended `rsync` would otherwise block with no window. It is seen by everything else you run as well, which is the trade.

To undo it: drop the `PATH` line and delete the directory.

### Trade-offs

- **Placement is X11's to give.** On Linux the overlay places and stacks
Expand Down
17 changes: 17 additions & 0 deletions plugins/magpie-setup/skills/isolated-setup-install/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,23 @@ show the current value and let the operator decide; do not overwrite
it. Why a symlink, what it covers, what it costs:
[docs/setup/secure-agent-setup.md → From your own terminal](../../../../docs/setup/secure-agent-setup.md#from-your-own-terminal--gits-program-config).

**Optional, beyond git.** `ssh`, `scp`, `sftp` and `rsync` typed
straight into a terminal reach the key with no program setting to
point anywhere — only `PATH` sits in front of them. Offer a shim
directory: `mkdir -p ~/.claude/scripts/shims` and, for each of
`ssh scp sftp rsync`, `ln -sfn ~/.claude/scripts/gpg-touch-overlay.sh
~/.claude/scripts/shims/<name>`. The script dispatches on its own
basename for those names and skips any `PATH` entry resolving back to
itself, so a shim finds the real program and a wrapped git does not
chain into a shim. Create the directory and the links, then **print
the `PATH` line and let the operator add it themselves** — a shell rc
is never edited for them:
`export PATH="$HOME/.claude/scripts/shims:$PATH"`. No sandbox grant
is needed beyond K.4's: the links resolve to the script already
allowed there. Skip the offer when the operator has no touch-required
key. Rationale and the undo:
[docs/setup/secure-agent-setup.md → Beyond git](../../../../docs/setup/secure-agent-setup.md#beyond-git--ssh-scp-sftp-and-rsync-you-type-yourself).

**K.4 — Sandbox grants.** Three, all surfaced as one settings diff:
gpg-agent's ssh socket (`gpgconf --list-dirs agent-ssh-socket`,
absolute path) under `sandbox.network.allowUnixSockets`, so a sandboxed
Expand Down
4 changes: 3 additions & 1 deletion plugins/magpie-setup/skills/isolated-setup-update/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ Walk each:
overlay where that is installed (the `gpg-touch-wrap-*` entry
beside them is a symlink to the script, not a copy — nothing to
diff, but report it missing when git's `gpg.ssh.program` /
`gpg.program` or `core.sshCommand` names it and it is gone),
`gpg.program` or `core.sshCommand` names it and it is gone; the
same for any link under `~/.claude/scripts/shims/`, which is a
symlink whose target is compared, not its contents),
`~/.claude/scripts/container-gateway-hook.sh` for the container
gateway's `SessionStart` / `SessionEnd` hook (diff against
`tools/agent-isolation/container-gateway-hook.sh`), and the
Expand Down
14 changes: 14 additions & 0 deletions plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,20 @@ Walk each in order:
`~/.claude/`:
[`docs/setup/sandbox-troubleshooting.md` → Signed commit fails with "cannot exec" of the touch-overlay wrapper](../../../../docs/setup/sandbox-troubleshooting.md#signed-commit-fails-with-cannot-exec-of-the-touch-overlay-wrapper).

**10e — the ssh shim directory, if the user wants one.** `ssh`,
`scp`, `sftp` and `rsync` typed into a terminal have no program
setting to point anywhere, so only a `PATH` shim puts the wrapper
in front of them. Absent entirely is **n/a**, not a gap — it is
opt-in. When `~/.claude/scripts/shims/` exists, every link in it
must resolve to `~/.claude/scripts/gpg-touch-overlay.sh`
(`readlink -f`) and be named for a command the script dispatches
on — a link named anything else never wraps and is ⚠ with the
name shown. The directory must also appear in `PATH` ahead of
`/usr/bin`: compare `command -v ssh` against the shim path, and
report ⚠ with both paths when the real binary wins, since the
links are then inert. Rationale and the `PATH` line:
[`docs/setup/secure-agent-setup.md` → Beyond git](../../../../docs/setup/secure-agent-setup.md#beyond-git--ssh-scp-sftp-and-rsync-you-type-yourself).

11. **`gh` runs outside the sandbox.** `sandbox.excludedCommands`
must contain `"gh *"` in the project `.claude/settings.json` or
the user-scope `~/.claude/settings.json`. On macOS a sandboxed
Expand Down
46 changes: 42 additions & 4 deletions tools/agent-isolation/gpg-touch-overlay.sh
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ agent_socket_rows() {
# sign just as much as `git commit -m`.
readonly KEY_SUBCOMMANDS='commit|tag|merge|rebase|revert|cherry-pick|am|push|pull|fetch|clone|ls-remote|remote|submodule'

# The same reach, without git in front of it. An ssh transport asks the
# key for its authentication touch before anything moves, and gpg asks
# for the signing touch straight through the OpenPGP card, so a bare
# `scp`, `rsync -e ssh` or `gpg --detach-sign` blocks exactly as a
# `git push` does — and, until these were listed, blocked with no
# window. The word has to end where a shell word ends, which is what
# keeps `ssh-agent` (starts one, never asks the key) and `sshuttle` out
# while leaving `ssh-keygen -Y sign` and `ssh-add -l` in. Longer names
# lead the alternation so the match cannot stop short at `ssh`.
readonly KEY_COMMANDS='ssh-keygen|ssh-add|ssh|scp|sftp|sshfs|rsync|svn|gpg2|gpg'

# A screen to draw on, and something to draw the window with.
#
# On macOS both questions collapse into one: a logged-in user always has
Expand Down Expand Up @@ -401,7 +412,7 @@ arm() {
session="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null)"

printf '%s' "$command_text" |
grep -Eq "(^|[;&|(]|[[:space:]])git([[:space:]]+-[A-Za-z-]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+($KEY_SUBCOMMANDS)([[:space:];&|)]|$)" ||
grep -Eq "(^|[;&|(]|[[:space:]])(git([[:space:]]+-[A-Za-z-]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+($KEY_SUBCOMMANDS)|($KEY_COMMANDS))([[:space:];&|)]|$)" ||
return 0

# Test seam: report the decision instead of spawning a watcher, so
Expand Down Expand Up @@ -495,12 +506,24 @@ wrap() {
local program=$1; shift
local real="" candidate
# The real program: first match on PATH that is not this script under
# another name.
# another name. `type -aP` and not `command -v -a`: bash's `command`
# has no -a, so that spelling only ever printed a usage error, left
# every candidate unseen and fell through to the name-only fallback
# below -- harmless while this script was never on PATH, fatal once
# a shim puts it there.
while IFS= read -r candidate; do
[[ "$(readlink -f "$candidate" 2>/dev/null)" == "$SELF" ]] && continue
real=$candidate; break
done < <(command -v -a "$program" 2>/dev/null || true)
[[ -n $real ]] || real=$program
done < <(type -aP "$program" 2>/dev/null || true)
# No fallback to the bare name. A shim is on PATH *as* that name, so
# exec'ing it re-enters this script forever: a hung terminal, no
# window, no message, and a load average that climbs until someone
# goes looking. 127 is what a missing program exits with anyway.
if [[ -z $real ]]; then
printf '%s: no %s found on PATH behind this wrapper\n' \
"${0##*/}" "$program" >&2
exit 127
fi

# Which invocations reach the key. A signing program is also git's
# verifier (`git log --show-signature` runs `ssh-keygen -Y verify`
Expand Down Expand Up @@ -775,6 +798,21 @@ if [[ $_name == gpg-touch-wrap-?* ]]; then
exit $?
fi

# The same dispatch under the program's own name, which is what a bare
# `ssh` typed in a terminal needs: git can be told to call a wrapper
# (`gpg.ssh.program`, `core.sshCommand`), a person cannot, so the only
# thing that can put the wrapper in front of them is PATH. A shim
# directory early on PATH holds symlinks named `ssh`, `scp`, `sftp`,
# `rsync`; `wrap` skips any PATH candidate that resolves back to this
# script, so the shim finds the real program behind itself. Restricted
# to the key commands on purpose -- a symlink named anything else is a
# mistake, and exec'ing what is behind it would hide that mistake for
# as long as the link lived.
if [[ $_name =~ ^(${KEY_COMMANDS})$ ]]; then
wrap "$_name" "$@"
exit $?
fi

case "${1:-}" in
arm) arm; exit 0 ;;
wrap) shift; wrap "$@"; exit $? ;;
Expand Down
Loading
Loading