Claude Code: Lock Down an Unattended Run with Permission Rules
Define exactly what a scheduled or headless Claude Code run may do via settings.json permission rules.
Run this workflow
CI-verified, 5/5 fixtures passing.
Build this with your agent
One copy-paste hands Claude Code, Codex, or Cursor the full recipe, steps included, nothing to fetch.
Intended Use
Defining exactly what an unattended Claude Code run may do via settings.json permission rules, so a scheduled or headless run cannot do something you would regret.
Not for
- Granting blanket access, the point is least privilege
- bypassPermissions environments, which skip the permission layer entirely
- Trusting allow rules alone, deny always wins and should cover pushes, deletes, and secrets
The Stack
Tested Against
claude-code@2.1.83node@20.xSide effects & data flow
- Network
- none, local only
- Writes
- ./settings.json, ./checkperms.mjs
- Credentials
- none required
Steps
- 1
Write the permission rules
Permission rules live in settings.json under permissions, with allow, deny, and ask lists plus a defaultMode. dontAsk mode runs only what your allow list permits and silently denies the rest, which is exactly what you want for a scheduled or headless run.
cat > settings.json <<'EOF' { "permissions": { "defaultMode": "dontAsk", "allow": [ "Read", "Bash(npm test)", "Bash(npm run lint)", "Bash(git status)", "Bash(git diff *)", "WebFetch(domain:docs.python.org)" ], "deny": [ "Bash(git push *)", "Bash(rm *)", "Read(.env)", "Edit(.env)", "Edit(/secrets/**)" ] } } EOF echo "wrote settings.json" - 2
What CI checks: the JSON parses and the deny list actually blocks the dangerous things
CI parses the JSON, confirms defaultMode is a real mode (dontAsk, per code.claude.com/docs), checks the deny list blocks pushes, deletions, and secret files, and checks the allow list contains only scoped entries (no blanket Bash(*)). No API key needed.
cat > checkperms.mjs <<'EOF' import { readFileSync } from 'node:fs'; const cfg = JSON.parse(readFileSync('settings.json','utf8')); const MODES = ['default','acceptEdits','plan','auto','dontAsk','bypassPermissions']; const p = cfg.permissions || {}; const deny = p.deny || []; const allow = p.allow || []; let ok = true; function check(label, cond){ console.log(label + ': ' + (cond ? 'yes' : 'NO')); if(!cond) ok=false; } check('settings.json parses', true); check('defaultMode is a real mode', MODES.includes(p.defaultMode)); check('deny blocks git push', deny.some(r => r.includes('git push'))); check('deny blocks rm', deny.some(r => r.includes('rm'))); check('deny blocks .env', deny.some(r => r.includes('.env'))); const dangerous = a => a === 'Bash(*)' || a.includes('rm ') || a.includes('git push'); check('allow is scoped (no blanket Bash(*) or destructive)', allow.length > 0 && !allow.some(dangerous)); if(!ok){ console.log('permission lockdown check FAILED'); process.exit(1); } console.log('permission lockdown check OK'); EOF node checkperms.mjs - 3
Run something with it (the model step, not checked by CI)
A run that uses these rules invokes the model, which is non-deterministic and fenced. CI proves the rules are well-formed and block the dangerous operations; it does not run the agent.
Eval, 5 fixtures
Last passed: verified todaymode-validcontainstimeout 30s · max $0Expected:
defaultMode is a real mode: yesdeny-pushcontainstimeout 30s · max $0Expected:
deny blocks git push: yesdeny-secretscontainstimeout 30s · max $0Expected:
deny blocks .env: yescheck-okcontainstimeout 30s · max $0Expected:
permission lockdown check OKclean-exitexit_codetimeout 30s · max $0Expected:
0
Results
Rules evaluate deny, then ask, then allow, so a deny always wins. Verifiable with no API key.
Did this work for you?
Our CI checks the setup runs. You tell us if the whole thing worked. Tell us straight.
Related workflows
- One AGENTS.md, no drift: prove CLAUDE.md is a real symlink and the generated files are in sync
- Shepherd: prove an agent task is retained and least-privilege before it runs
- Vet the fine print a star count hides: real license and a gate on dual-use tools
- Agent-Reach: throwaway account, least privilege, scan before install
- Vet a SKILL.md before you install it
- SkillSpector: fail your CI build on a risky agent skill
Liked this workflow?
Get new verified workflows in WebAfterAI, three issues a week (Tue, Thu, Sat).