Paths Subjects Questions Quizzes Pricing Search
Intermediate Open Free

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 *)"
    ]
  }
}
  1. Identify the specific risks in this configuration — what could go wrong that the proposer probably didn't intend?
  2. Rewrite the permissions block to achieve the actual goal (fast, unattended frontend workflow commands) without the risks you identified.
  3. Explain what role, if any, permissions.deny should play here in addition to a narrower allow list, and why deny entries matter even when allow is already scoped tightly.
Solution

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

← Back to CLAUDE.md and Context Configuration practice

We use cookies for product analytics to improve OmniAtlas. See our Privacy Policy.