Skip to content

Hardening Against AI

When you delegate work to AI coding tools (Claude Code, Codex, Aider, Cursor, Gemini CLI, etc.), they run with your full user privileges. They can read, write, delete, push, force-push, and call any external API your shell can reach. This is by design — you want them to actually do work — but it means you need a defense strategy that does not rely on the AI behaving correctly.

This guide is AI-agnostic and tool-agnostic. It applies whether you use Karajan, raw Claude Code, raw Codex, or anything else.

Once kj harden has run, the rules don’t live in a prompt your agent can forget — they live in git guards and the project’s policy. So when your agent (Claude Code, Codex, Antigravity, the VS Code assistant) tries to commit something that breaks a rule — no card, no review verdict, an AI-attribution footer, a change to a security-class rule — the commit simply doesn’t enter, and the reason is printed in the session. You don’t police the AI; the mechanism does. The one category that is never overridable, not even by you, is security.

Harden a repo and read what it now enforces:

Terminal window
kj harden # installs the git guards + the Sentinel for this project
cat .karajan/policy.yml # what each role may do; which rules only warn vs deny
kj policy report # the standing rules, granted exceptions, and the sealed decision log

A rule marked class: security closes without escape and without arbitration — a deny you cannot argue with is the point.

“I’ll add rm -rf to my deny list.”

This is whack-a-mole. For every dangerous command you block, the AI can find another:

Blocked Equivalent
rm -rf find -delete
find -delete python -c "shutil.rmtree(...)"
python node -e "fs.rmSync(...)"
node perl -e "unlink ..."

You’d need to enumerate every shell command, every interpreter, every language’s filesystem API. You can’t.

Defense in depth, with layers that don’t depend on enumerating bad behavior. The strong layers are kernel-enforced and server-side; everything else is reinforcement.

Layer 1 — Recovery: assume something will break

Section titled “Layer 1 — Recovery: assume something will break”

Take regular encrypted backups to a destination you control. The goal isn’t “prevent damage” but “make recovery cheap and reliable”.

Recommended: BorgBackup to a local NAS, daily, with deduplication and AES encryption.

Terminal window
# Initialize repo
borg init --encryption=repokey-blake2 user@nas:/path/to/borg-repo
# Daily snapshot
export BORG_PASSCOMMAND="cat ~/.config/borg/passphrase"
borg create user@nas:/path/to/borg-repo::"$(date +%Y%m%dT%H%M%S)" \
~/projects \
--exclude '**/node_modules' \
--exclude '**/dist' \
--exclude '**/.cache'
# Retention: 7 daily, 4 weekly, 6 monthly
borg prune user@nas:/path/to/borg-repo \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6

If an AI deletes something you needed, recovery is one borg extract away.

Layer 2 — Prevention: a separate restricted Linux user

Section titled “Layer 2 — Prevention: a separate restricted Linux user”

This is the strongest layer. Create a dedicated user (e.g., ia-user) for running AI tools. The kernel itself enforces what the user can read and write.

Why it works: an AI running as ia-user cannot escape the UID. No find -delete, no Python script, no exotic syscall can bypass it. The kernel returns EACCES before the AI even attempts the operation.

Terminal window
# 1. Create user
sudo useradd -m -s /bin/bash ia-user
sudo passwd ia-user
# 2. Allow ia-user to traverse your home and read project dirs
setfacl -m u:ia-user:--x /home/$USER
setfacl -R -m u:ia-user:r-X /home/$USER/projects
setfacl -dR -m u:ia-user:r-X /home/$USER/projects
# 3. Generate a separate SSH key for ia-user, register it on GitHub
sudo -u ia-user ssh-keygen -t ed25519 \
-f /home/ia-user/.ssh/id_ed25519 -N ""
gh ssh-key add /home/ia-user/.ssh/id_ed25519.pub \
--title "ia-user@$(hostname)"
# 4. Make claude (or any other CLI) discoverable
sudo ln -s "$(which claude)" /usr/local/bin/claude

Per-project workflow: grant write only when actively working, revoke after.

Terminal window
# Grant write on a project
setfacl -R -m u:ia-user:rwX ~/projects/active-project
setfacl -dR -m u:ia-user:rwX ~/projects/active-project
# Run AI tools as ia-user
sudo -i -u ia-user
cd /home/$YOUR_USER/projects/active-project
claude # or codex, gemini, aider...

One restricted user serves all AI CLIs. Claude Code, Codex, Aider, Gemini CLI, Cursor — they all run under the same UID and inherit the same restrictions. One configuration protects the whole ecosystem.

Layer 3 — Immutability for critical files

Section titled “Layer 3 — Immutability for critical files”

For files that must never change (SSH keys, identity configs):

Terminal window
sudo chattr +i ~/.ssh/id_ed25519
sudo chattr +i ~/.ssh/id_ed25519.pub
sudo chattr +i ~/.ssh/config

Once set, even sudo rm -rf cannot remove the file. To modify legitimately, run sudo chattr -i <file> first.

Layer 4 — Server-side protection on Git hosts

Section titled “Layer 4 — Server-side protection on Git hosts”

The remote should enforce its own rules independent of your local environment.

Block force-push and deletion of main on every repo:

Terminal window
gh api -X POST repos/OWNER/REPO/rulesets --input - <<EOF
{
"name": "Protect main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["refs/heads/main", "refs/heads/master"], "exclude": [] }
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" }
],
"bypass_actors": []
}
EOF

Don’t grant delete_repo to your default GitHub token. Without that scope, no AI can delete your repos via the API even if it tries.

Terminal window
gh auth status # verify scopes; ensure delete_repo is NOT listed

A token without admin power for the agent (ADR 0037)

Section titled “A token without admin power for the agent (ADR 0037)”

Going further: the agent should not hold admin power at all. A classic or OAuth token with the repo scope lets it change branch protection, create environments and secrets, approve a protected environment and merge as admin, all in your name. An agent running as your OS user can read any token stored on your laptop, so a second account is not enough: the admin-capable token has to leave the laptop.

  1. Create a fine-grained token at github.com/settings/personal-access-tokens/new, owned by your account. Give it Contents, Pull requests, Issues, Workflows and Actions (read and write), plus Commit statuses (read). Fine-grained tokens have no Checks permission; a PR’s checks are read through the permissions above. Give it no account permissions. Leave out Administration, Environments, Secrets, Variables and Deployments.
  2. Load it into gh yourself, in your own terminal: read -rs T && printf '%s' "$T" | gh auth login --with-token; unset T. read -s hides what you type, and gh reads the token from stdin. Never paste it into the agent’s chat or into a command line.
  3. Revoke the old admin-capable credentials: the GitHub CLI authorization under Settings, Applications, Authorized OAuth Apps (it invalidates gh’s old OAuth token), and any old classic token with the repo scope under Settings, Developer settings, Personal access tokens, Tokens (classic). Review the other Authorized OAuth Apps too: an editor signed in to GitHub, such as VS Code, keeps a repo-scoped token on the laptop that the agent could read just the same.
  4. Do admin acts (branch protection, environments, secrets, settings) in the browser with your passkey or 2FA.
  5. Keep “Do not allow bypassing the above settings” (enforce_admins) on in the protected branch. The token still acts as you, and that setting is what stops an admin-role merge bypass.

kj doctor checks this with two probes that change nothing. It warns if the token gh uses can read branch protection (the Administration permission) or approve a protected environment (Deployments write).

Your SSH key stays for git and the agent can read it too. It only does git operations, never the API, and branch protection applies to it. For more, use a FIDO2 key (ed25519-sk), which needs a touch on every push.

This is the weakest layer but useful for catching obvious mistakes.

Example for Claude Code (~/.claude/settings.json):

{
"permissions": {
"deny": [
"Bash(rm -rf*)",
"Bash(rm -fr*)",
"Bash(git push --force*)",
"Bash(git push -f*)",
"Bash(git reset --hard*)",
"Bash(git branch -D*)",
"Bash(gh repo delete*)",
"Bash(gh api -X DELETE*)"
]
}
}

Layer 6 — Pre-commit hooks against AI attribution

Section titled “Layer 6 — Pre-commit hooks against AI attribution”

If your policy is “no AI references in commits/PRs”:

  • Local: pre-commit / commit-msg hook blocking Co-Authored-By: Claude, generated by AI, etc.
  • Server-side: GitHub Action that checks PR body, issue body, commit messages, and review comments.

Reference: @geniova/git-hooks.

Layer Effort Effectiveness
Backups (Borg) Low High — recovery
Restricted user Medium Very high — kernel-enforced prevention
chattr +i on SSH keys Trivial High for those files
Branch protection Trivial High — server-side
Token without delete_repo Trivial High

“This is too much friction.” — Run AI as ia-user only for real delegation. For exploration, your normal user is fine. The friction is one sudo -i -u ia-user per session.

“My AI is trustworthy.” — Maybe today. The defense protects against future model updates, prompt injection from untrusted input, unaudited MCP servers, and your own 2am typos.

“I use Docker.” — Great Layer 2 alternative. Same principle: kernel-enforced isolation, not denylist policing.

You can’t prohibit what you haven’t imagined. But you can deny access to what shouldn’t be touched.

The strong layers (kernel-enforced isolation + reliable backups) do the real work. Everything else is narrow adjustments. This applies whether you’re orchestrating agents with Karajan, running a single AI assistant, or any combination — the defenses live at the OS and Git-host level, which is exactly why they work.

This page is the why. For the how — concrete config for git hooks, agent permissions (Claude / Codex / Gemini), and multi-account SSH — see Recommended Setup.

Karajan Code™ · AGPL-3.0