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.
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.
Liked this workflow?
Get new verified workflows in WebAfterAI, three issues a week (Tue, Thu, Sat).