Scoping a Permissions Block for a New Contractor
A contractor is being onboarded to work on the frontend of a repo for two weeks. The team wants Claude Code to run freely for common frontend tasks without constant approval prompts, but wants to avoid giving broad, unaudited trust given the contractor is temporary and the repo also contains backend and infra code the contractor shouldn't be touching.
A well-meaning teammate proposes this .claude/settings.local.json
for the contractor's checkout:
{
"permissions": {
"allow": [
"Bash(npm *)",
"Bash(git *)"
]
}
}
- Identify the specific risks in this configuration — what could go wrong that the proposer probably didn't intend?
- Rewrite the permissions block to achieve the actual goal (fast, unattended frontend workflow commands) without the risks you identified.
- Explain what role, if any,
permissions.denyshould play here in addition to a narrowerallowlist, and whydenyentries matter even whenallowis already scoped tightly.
1. Risks in the proposed configuration:
Bash(npm *) and Bash(git *) are far broader than "common frontend
tasks." Wildcard permission entries match on the command prefix,
not on intent — Bash(npm *) allows npm run test, but it equally
allows npm publish, npm install <arbitrary-untrusted-package>,
or scripts defined in package.json that could do almost anything
(some npm lifecycle scripts execute arbitrary shell commands).
Bash(git *) is worse: it allows git push --force, git push origin main, git reset --hard, and git clean -fd — all
unattended, all destructive, none of them "frontend build tooling."
None of this requires malicious intent from the contractor or the
agent; it only requires a task that happens to construct one of
these commands (e.g., "clean up my branch" plausibly leading to
git reset --hard or git push --force), and the point of scoping
permissions is exactly to prevent unattended execution of things
like that, not to prevent deliberate misuse.
There's also nothing here that reflects the "temporary, restricted scope" intent at all — this configuration would be exactly as broad for a full-time senior engineer as for a two-week contractor who shouldn't be touching backend/infra. The stated goal (fast frontend iteration) doesn't require anywhere near this much trust.
2. A narrower, goal-matched configuration:
{
"permissions": {
"allow": [
"Bash(npm run lint)",
"Bash(npm run test *)",
"Bash(npm run build)",
"Bash(npm run dev)",
"Bash(git diff *)",
"Bash(git status)",
"Bash(git log *)",
"Bash(git add *)",
"Bash(git commit -m *)"
]
}
}
This allows the actual day-to-day loop (lint, test, build, dev
server, inspecting diffs/status/log, staging and committing changes)
without unattended approval for anything that pushes to a remote,
force-rewrites history, installs new dependencies, or publishes
anything. Notably, even git commit here doesn't cover git push —
that's a deliberate choice, not an oversight: pushing is the point
where local, reversible work becomes shared and harder to undo, and
is a reasonable place to keep a human in the loop, especially for a
temporary contractor.
3. The role of permissions.deny:
A narrow allow list restricts what runs without a prompt, but
anything not listed doesn't vanish — it falls through to interactive
approval, meaning the contractor (a human, presumably prompting
Claude) can still ask Claude to run something outside the allow
list, and could approve it themselves in the moment. If the actual
goal is to make certain actions genuinely off-limits regardless of
what gets approved in a rushed moment — e.g., "never touch anything
under backend/ or infra/ from this checkout" — that needs an
explicit deny, not just omission from allow:
{
"permissions": {
"deny": [
"Read(./backend/**)",
"Edit(./backend/**)",
"Write(./backend/**)",
"Read(./infra/**)",
"Edit(./infra/**)",
"Write(./infra/**)",
"Bash(git push *)"
]
}
}
This matters because deny rules merge across settings scopes and
represent a hard block, whereas an unlisted command is still
reachable through the normal approval flow — relying on "I just
didn't put it in allow" is not the same guarantee as an explicit
deny, and for a restriction the team actually wants to hold (not
just "we didn't get around to automating this"), the explicit deny
is the correct mechanism.
Share this question