< back to series
DAY 16

Host-Routed Git for Protected Repo Operations

Mar 30, 2026

Day 9 tightened the tool tier. Python tools now run as tito-toolbox under nftables and AppArmor, with outbound HTTP routed through the secrets proxy. That closed the obvious supply-chain holes, but it also exposed a practical problem: website repo pushes were no longer a normal tool-tier operation.

The root issue was simple. The website repos use SSH deploy keys stored under /home/tito/.ssh/. The restricted tool tier is not supposed to read that directory, and AppArmor does not grant it. That is the right default for day-to-day tool execution, but it means a repo push from the restricted lane will fail unless there is a narrow, explicit path for it.

This post covers that path.

Routing shape


The problem

Before the routing fix, a website push from the assistant path was failing for a few different reasons at once:

  • git was running from the restricted exec lane instead of the host user context
  • the process was resolving paths under /home/tito-toolbox in some cases instead of /home/tito
  • SSH key access for the website repo did not belong in the restricted tool tier in the first place

The result was not a subtle bug. Commits and pushes to solutionscay.com/ broke because the tool tier did not have the host identity and host SSH key path those operations expect.

That failure was useful. It forced the distinction between two kinds of work:

  1. normal tools that should stay inside the restricted lane
  2. a very small set of repo operations that need host-side identity and host-side SSH custody

Design goal

The fix was not "let the restricted tier read ~/.ssh again."

That would have undone the point of day 9.

The goal was narrower:

  • keep the restricted lane as the default for tool execution
  • add a specific route for approved website git operations
  • keep the route tied to known repos and known SSH keys
  • avoid turning git into a general escape hatch

That leads to two pieces:

  • tito-tools decides when a command should stay in tito-toolbox and when it should be handed to host tito
  • tito-git restricts host-routed git operations to an allowlisted set of repos with fixed SSH keys

tito-tools: route one narrow class of command

The wrapper still defaults to tito-toolbox. Any tito-git invocation — regardless of which repo — is routed through host tito. Everything else stays restricted:

bash#!/bin/bash
set -euo pipefail

export TITO_TOOL_TIER=restricted
export HTTP_PROXY=http://127.0.0.1:18080
export HTTPS_PROXY=http://127.0.0.1:18080
export http_proxy=http://127.0.0.1:18080
export https_proxy=http://127.0.0.1:18080

route_website_git() {
    local cmd="$1"

    # Route ALL tito-git invocations through host tito — no repo exceptions
    if [[ "$cmd" == *"tito-git"* ]]; then
        exec sudo -n -u tito /bin/bash -lc "$cmd"
    fi

    exec sudo -n -u tito-toolbox /bin/bash -c "$cmd"
}

tito-git: repo allowlist, subcommand allowlist, fixed key mapping

The host-routed side uses a separate wrapper with two layers of validation:

bash#!/bin/bash
set -euo pipefail

export HOME=/home/tito

declare -A REPO_KEYS=(
    ["/home/tito/.openclaw/workspace"]="$HOME/.ssh/openclaw-tito-key"
    ["/home/tito/.openclaw/workspace/solutionscay.com"]="$HOME/.ssh/tito-solutionscay.com-key"
    ["/home/tito/.openclaw/workspace/planolinestriping.com"]="$HOME/.ssh/tito-planolinestriping.com-key"
    ["/home/tito/.openclaw/workspace/services-cay"]="$HOME/.ssh/tito-servicescay-app"
)

ALLOWED_SUBCOMMANDS=(
    add commit push pull fetch
    status diff log
    checkout stash
)

REPO_PATH="$1"
shift
SUBCOMMAND="$1"

# Validate repo path
if [ -z "${REPO_KEYS[$REPO_PATH]+_}" ]; then
    echo "tito-git: repo not in allowlist: $REPO_PATH" >&2
    exit 1
fi

# Validate subcommand
ALLOWED=false
for cmd in "${ALLOWED_SUBCOMMANDS[@]}"; do
    [ "$SUBCOMMAND" = "$cmd" ] && ALLOWED=true && break
done
if [ "$ALLOWED" = false ]; then
    echo "tito-git: subcommand not allowed: $SUBCOMMAND" >&2
    exit 1
fi

SSH_KEY="${REPO_KEYS[$REPO_PATH]}"
export GIT_SSH_COMMAND="ssh -i $SSH_KEY -o StrictHostKeyChecking=no -o BatchMode=yes"
cd "$REPO_PATH"
exec git "$@"

This wrapper does four things:

  • rejects repos not in the allowlist
  • rejects git subcommands not in the allowlist — archive, config, credential, and others are blocked
  • uses a fixed host home for key lookup instead of inheriting a wrong $HOME
  • binds each allowed repo to a specific SSH key

The subcommand restriction matters. Without it, the repo allowlist stops path traversal but still allows arbitrary git operations as titogit archive to read files, git config to modify host git settings, git credential to interact with credential storage. The allowlist closes that surface.


Why this is a separate day from day 9

Day 9 was about reducing the privileges of the general tool tier.

This change is about what to do when a real workflow still needs host identity.

Those are related, but they are not the same decision. The mistake would have been to quietly poke a hole in day 9 and call it done. The better move was to keep day 9 honest, then add a separate, explicit mechanism for the small category of work that still belongs to the host user.

That mechanism is easier to reason about because it is narrow and boring:

  • only known repos
  • only known git subcommands
  • only the mapped key for that repo
  • restricted lane stays restricted for everything else

Tradeoffs

Host-routed wrapper over exposing ~/.ssh to the tool tier: Exposing the SSH directory would be simpler in the short term, but it would weaken the main day 9 boundary. The host-routed wrapper keeps key custody with tito and leaves tito-toolbox out of it.

Repo allowlist over generic host git: A generic host git trampoline would work, but it would be broader than needed. The allowlist keeps the route tied to a known set of repos and deploy keys.

Subcommand allowlist over unrestricted git args: Validating the repo path is not enough. git has subcommands (archive, config, credential) that can read files, modify host configuration, or interact with credential storage — all running as tito with no nftables fence. Restricting to operational subcommands only (add, commit, push, pull, fetch, status, diff, log, checkout, stash) closes that surface.

Fixed HOME=/home/tito over inherited environment: The inherited home directory was part of the failure path. Using a fixed host home removed ambiguity and made key lookup deterministic.


What this still does not fix

This is a controlled operational path, not full SSH key isolation.

The deploy keys still live under /home/tito/.ssh/. The difference is that the restricted tool tier no longer reads them directly for normal work, and the only intended host-routed access path is the repo wrapper described here — with a known repo, a known subcommand, and a fixed key.

That is better than the pre-fix state, but it is still not the end-state for SSH key custody. Day 9 called that out, and it remains true.


Result

After the routing fix, website repo commits and pushes could run again from the assistant path without reopening the restricted lane.

The useful boundary is now clearer:

  • prospecting, CRM work, aerial tooling, and other normal tools stay in the restricted lane
  • website repo pushes use the host-routed git path on purpose

That is the kind of split I want in this system. General automation stays constrained. The exceptional path is explicit.


What's next

The next cleanup step is to revisit SSH key custody directly instead of relying on better routing around the existing location.