Linuxsecurity

Read-only forced-command SSH reader for a Git vault

Give cluster services and AI agents safe read-only access to a canonical vault via an SSH forced command — status, read, and search only, reporting the commit on every call, with no shell and no write path.

Distrosdebian, ubuntu, opensuse
Shellbash
Updated
Script
bash
# ─────────────────────────────────────────────────────────────
# One writer (a direct mount), many read-only readers. Readers
# reach the vault through an SSH key pinned to a single command,
# so they can never write, get a shell, or forward anything.
# ─────────────────────────────────────────────────────────────

# 1. On the VAULT HOST: create the reader program
sudo tee /usr/local/bin/vault-reader > /dev/null <<'EOF'
#!/bin/bash
# Invoked via SSH forced command. Request is in $SSH_ORIGINAL_COMMAND.
set -euo pipefail
VAULT=/wiki-data/git
COMMIT=$(git -C "$VAULT" rev-parse HEAD)
read -r verb arg <<< "${SSH_ORIGINAL_COMMAND:-}"
case "$verb" in
  status) echo "commit $COMMIT" ;;
  read)   echo "commit $COMMIT"; git -C "$VAULT" show "HEAD:$arg" ;;
  search) echo "commit $COMMIT"; git -C "$VAULT" grep -n -- "$arg" '*.md' || true ;;
  *)      echo "denied: read-only reader (status|read <path>|search <query>)"; exit 1 ;;
esac
EOF
sudo chmod 755 /usr/local/bin/vault-reader

# 2. On a READER machine: make a dedicated key (no passphrase for automation)
ssh-keygen -t ed25519 -f ~/.ssh/vault-reader -N ""

# 3. On the VAULT HOST: pin that key to the reader via forced command.
#    Append to the reader account's authorized_keys (one line):
#    command="/usr/local/bin/vault-reader",no-pty,no-port-forwarding,\
#    no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA...pubkey... vault-reader
#
#    (paste the PUBLIC key from ~/.ssh/vault-reader.pub after the options)

# 4. Use it from the reader machine — every call prints the commit first
ssh -i ~/.ssh/vault-reader vaultreader@10.0.0.76 "status"
ssh -i ~/.ssh/vault-reader vaultreader@10.0.0.76 "read runbooks/example.md"
ssh -i ~/.ssh/vault-reader vaultreader@10.0.0.76 "search keyring"

# 5. Prove it is locked down: a shell request is refused
ssh -i ~/.ssh/vault-reader vaultreader@10.0.0.76 "bash -i"   # → denied

# 6. Optional: a thin consumer wrapper that fails closed on stale reads
cat > ~/.local/bin/vault-read <<'EOF'
#!/bin/bash
# usage: vault-read <path> [expected-commit]
set -euo pipefail
OUT=$(ssh -i ~/.ssh/vault-reader vaultreader@10.0.0.76 "read $1")
GOT=$(head -1 <<< "$OUT" | awk '{print $2}')
if [ -n "${2:-}" ] && [ "$GOT" != "$2" ]; then
  echo "FAIL CLOSED: reader at $GOT != expected $2 (stale)"; exit 3
fi
tail -n +2 <<< "$OUT"
EOF
chmod +x ~/.local/bin/vault-read

What this does

Gives cluster services, AI agents, and other machines read-only access to a canonical Git vault without any of them becoming a competing authority. An SSH key is pinned with a forced command= so connecting with it can only run a small reader — status, read <path>, search <query> — which prints the current commit on every call. There is no shell, no write path, and no forwarding, so split-brain is structurally impossible for readers.

Why this approach

When you have multiple machines that need to read your knowledge vault — an AI agent on the cluster, a second laptop, a monitoring script — the tempting shortcut is to clone the repo on each machine and pull when you need fresh content. That works right up until one of those machines accidentally does a write (a merge commit, an auto-stash, a git add that shouldn’t have happened), and you’ve silently introduced a second writable copy. The canonical vault has split-brain.

The restricted reader in this playbook makes split-brain structurally impossible for everything except the single editing client, by removing the write path from all other machines at the SSH key level. Each reader gets its own SSH key, but that key is pinned with a command= entry in authorized_keys — so connecting with it can only invoke one specific script (the reader), never a shell. No shell means no git commit, no git push, no editing of any kind. The key literally cannot be used for anything other than reading vault content.

The reader itself is equally constrained: it accepts exactly three verbs (status, read, search) and passes the argument to a safe subset of Git operations (git show, git grep). The most important output is the canonical commit that appears at the start of every response. Consumers — especially an agent’s begin step — check this commit against the expected HEAD and fail closed if they differ. A reader that hides its revision is dangerous because stale content returned with authority-level confidence is exactly the failure mode the whole architecture exists to prevent.

I set this up after running into the failure mode the hard way: a Hermes agent on a cluster node had been given a full SSH key for vault access (for “convenience”), and at some point during testing it had pulled a different ref and was operating on a slightly stale snapshot. The responses it was giving were mostly right — just outdated. Switching to the restricted reader pattern meant that the agent couldn’t have outdated context without knowing it, because every read call announces its commit and the agent’s begin step verifies it.

Prerequisites

  • The vault on an always-on host with Git installed; a dedicated reader account (e.g. vaultreader).
  • SSH access to add an authorized_keys entry on the vault host.
  • Reader machines that need vault access (replace 10.0.0.76 with the real host IP).

Notes

  • Substitute your own values before running any line — this playbook uses example stand-ins. Replace 10.0.0.76 (your vault host’s IP), vaultreader (your reader account), the ssh-ed25519 AAAA… public key (your generated key), and /wiki-data/git (your repo path).
  • The forced command is the security boundary. command="…",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding pins the key to exactly one program — an attacker or a buggy agent with the key still can’t get a shell or write.
  • Every response starts with the commit on purpose. Consumers (like an agent’s begin step) compare it to the expected HEAD and fail closed if they differ. A read interface that hides its revision turns a stale cache into invisible misinformation.
  • Readers cannot write — by construction. This is what makes the split-brain failure mode impossible for everything except the single editing client with the direct mount.
  • Quote and validate $SSH_ORIGINAL_COMMAND carefully. The reader only dispatches on a fixed verb set and passes the argument to git show/git grep; do not extend it to anything that evals user input or shells out unsafely.
  • Mark tested: true only after confirming, on your own host, that non-allowed commands (including a plain shell) are refused.