The manual path — four root steps a checkout installs with, no RPM. The package install
(see the README) automates all of it; sudo ai-tools-bootstrap automates steps 2–3 once
install.sh has deployed it, and install.sh automates everything from step 4 on.
Set the recurring identities once, in the shell you run these steps in, so every command pastes verbatim (the full naming spec is in naming-conventions.md):
export PROJECTS_USER="$(id -un)"
export PROJECTS_GROUP="$(id -gn)"
export PROJECTS_HOME="${HOME}"
export SANDBOX_USER=ai-tools
export SANDBOX_GROUP=ai-tools
Each critical step also re-states the sandbox name inline, so a step pasted on its own still works.
sudo install -d -o root -g root -m 751 /usr/local/lib/ai-tools
sudo install -o root -g root -m 644 \
src/usr/local/lib/ai-tools/path-dedup.sh /usr/local/lib/ai-tools/path-dedup.sh
(The lib directory's group becomes ai-tools once the account exists —
install.sh and the RPM re-assert root:ai-tools 0751.)
path-dedup deduplicates the shell's existing $PATH and orders it
root-owned-first, so /usr/local/bin/claude — the wrapper that launches
claude restricted — always resolves ahead of the nvm-managed claude. It is
sourced per-account: only the operator shells wired for it get the ordering,
and every other account on the host keeps its stock PATH.
sudo ai-tools-admin operators add <user> offers to wire the source line into your
~/.bashrc and ~/.bash_profile. To wire it by hand, add it to both files
(non-login interactive shells read only ~/.bashrc, login shells ~/.bash_profile),
after your nvm init:
export NVM_DIR="${HOME}/.nvm"
[ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh"
# ai-tools PATH dedup (must follow nvm init)
[[ -f /usr/local/lib/ai-tools/path-dedup.sh ]] && source /usr/local/lib/ai-tools/path-dedup.sh
Those two files are bash's, and operators add names your login shell when it
reads something else. The fragment sources cleanly under zsh, so the same line
goes in ~/.zshrc and ~/.zprofile; a shell that reads no bash (fish) takes
the same tier ordering in its own syntax.
nvm must be sourced before path-dedup: nvm prepends its versioned bin dir
to $PATH, and path-dedup then restructures it into Tier 4, behind the T1
system bins (which include the wrapper) and T2 ~/.local/bin. path-dedup.sh
is idempotent — sourcing it again in the same shell produces the same PATH.
# The sandbox account name is fixed at ai-tools (see "Identities and naming" in the
# README). Set it here so this block works even pasted on its own -- an unset
# SANDBOX_USER makes useradd fail with "invalid user name ''".
SANDBOX_USER=ai-tools
SANDBOX_GROUP=ai-tools
sudo useradd \
--system \
--shell /sbin/nologin \
--home-dir /opt/ai-tools \
--no-create-home \
--comment "AI tools sandbox user" \
"${SANDBOX_USER}"
sudo install -d -o "${SANDBOX_USER}" -g "${SANDBOX_GROUP}" -m 755 /opt/ai-tools
# Lock password (system users have no password by default, but be explicit)
sudo passwd -l "${SANDBOX_USER}"
The install -d creates /opt/ai-tools owned by the account with +x for all, so
${PROJECTS_USER} can traverse into bin/. The RPM ships this account via sysusers.d, so
this step applies only to the from-source path.
/home is mounted nosuid, which would prevent the sudo UID-switch from taking
effect. /opt/ai-tools has no nosuid restriction, so the switch to ${SANDBOX_USER}
actually takes effect.
ai-tools-bootstrap does steps 2 and 3 in one idempotent command once the package is
installed — it creates the account, installs the toolchain, seeds the symlink, and enables
the nvm-update.timer. The manual equivalent:
# cd first: the block runs as ${SANDBOX_USER}, which cannot occupy your home as cwd
sudo -u "${SANDBOX_USER}" bash -c '
cd /opt/ai-tools
export NVM_DIR=/opt/ai-tools/.nvm
export HOME=/opt/ai-tools
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source /opt/ai-tools/.nvm/nvm.sh
nvm install 22
nvm alias default 22
npm install -g @anthropic-ai/claude-code
'
# Create bin dir and initial claude symlink (nvm-update.sh maintains it going forward)
sudo -u "${SANDBOX_USER}" bash -c '
cd /opt/ai-tools
source /opt/ai-tools/.nvm/nvm.sh
mkdir -p /opt/ai-tools/bin
ln -sf "/opt/ai-tools/.nvm/versions/node/$(nvm version default)/bin/claude" \
/opt/ai-tools/bin/claude
'
Once install.sh (step 4) has run, /opt/ai-tools/bin is locked 0551 root:ai-tools and
only root maintains the symlink: instead of the ln above, run sudo ai-tools-bootstrap
(idempotent -- it provisions whatever is missing and seeds the symlink through the root
helper), or re-run sudo ./install.sh install.
Everything from here on is fully automated by install.sh. Complete steps 2 and 3
first — the account must exist (else the script stops with ai-tools user not found)
and /opt/ai-tools/bin must exist (step 3 creates it; the script writes nvm-update.sh
into it). sudo ai-tools-bootstrap does both in one idempotent command. Then run:
sudo ./install.sh install
The script asks which account to enrol as the operator, offering the invoking SUDO_USER
as the default — answer No to name another. A non-interactive run and a plain Enter both
take SUDO_USER. root is refused at either route, including the one that reaches it by
accident: sudo from a root shell sets SUDO_USER=root, and the resulting host has an
operator the CLI refuses every project verb.
Enrolling your own login account is the usual choice. Agent-written files are handed
back to it, so an editor or IDE working in a claimed project keeps seeing its own files,
and a login that can already sudo holds what claiming needs. Enrol a different account
when this host is being set up for someone else, or when a dedicated provisioning account
owns the projects. Create one before installing:
sudo useradd -m -s /bin/bash op && sudo usermod -aG wheel op
useradd creates the login account the script enrols; usermod -aG wheel is this host's
general sudo grant, which nothing in this project writes (see below). Naming an account
that does not exist yet refuses the install and prints this same command.
The question is asked once per account. A re-install whose invoking account already holds
both facts — a name in OPERATORS and ai-ops membership — reports whose host it is
working on and re-asserts that enrolment without prompting.
Name the account up front to skip the question — what an unattended install uses, and how to enrol a different account on a host that already has one:
sudo ./install.sh install --operator op
The name is refused on the same terms as a typed one (root, the ai-tools sandbox
account, an account that does not exist or has no home), and it decides only who is
enrolled: the script still runs as sudo, and its verification suite still runs as the
invoking SUDO_USER.
Enrolment writes the two facts that make an operator — ai-ops membership and a name in
OPERATORS. Claiming a project needs a general sudo grant as well, which nothing here
writes; the host's own sudoers decides it. An operator without one launches agent sessions,
and another operator claims for it with ai-tools --project-claim --for <operator>. A host
needs at least one operator holding the grant, so enrol one that does — a service account
holding none is enrolled after the install with ai-tools-admin, rather than named at this
prompt.
The script deploys the static %ai-ops sudoers drop-in, the helpers and the system
units, creates the approved-projects allowlist with format documentation, installs the
ai-tools project CLI and the /var/opt/ai-tools sandbox area, enables the
nvm-update.timer in ${SANDBOX_USER}'s --user instance, and enables the
ai-tools-relabel.path watcher. It is idempotent — safe to re-run after updates. The
install directory is never auto-registered as a project.
Enrol each further login user as an operator (ai-ops membership, allowlist seed):
sudo ai-tools-admin operators add <user> # defaults to $SUDO_USER
It reports which shape the enrolment produced — whether the account can claim projects, or only launch sessions and have them claimed for it — by asking sudo about that account.
Register projects with the ai-tools CLI, run as your own user (no sudo):
ai-tools --project-claim /path/to/project # an existing directory, claimed in place
ai-tools --project-create /path/to/new # a new project directory, created and claimed
ai-tools --sandbox-create /path/to/repo # an isolated shallow clone
ai-tools --lockdown /path/to/project # revoke agent access to secrets (sudo)
project-lifecycle.md covers registering in depth — claim vs sandbox clone, what each consent prompt grants (including the traverse-only parent grant a home-nested project needs), and every recovery/reversal path.
To remove everything installed by this script:
sudo ./install.sh uninstall
The source→deploy map install.sh applies (the authoritative per-artifact
owner/group/mode list is tests/integration/perms.sh, which
sudo ./install.sh check-perms runs):
| File | Deploy path |
|---|---|
| src/usr/local/lib/ai-tools/path-dedup.sh | /usr/local/lib/ai-tools/path-dedup.sh (root) |
| src/opt/ai-tools/bin/nvm-update.sh | /opt/ai-tools/bin/nvm-update.sh |
| src/usr/local/libexec/ai-tools/ai-tools-chown.sh | /usr/local/libexec/ai-tools/ai-tools-chown (root) |
| src/usr/local/libexec/ai-tools/ai-tools-setgid.sh | /usr/local/libexec/ai-tools/ai-tools-setgid (root) |
| src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh | /usr/local/libexec/ai-tools/ai-tools-launcher-symlink (root) |
| src/usr/local/libexec/ai-tools/ai-tools-relabel-agent.sh | /usr/local/libexec/ai-tools/ai-tools-relabel-agent (root) |
| src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh | /usr/local/libexec/ai-tools/ai-tools-bootstrap (root) |
| src/usr/local/libexec/ai-tools/ai-tools-admin.sh | /usr/local/libexec/ai-tools/ai-tools-admin (root) |
| src/usr/local/libexec/ai-tools/ai-tools-lockdown.sh | /usr/local/libexec/ai-tools/ai-tools-lockdown (root) |
| src/usr/local/libexec/ai-tools/ai-tools-handback.py | /usr/local/libexec/ai-tools/ai-tools-handback (root) |
| src/usr/local/bin/ai-tools-handback-client.py | /usr/local/bin/ai-tools-handback-client (root:ai-tools) |
| src/usr/lib/systemd/system/ai-tools-handback.socket | /usr/lib/systemd/system/ai-tools-handback.socket (root) |
| src/usr/lib/systemd/system/ai-tools-handback@.service | /usr/lib/systemd/system/ai-tools-handback@.service (root) |
| src/usr/local/lib/ai-tools/secret-patterns.lib.sh | /usr/local/lib/ai-tools/secret-patterns.lib.sh (root) |
| src/usr/local/lib/ai-tools/skip-dirs.lib.sh | /usr/local/lib/ai-tools/skip-dirs.lib.sh (root) |
| src/usr/local/lib/ai-tools/filters.lib.sh | /usr/local/lib/ai-tools/filters.lib.sh (root) |
| src/usr/local/lib/ai-tools/filters.d/core.rules | /usr/local/lib/ai-tools/filters.d/core.rules (root) |
| src/usr/local/lib/ai-tools/filters.d/dotnet.rules | /usr/local/lib/ai-tools/filters.d/dotnet.rules (root) |
| src/usr/local/bin/claude.sh | /usr/local/bin/claude (root) |
| src/opt/ai-tools/bin/ai-tools-run.sh | /opt/ai-tools/bin/ai-tools-run |
| src/opt/ai-tools/agents/claude-code/post-tool-hook.sh | /opt/ai-tools/.claude/post-tool-hook.sh |
| src/opt/ai-tools/agents/claude-code/session-hook.sh | /opt/ai-tools/.claude/session-hook.sh |
| src/opt/ai-tools/agents/claude-code/filter-hook.sh | /opt/ai-tools/.claude/filter-hook.sh |
| src/opt/ai-tools/agents/claude-code/settings.json | /opt/ai-tools/.claude/settings.json |
| src/usr/lib/systemd/user/nvm-update.service | /usr/lib/systemd/user/nvm-update.service (root) |
| src/usr/lib/systemd/user/nvm-update.timer | /usr/lib/systemd/user/nvm-update.timer (root) |
| src/usr/lib/systemd/system/ai-tools-relabel.path | /usr/lib/systemd/system/ai-tools-relabel.path (root) |
| src/usr/lib/systemd/system/ai-tools-relabel.service | /usr/lib/systemd/system/ai-tools-relabel.service (root) |
| src/etc/sudoers.d/ai-tools | /etc/sudoers.d/ai-tools (root) |
| src/etc/ai-tools/operator.conf | /etc/ai-tools/operator.conf (root; seeded once, then operator-maintained) |
| install.sh | run in place via sudo |