Skip to content

  • 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 agent here. 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:

agent@spare-mac
mkdir -p ~/.config/tokens && chmod 700 ~/.config/tokens
cat > ~/.config/tokens/my-site

Paste the token, press Enter, then Ctrl+D (keeps it out of shell history). Repeat for each project, then:

agent@spare-mac
chmod 600 ~/.config/tokens/*
ls -l ~/.config/tokens

Each file should show -rw-------.

2. Map folders to tokens

agent@spare-mac
cat > ~/.config/tokens/map <<'EOF'
# folder                  token file
~/code/my-site            my-site
~/code/client-project     client-project
EOF

The longest matching folder wins, so subfolders (like a separate repo checked out inside a project) inherit their project's token.

3. Add the wrapper

agent@spare-mac
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 ~/.zshrc

Starting 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:

agent@spare-mac
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:

agent@spare-mac
cd ~/code/my-site
git remote set-url origin https://github.com/<owner>/<repo>.git

Gotcha

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:

agent@spare-mac
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

agent@spare-mac
cd ~/code/my-site && gh auth status
cd ~/code/client-project && gh auth status

Each 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:

agent@spare-mac
cd ~
command gh auth logout
gh auth status
git config --global --get-all credential.https://github.com.helper

gh 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:

agent@spare-mac
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-branch

The 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.