chore(claude): tighten gh permission rules and lint settings.json sort order

Consolidate gh ask/deny entries via namespace-wildcard patterns, cover
compound subcommands (item-*, field-*, mark-template*), and add a lint
test that enforces ASCII-sorted permission and sandbox arrays in
.claude/settings.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-04-16 21:47:14 +02:00
1 parent 25f01ccfc4
commit ee30f6d446
3 files changed
+163 -26

No files matched your search

+79 -19
View File
@@ -6,28 +6,88 @@
},
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Read",
"WebFetch(domain:*)",
"WebSearch",
"WebFetch(domain:*)"
"Write"
],
"deny": [
"Bash(gh api * --method DELETE *)",
"Bash(gh api * --method PUT *)",
"Bash(gh api * -X DELETE *)",
"Bash(gh api * -X PUT *)",
"Bash(gh api * /actions/secrets*:*)",
"Bash(gh api repos/*/actions/variables*:*)",
"Bash(gh api repos/*/collaborators*:*)",
"Bash(gh api repos/*/keys*:*)",
"Bash(gh api repos/*/pulls/*/merge:*)",
"Bash(gh extension *)",
"Bash(gh gpg-key *)",
"Bash(gh issue transfer*)",
"Bash(gh pr merge*)",
"Bash(gh repo delete*)",
"Bash(gh secret *)",
"Bash(gh ssh-key *)",
"Bash(gh variable *)",
"Bash(gh workflow disable*)",
"Bash(gh workflow enable*)",
"Bash(git branch -D*)",
"Bash(git clean*)",
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(git clean*)",
"Bash(git branch -D*)",
"Bash(rm -rf*)",
"Bash(sudo*)",
"Bash(gh workflow enable*)",
"Bash(gh workflow disable*)"
"Bash(sudo*)"
],
"ask": [
"Bash(git commit*)",
"Bash(git push*)",
"Bash(docker run*)",
"Bash(gh api*)",
"Bash(gh workflow run*)"
"Bash(gh * archive*)",
"Bash(gh * cancel*)",
"Bash(gh * close*)",
"Bash(gh * comment*)",
"Bash(gh * create*)",
"Bash(gh * delete*)",
"Bash(gh * develop*)",
"Bash(gh * edit*)",
"Bash(gh * field-*)",
"Bash(gh * fork*)",
"Bash(gh * item-*)",
"Bash(gh * link*)",
"Bash(gh * lock*)",
"Bash(gh * mark-template*)",
"Bash(gh * pin*)",
"Bash(gh * ready*)",
"Bash(gh * rename*)",
"Bash(gh * reopen*)",
"Bash(gh * rerun*)",
"Bash(gh * review*)",
"Bash(gh * unlink*)",
"Bash(gh * unlock*)",
"Bash(gh * unmark-template*)",
"Bash(gh * unpin*)",
"Bash(gh * upload*)",
"Bash(gh api * --method *)",
"Bash(gh api * -X *)",
"Bash(gh api enterprises/*:*)",
"Bash(gh api orgs/*:*)",
"Bash(gh api repos/*/actions/permissions*:*)",
"Bash(gh api repos/*/actions/workflows/*/dispatches:*)",
"Bash(gh api repos/*/branches/*/protection*:*)",
"Bash(gh api repos/*/environments*:*)",
"Bash(gh api repos/*/hooks*:*)",
"Bash(gh api repos/*/releases*:*)",
"Bash(gh api repos/*/rulesets*:*)",
"Bash(gh api teams/*:*)",
"Bash(gh auth *)",
"Bash(gh codespace *)",
"Bash(gh config set*)",
"Bash(gh label clone*)",
"Bash(gh pr checkout*)",
"Bash(gh project copy*)",
"Bash(gh repo set-default*)",
"Bash(gh repo sync*)",
"Bash(gh workflow run*)",
"Bash(git commit*)",
"Bash(git push*)"
]
},
"sandbox": {
@@ -67,17 +127,17 @@
"allowWrite": [
".",
"/tmp",
"~/.cache/pre-commit",
"~/.ansible",
"~/.cache/pip",
"~/.venvs",
"~/.ansible"
"~/.cache/pre-commit",
"~/.venvs"
],
"denyRead": [
"~/.ssh",
"~/.aws",
"~/.config/gcloud",
"~/.gnupg",
"~/.kube",
"~/.aws",
"~/.config/gcloud"
"~/.ssh"
]
}
}
@@ -30,7 +30,7 @@ For Bash specifically, `sandbox.autoAllowBashIfSandboxed: true` adds an implicit
## Allow Permissions ✅
The allowlist is intentionally short. With sandboxed Bash auto-allowed, only non-Bash tools and the wildcarded web tools need explicit entries.
The allowlist covers non-Bash tools and the wildcarded web tools. Read-only `gh api` calls (default GET, no `-X`/`--method`) are not listed explicitly: they match no `ask` or `deny` rule and therefore execute via sandbox auto-allow without prompting.
| Permission | When | Why | Security |
|---|---|---|---|
@@ -63,8 +63,28 @@ These operations pause and require explicit operator approval before executing,
| `git commit*` | Creating a permanent history entry. | The operator MUST review the staged diff and message before committing. | Commits are persistent and visible to all contributors after push. |
| `git push*` | Publishing changes to the remote. | Cannot be undone without a force-push. | Exposes changes to all repository collaborators and CI. |
| `docker run*` | Starting a standalone container outside the compose stack. | Each invocation carries a unique risk profile depending on flags. | Can mount host paths, expose ports, and run privileged containers. |
| `gh api*` | Making direct GitHub REST or GraphQL API calls. | Can modify branch protection, secrets, webhooks, collaborators, and workflow triggers. | Supports arbitrary HTTP methods (`-X POST/PATCH/DELETE`). Each invocation MUST be reviewed individually. |
| `gh workflow run*` | Triggering a CI workflow run on the remote. | Consumes runner minutes, executes with workflow secrets, and produces remotely-visible results. Equivalent in effect to `git push` for triggered runs. | Each invocation MUST be reviewed to confirm the target workflow and inputs. |
| `gh api * -X *` / `gh api * --method *` | Any explicit non-GET HTTP verb against the GitHub API. | The default `gh api` verb is GET (read-only). Specifying `-X` or `--method` always indicates a write — POST/PATCH/DELETE/PUT — so the prompt forces a per-call review. PUT and DELETE are blocked outright by `deny`; POST and PATCH land here. | Captures every mutating call regardless of endpoint. The DELETE/PUT-specific deny rules below take precedence and reject those verbs unconditionally. |
| `gh api enterprises/*:*` | Any enterprise-scoped API call. | Enterprise endpoints administer org membership, billing, and policy across multiple orgs. | Reviewer MUST confirm the enterprise slug and intended scope. |
| `gh api orgs/*:*` | Any organization-scoped API call. | Org endpoints govern membership, teams, and org-wide settings. | Reviewer MUST confirm the org and the specific endpoint. |
| `gh api repos/*/actions/permissions*:*` | Reading or mutating Actions permissions on a repo. | Toggles whether forks may run workflows, restricts the action allowlist, and gates repo-level CI policy. | Reviewer MUST confirm the policy delta before approval. |
| `gh api repos/*/actions/workflows/*/dispatches:*` | Triggering a workflow via `workflow_dispatch`. | Equivalent in effect to `gh workflow run` — runs CI with workflow secrets and produces remotely-visible results. | Reviewer MUST confirm the workflow ref and inputs payload. |
| `gh api repos/*/branches/*/protection*:*` | Reading or mutating branch protection on a specific branch. | Branch protection gates pushes, merges, and required checks. PUT is already blocked unconditionally; this pattern catches GET-audit and explicit-PATCH paths. | Reviewer MUST confirm the rule delta. |
| `gh api repos/*/environments*:*` | Reading or mutating Actions environments. | Environments gate deployment secrets and required reviewers; weakening them exposes secrets to additional workflows. | Reviewer MUST confirm the environment and gating change. |
| `gh api repos/*/hooks*:*` | Reading or mutating repository webhooks. | Hooks deliver events to external systems; misconfiguration can leak data or break integrations. | Each call MUST be reviewed for target URL and event scope. |
| `gh api repos/*/releases*:*` | Reading or mutating GitHub releases. | Release creation and asset uploads are user-visible publishing actions. | Reviewer MUST confirm the tag and asset payload before approval. |
| `gh api repos/*/rulesets*:*` | Reading or mutating branch/tag rulesets (branch protection successor). | Rulesets gate merges and pushes; weakening them affects every contributor. | Each call MUST be reviewed for the rule scope and required-checks impact. |
| `gh api teams/*:*` | Any team-scoped API call. | Team endpoints govern membership and permission grants. | Reviewer MUST confirm the team and intended change. |
| `gh * <verb>*` (archive, cancel, close, comment, create, delete, develop, edit, fork, link, lock, pin, ready, rename, reopen, rerun, review, unlink, unlock, unpin, upload) | Any `gh` CLI invocation whose second token is a known mutating verb. | The wildcard in the namespace slot collapses 21 mutating verbs across `gh issue/pr/release/repo/ruleset/run/cache/gist/label/project/…` into a single rule per verb. Future namespaces that ship the same verb (e.g. a hypothetical `gh discussion lock`) are caught automatically — explicit per-namespace lists rot as the `gh` CLI grows. | Read-only subcommands (`view`, `list`, `status`, `diff`, `checks`, `watch`, `download`, `clone`) do not match and remain auto-allowed via the sandbox, mirroring the GET-vs-mutation split used for `gh api`. The `deny` rules above take precedence for the highest-blast verbs (e.g. `gh repo delete`, `gh secret *`, `gh ssh-key *`). |
| `gh * field-*` / `gh * item-*` / `gh * mark-template*` / `gh * unmark-template*` | Compound subcommands (e.g. `gh project item-create`, `gh project field-delete`, `gh project mark-template`). | The single-token `gh * <verb>*` patterns above only match when the third token starts with the verb. Compound names like `item-create` or `mark-template` slip through because the third token is the full compound, not the bare verb. These rules close that gap for Projects v2 mutations namespace-wide. | Catches all current and future compound-verb mutations without requiring per-verb enumeration. |
| `gh auth *` | Any `gh auth` subcommand (login, logout, refresh, setup-git, status, switch, token). | Whole namespace asked: `logout` is operator-scope DoS; `token` prints the OAuth/PAT to stdout where it would land in the transcript permanently if approved (operator MUST decline unless they explicitly want the token captured); `switch` silently changes the active identity; `setup-git` rewrites global git config; `login`/`refresh`/`status` are lower-risk but kept inside the same gate for symmetry. The trade-off vs. an explicit `deny` on `logout`/`token` is that approval is now a single click — operators must read the prompt before tapping through. | Reviewer MUST confirm intent for every invocation; in particular `gh auth token` MUST be declined unless the operator deliberately wants the token in the transcript. |
| `gh codespace *` | Any codespace subcommand (create, ssh, cp, delete, ports, …). | Codespaces are billable, persistent VMs with full repo + secret access; `ssh`/`cp` open a remote shell or move files in/out. The whole namespace is asked because even read commands (`list`, `view`) interact with billable infrastructure. | Each invocation MUST be reviewed individually for billing + data-flow impact. |
| `gh config set*` | Sets a `gh` CLI config key. | `gh config set editor /tmp/evil.sh` plants a binary that runs the next time any `gh` subcommand opens an editor (e.g. `gh issue create` without `--body`) — a deferred-execution backdoor that survives the current session. Persistent CLI config belongs to a deliberate operator decision. | Reviewer MUST confirm the key and value, especially for `editor`, `pager`, `git_protocol`. |
| `gh label clone*` | Copies all labels from one repository into another. | Mutation against the destination repo. Verb `clone` is not in the wildcard list because it is otherwise read-only-ish (e.g. `gh repo clone` is just a git clone). | Reviewer MUST confirm both source and destination repos. |
| `gh pr checkout*` | Switches the local working tree to a PR branch. | Does not check for uncommitted changes by default — silently overwrites them. The risk is to local state, not remote, but the loss is unrecoverable. | Reviewer MUST confirm the working tree is clean (or stashed) before approving. |
| `gh project copy*` | Duplicates an entire Projects v2 board. | Verb `copy` is not in the wildcard list. The duplicate is created under the operator's identity and may be public-by-default depending on org settings. | Reviewer MUST confirm the source/destination owners and visibility. |
| `gh repo set-default*` | Sets the default repository for `gh` invocations in the current directory. | Mutates per-directory `gh` config; subsequent ambiguous `gh pr/issue/run` calls would silently target the new default, which can mis-route mutations. Verb `set-default` is not in the wildcard list. | Reviewer MUST confirm the new default repository. |
| `gh repo sync*` | Syncs a fork against its upstream. | Pulls upstream commits into the fork (the operator's repo). Verb `sync` is not in the wildcard list because `gh repo clone`-style reads use related verbs that should remain frictionless. | Reviewer MUST confirm the source/destination repos and that the local working tree tolerates the sync. |
| `gh workflow run*` | Triggering a CI workflow run on the remote. | Consumes runner minutes, executes with workflow secrets, and produces remotely-visible results. Equivalent in effect to `git push` for triggered runs. The verb is `run`, which is not in the wildcarded mutating-verb list above, so it needs its own entry. | Each invocation MUST be reviewed to confirm the target workflow and inputs. |
## Deny Rules 🚫
@@ -72,14 +92,29 @@ These operations are unconditionally blocked, regardless of any `allow` entry or
| Permission | Reason |
|---|---|
| `gh api * -X DELETE *` / `gh api * --method DELETE *` | DELETE is irreversible from the API surface — deleting a release, branch ref, deploy key, or webhook destroys data with no undo. Both `-X` and `--method` spellings are blocked so neither shorthand slips through. |
| `gh api * -X PUT *` / `gh api * --method PUT *` | PUT replaces a resource wholesale. On endpoints like branch protection or rulesets, a PUT silently overwrites the entire policy with whatever payload is sent — partial JSON drops every unspecified field. PATCH (the merge variant) still goes through `ask`. |
| `gh api * /actions/secrets*:*` | Touches GitHub Actions secrets — reading metadata, creating, updating, or deleting. Even GETs leak secret names; mutations rotate credentials CI depends on. Out-of-band secret management only. |
| `gh api repos/*/actions/variables*:*` | Touches Actions variables. Variables are unencrypted secrets-lite — workflows read them like secrets, and they are equally load-bearing for CI. Out-of-band variable management only. |
| `gh api repos/*/collaborators*:*` | Reads or mutates repository collaborator membership. Mutations escalate access; even GETs leak the contributor list. Access changes belong to a human reviewer. |
| `gh api repos/*/keys*:*` | Reads or mutates repository deploy keys. Deploy keys are full-power repo-auth credentials — adding one creates a persistent backdoor; removing one breaks deployments. Manage out-of-band only. |
| `gh api repos/*/pulls/*/merge:*` | Merges a pull request via API, bypassing the `gh pr merge` confirmation flow. Merges are destructive to branch state and trigger downstream CI/deploys. |
| `gh extension *` | Any `gh extension` subcommand. `install` and `upgrade` pull remote code that then runs in-process under the operator's GitHub credentials on every subsequent `gh` invocation; `remove`/`list`/`search`/`exec` are blocked alongside to keep the namespace simple. Extensions belong to a deliberate operator decision, not an agent. |
| `gh gpg-key *` | Any `gh gpg-key` subcommand. `add` installs a persistent commit-signing identity an attacker could use to impersonate the operator on signed commits; `delete` can lock the operator out of signed-commit policies; `list` leaks the key-fingerprint inventory. Out-of-band key management only. |
| `gh issue transfer*` | Moves an issue (with its history and attachments) into a different repository. The destination repo can be attacker-controlled — equivalent to data exfiltration of the issue's contents. Issue transfers must be a deliberate human action. |
| `gh pr merge*` | CLI parallel to the `/pulls/*/merge` API deny. Merges are destructive to branch state and trigger downstream CI/deploys. |
| `gh repo delete*` | Permanently deletes a repository. No undo from the API; recovery requires GitHub support and is not guaranteed. |
| `gh secret *` | Any `gh secret` subcommand. `set`/`remove` mutate CI secrets under the operator's identity; `list` leaks secret names — exact same risk class as the `gh api * /actions/secrets*` deny ("Even GETs leak secret names"). Out-of-band secret management only. |
| `gh ssh-key *` | Any `gh ssh-key` subcommand. `add` is a persistent auth backdoor giving shell-equivalent push access until revoked; `delete` can lock the operator out; `list` leaks the key-fingerprint inventory. Out-of-band key management only. |
| `gh variable *` | Any `gh variable` subcommand. Mirror of the `gh secret *` rule for Actions variables. Variables are unencrypted secrets-lite; even `list` exposes their values directly, so the entire namespace is blocked and managed out-of-band. |
| `gh workflow disable*` | Disabling a workflow can silently turn off security-critical CI (CodeQL, security-review, lint gates). Must never be done autonomously. |
| `gh workflow enable*` | Re-enabling a workflow can silently restart paused CI (e.g. security scans deliberately disabled during incident response) without operator review. |
| `git branch -D*` | Force-deletes local branches regardless of merge status. Can destroy unmerged work with no recovery path. |
| `git clean*` | Any invocation of `git clean` is blocked. All useful variants (`-f`, `-fd`, `-xfd`) force-delete untracked files with no recovery path; the wildcard covers every flag order. Can silently wipe in-progress work the agent has not yet surfaced to the operator. |
| `git push --force*` | Rewrites remote history. Can permanently destroy other contributors' work. |
| `git reset --hard*` | Discards all uncommitted local changes without any recovery path. |
| `git clean*` | Any invocation of `git clean` is blocked. All useful variants (`-f`, `-fd`, `-xfd`) force-delete untracked files with no recovery path; the wildcard covers every flag order. Can silently wipe in-progress work the agent has not yet surfaced to the operator. |
| `git branch -D*` | Force-deletes local branches regardless of merge status. Can destroy unmerged work with no recovery path. |
| `rm -rf*` | Recursive force-delete with no confirmation and no undo. Sandbox `allowWrite` does not protect here because `.` (the repo) is writable; `rm -rf .` would erase the working tree and `.git/`. |
| `sudo*` | Prevents privilege escalation attempts. The sandbox already blocks privileged operations, but denying `sudo` outright avoids accidental approval prompts and makes the intent explicit. |
| `gh workflow enable*` | Re-enabling a workflow can silently restart paused CI (e.g. security scans deliberately disabled during incident response) without operator review. |
| `gh workflow disable*` | Disabling a workflow can silently turn off security-critical CI (CodeQL, security-review, lint gates). Must never be done autonomously. |
## Environment Overrides 🌳
@@ -0,0 +1,42 @@
import json
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
SETTINGS_PATH = REPO_ROOT / ".claude" / "settings.json"
SORTED_ARRAYS: list[tuple[str, ...]] = [
("permissions", "allow"),
("permissions", "deny"),
("permissions", "ask"),
("sandbox", "network", "allowedDomains"),
("sandbox", "filesystem", "allowWrite"),
("sandbox", "filesystem", "denyRead"),
]
class TestClaudeSettingsSorted(unittest.TestCase):
"""Lint .claude/settings.json: every list-of-strings array we curate by hand
must stay ASCII-sorted so diffs are reviewable and merge conflicts minimal."""
@classmethod
def setUpClass(cls) -> None:
with open(SETTINGS_PATH) as fh:
cls.settings = json.load(fh)
def test_arrays_are_ascending(self) -> None:
for path in SORTED_ARRAYS:
label = ".".join(path)
with self.subTest(array=label):
obj = self.settings
for key in path:
obj = obj[key]
self.assertEqual(
obj,
sorted(obj),
f"{label} is not ASCII-sorted ascending in {SETTINGS_PATH}",
)
if __name__ == "__main__":
unittest.main()