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-toolboxin 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:
- normal tools that should stay inside the restricted lane
- 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-toolsdecides when a command should stay intito-toolboxand when it should be handed to hosttitotito-gitrestricts 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 tito — git 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.
