- Sam Kent
- 7 min read
- Updated 8 October 2026
Give an AI coding agent one GitHub login and it can reach every repo you can. Give each project its own token instead and it can only touch the repos it's working on.
The short version
Create a fine-grained token for each project, scoped to just its repos, with no admin or workflow access and an expiry date. Store each token in its own file. A small zsh wrapper then picks the right token for the folder you're in, so claude, git and gh all just work. Folders with no mapping get no token at all. A claude session keeps the token of the folder it started in, even if the agent moves to another folder. That token still only reaches its own project's repos.
This is Step 8 of Lock down a spare Mac for AI coding agents, in full. It works the same on any Mac with zsh, with or without the rest of that setup.
Before you start
- The GitHub CLI (
gh) installed. - zsh, the default shell on macOS.
- The commands run as the user your agent works as, called
agenthere. On your own machine, that's you.
Create a fine-grained token per project
GitHub > Settings > Developer settings > Personal access tokens > Fine-grained.
- Resource owner: your account, or the organisation that owns the repos
- Repository access: only select repositories > the project's repo(s)
- Contents: read and write
- Pull requests: read and write
- Metadata: read-only (automatic)
- Everything else: no access, especially Administration and Workflows (Workflows would let an agent edit Actions to leak repo secrets)
- Expiry: 30 to 90 days
Save it straight into your password manager; GitHub shows it once.
Protect main with a ruleset
Repo > Settings > Rules > Rulesets > New ruleset > New branch ruleset:
- Enforcement: Active
- Bypass list: empty (the token acts as you, so any bypass for you is a bypass for the agent)
- Target: default branch
- Rules: Restrict deletions, Require a pull request before merging (0 approvals), Block force pushes
Gotcha
Rulesets on private repos need a paid plan. For a personal account, GitHub Pro unlocks them; no need for an organisation. Pro doesn't cover organisation repos: a private organisation repo needs the organisation on GitHub Team, so until then main isn't protected there and your agent's house rules have to carry it.
Gotcha
Organisations can require fine-grained tokens to be approved. Until an organisation owner approves it (organisation Settings > Personal access tokens > Pending requests), the token gets 403 or "not found" errors.
Gotcha
A fine-grained token acts as you, so GitHub can't tell agent work from yours. Requiring an approval would block you too (you can't approve your own PR). If you need a hard "agent can't merge" rule, use a separate machine account for the agent. Claude Code's auto mode also blocks merging a PR no human has approved by default.
Only an admin can edit or switch off a ruleset. The token has no admin permission.
Why not just gh auth login?
gh holds one login per GitHub user. Every token here belongs to you, so logging in with a second token replaces the first. Instead, tokens live in files and a wrapper hands the right one to each command through GH_TOKEN.
Set it up
1. Store each token in its own file
Keep tokens outside any project folder, so containers never see them:
mkdir -p ~/.config/tokens && chmod 700 ~/.config/tokens
cat > ~/.config/tokens/my-sitePaste the token, press Enter, then Ctrl+D (keeps it out of shell history). Repeat for each project, then:
chmod 600 ~/.config/tokens/*
ls -l ~/.config/tokensEach file should show -rw-------.
2. Map folders to tokens
cat > ~/.config/tokens/map <<'EOF'
# folder token file
~/code/my-site my-site
~/code/client-project client-project
EOFThe longest matching folder wins, so subfolders (like a separate repo checked out inside a project) inherit their project's token.
3. Add the wrapper
cat > ~/.config/gh-tokens.zsh <<'EOF'
# Picks a GitHub token based on the current folder, using ~/.config/tokens/map.
# The longest matching folder wins. Unmapped folders get no token.
_gh_token_name() {
local map="$HOME/.config/tokens/map" dir="$PWD" best="" bestlen=0 p name
[[ -f $map ]] || return 1
while read -r p name; do
[[ -z $p || $p == \#* ]] && continue
p="${p/#\~/$HOME}"
if [[ $dir == $p || $dir == $p/* ]] && (( ${#p} > bestlen )); then
best=$name; bestlen=${#p}
fi
done < "$map"
[[ -n $best ]] && print -r -- "$best"
}
_with_gh_token() {
local name file
name=$(_gh_token_name)
if [[ -n $name ]]; then
file="$HOME/.config/tokens/$name"
if [[ -r $file ]]; then
GH_TOKEN="$(<"$file")" "$@"
return
fi
print -u2 "GitHub token file not found: $name"
fi
"$@"
}
git() { _with_gh_token command git "$@"; }
gh() { _with_gh_token command gh "$@"; }
claude() { print -u2 "GitHub token: ${$(_gh_token_name):-none}"; _with_gh_token command claude "$@"; }
EOF
echo 'source ~/.config/gh-tokens.zsh' >> ~/.zshrc
source ~/.zshrcStarting claude now prints which token it picked, e.g. "GitHub token: my-site".
Gotcha
Don't name a variable path in zsh functions. zsh ties path to PATH, so a local path silently breaks every command. The wrapper uses p for that reason.
Gotcha
Terminals and tmux sessions that were already open don't see the new functions until you run source ~/.zshrc. New ones pick them up automatically.
4. Clone over HTTPS, or switch an existing remote
For a new project, create its folder first and clone into it. Cloning from ~/code would run with no token, since that folder isn't mapped:
mkdir -p ~/code/my-site && cd ~/code/my-site
gh repo clone <owner>/<repo> .For a project that's already on the machine, switch its remote to HTTPS:
cd ~/code/my-site
git remote set-url origin https://github.com/<owner>/<repo>.gitGotcha
gh repo view and gh repo clone need both parts, owner/repo. With just a repo name, gh assumes it belongs to you, which fails for organisation repos.
Gotcha
In examples, <owner> and <repo> are placeholders. Typed literally, zsh reads the angle brackets as redirection and gives parse error near '\n'. Set your repo under Your names in the guide and the commands here fill in too.
5. Point git at gh for credentials
Run this from inside a mapped project folder, since gh may complain when no token is available:
cd ~/code/my-site
gh auth setup-git
git config --global user.name "Your Name"
git config --global user.email "12345678+username@users.noreply.github.com"Use your GitHub private noreply address (Settings > Emails, shown under "Keep my email addresses private").
6. Test each folder
cd ~/code/my-site && gh auth status
cd ~/code/client-project && gh auth statusEach should say you're logged in via GH_TOKEN.
7. Remove any global login
So unmapped folders get nothing. Run it from your home folder and bypass the wrapper:
cd ~
command gh auth logout
gh auth status
git config --global --get-all credential.https://github.com.helpergh auth status should say you're not logged in. The last line should still mention gh auth git-credential. If it's empty, re-run gh auth setup-git from a mapped folder.
Round-trip test
From a project folder:
git switch -c test/agent-setup
echo "Pushed from the spare Mac" > DEVBOX_TEST.md
git add DEVBOX_TEST.md
git commit -m "Test commit from the spare Mac"
git push -u origin test/agent-setup
gh pr create --fill --draft
git push origin HEAD:main
git switch main
gh pr close test/agent-setup --delete-branchThe push straight to main should be rejected by the ruleset. On shared team repos, skip the PR so colleagues don't see it: push the branch, then delete it with git push origin --delete test/agent-setup.
Gotcha
gh pr close needs the pull request's branch, number or URL. On its own, it fails.
Are plaintext token files a problem?
It's how most developer tools store tokens. chmod 600 keeps other users and the containers out. FileVault encrypts the disk. Anything running as the agent user could read them, but encryption wouldn't change that, because unattended runs need to decrypt them too. The real protection is what each token can do: one project's repos, no admin, no workflows and an expiry date. If a token might have leaked, revoke it on GitHub (instant), create a new one and replace the file.
Why not the macOS Keychain? The agent user's login keychain stays locked over SSH (it only unlocks with a desktop login), which breaks unattended runs. Once it's unlocked, any process running as the agent user can read it anyway.
When a token expires, replace its file. Nothing else changes.
Per-project tokens are scoping, not isolation. All the token files sit in one user account, so an agent in one project could read another's. Each token only reaches its own repos, which limits the damage. Hard separation means one user per client.