Claude Code Permissions Management
Document Location
This document is located at: ~/stuff/gitrepos/gh/vtmocanu/skills/claude-permissions/SKILL.md (public repo: github.com/vtmocanu/skills)
Note: This is the source of truth. The installed copy at
~/.claude/skills/claude-permissions/SKILL.mdis derived from this file by thenpx skillspackage manager; edit here, then runnpx skills updateto re-pull it. Never edit the installed copy.
Dippy docs: Configuration syntax and behavior are documented at https://github.com/ldayton/Dippy/wiki. If you encounter syntax you're unsure about, fetch the relevant wiki page to verify before making changes. Key pages: Configuration, MCP Tools, File Editing, Afterthoughts, Handler Model, Security Model.
Entry Point
When invoked without specific instructions, ask what the user wants to do:
- Permit a new command or MCP tool? (Dippy config)
- Permit a new Read/WebFetch/Skill entry? (settings.json)
- Review current permissions?
- Troubleshoot a permission error?
- Clean up stale permissions?
- Something else?
Architecture Overview
Permissions are split across two systems:
| Permission type | Managed by | Config files |
|---|---|---|
| Bash commands | Dippy | ~/.dippy/config (global), .dippy (project) |
| MCP tools | Dippy | ~/.dippy/config (global), .dippy (project) |
| File redirects | Dippy | ~/.dippy/config (global), .dippy (project) |
| Read, WebFetch, WebSearch, Skill | settings.json | ~/mackup/confs/claude/settings.json (global), .claude/settings.local.json (project) |
Dippy runs as a PreToolUse hook (configured in settings.json) for both Bash and mcp__.* matchers. A PostToolUse hook enables afterthoughts.
CRITICAL: Dippy is invoked through a wrapper, not directly. The settings.json hook command is
~/stuff/gitrepos/gh/vtmocanu/skills/claude-permissions/dippy-with-auto-fallback.sh, not baredippy. The wrapper changes howaskverdicts behave in auto mode (the permission mode that autonomous runs and spawned agents/teammates use). See Auto-mode fallback wrapper below. This is the single most surprising part of the setup: in auto mode a plainaskrule does not prompt the human.
Auto-mode fallback wrapper
The wrapper reads permission_mode from the hook payload and branches (this is the behavior in the default ACTIVE state; see Three states for the two marker-file states that pre-empt it):
Non-auto modes (
default,acceptEdits,plan, etc.): the payload is handed straight todippy. Normal behavior,askrules surface an interactive prompt to the user.automode: the wrapper runsdippy, inspects the decision, and forwards only:allow/denyverdicts (the whitelist + blocklist are always enforced), andaskverdicts whosepermissionDecisionReasoncontains the literal token[ASK].
Any other
ask(no[ASK]marker) is dropped (the wrapper emits nothing andexit 0s), which hands the decision to auto-mode's own classifier, that typically runs routine commands without prompting the human.
Consequence: in auto mode, only ask rules tagged with [ASK] in their message escalate to the user. Everything else dippy would have asked about is silently auto-handled. So the [ASK] prefix is a real, load-bearing convention, not decoration: use it on any operation you want a human prompt for even during autonomous/agent runs.
| Dippy verdict | Reason has [ASK]? |
Non-auto mode | Auto mode |
|---|---|---|---|
allow |
n/a | runs, no prompt | runs, no prompt |
deny |
n/a | blocked | blocked |
ask |
yes | user prompted | user prompted |
ask |
no | user prompted | dropped, auto-classifier decides (usually runs, no prompt) |
Worked example from the current config: ask git push "[ASK] Confirm push target" (has [ASK]) prompts the human even in auto mode, but ask git commit "Confirm commit" and ask git add "Confirm staging" (no [ASK]) are silently handled by the auto-classifier in auto mode, no prompt. When authoring a new ask rule, decide deliberately whether it needs [ASK]: write/destructive operations that must always reach a human should carry it.
Three states: ACTIVE / ALLOW-ONLY / DISABLED
Dippy has no enable/disable flag of its own (dippy --help, v0.2.7). The state lives in two marker files that the wrapper checks per invocation:
| State | Marker | What the wrapper forwards | Net effect |
|---|---|---|---|
| ACTIVE (default) | none | allow, deny, and ask (plain ask filtered in auto mode, per the table above) |
Full ruleset, full guardrails |
| ALLOW-ONLY | ~/.dippy/ALLOW_ONLY |
allow only |
Whitelist skips the host's auto classifier; everything else is decided by it |
| DISABLED | ~/.dippy/OFF |
nothing, for every event | Dippy bypassed end to end |
OFF wins if both markers exist. ~/.dippy is a symlink into the mackup repo, so the markers land at confs/.dippy/; both are gitignored on purpose (machine-local state, not synced config).
No restart is needed for any transition: Claude Code caches the hook command from settings.json at startup, but the script is re-read on every invocation, so a switch applies to the running session immediately. (Editing the hook entries in settings.json, by contrast, does require a restart.)
On this Mac, prefer mackup/scripts/dippy-toggle.sh (on PATH via ~/scripts) over touching the markers by hand. It reports state, keeps the two markers mutually exclusive, and warns if settings.json has stopped routing hooks through dippy-with-auto-fallback.sh or if the wrapper it points at is a stale clone with no ALLOW_ONLY branch (in either case the marker is a no-op):
dippy-toggle.sh # status (default)
dippy-toggle.sh on # ACTIVE - full dippy (clears both markers)
dippy-toggle.sh allow-only # ALLOW-ONLY - whitelist only
dippy-toggle.sh off # DISABLED - bypass dippy
dippy-toggle.sh toggle # flip on <-> off
dippy-toggle.sh cycle # rotate active -> allow-only -> off -> active
ALLOW-ONLY: whitelist-only, a noise filter
The wrapper runs dippy, forwards the verdict only when it is allow, and emits nothing otherwise:
[ -f "${HOME}/.dippy/ALLOW_ONLY" ] && forward only `allow`
A hook allow short-circuits the host's permission flow, so whitelisted commands never reach the auto classifier — that is the point of the state. Everything not on the whitelist falls through to whatever the host would have done: the classifier in auto mode, an ordinary prompt in the other modes.
deny rules and [ASK] escalations do NOT fire in this state. Verified against dippy 0.2.7 on 2026-08-04:
| Command | ACTIVE verdict | ALLOW-ONLY: what Claude sees |
|---|---|---|
cat /etc/hosts |
allow |
allow — classifier skipped |
kubectl get pods |
allow |
allow — classifier skipped |
helm install foo bar |
deny ("Use GitOps…") |
nothing — classifier decides |
brew install foo |
ask [ASK] |
nothing — classifier decides |
some-unknown-tool --wat |
ask |
nothing — classifier decides |
PostToolUse afterthoughts still fire in ALLOW-ONLY (they are guidance text, not a permission decision, and the event check runs before the filter).
DISABLED: a safety-off switch, not a noise filter
While ~/.dippy/OFF exists the wrapper emits nothing and exit 0s for every event, so:
- The
denyblocklist is not enforced —rm -rf,git push --force,deny-redirectsecret-path guards,deny-mcprules all stop firing. allowrules stop firing too, so in non-auto modes routine commands start prompting (more prompts, not fewer). In auto mode everything falls to auto-mode's own classifier, which usually runs commands without prompting.- Afterthoughts go silent too, since the kill-switch fires before the event check.
Prefer narrower alternatives when they fit:
| Goal | Better tool than the kill-switch |
|---|---|
| Keep the fast-path allows but stop all dippy prompts | dippy-toggle.sh allow-only (note: drops deny too) |
| Stop prompts in one repo | set default allow in that repo's .dippy (global deny rules still fire) |
| Stop prompts for one command | allow <cmd> rule in the project .dippy |
| Swap the whole ruleset for a run | DIPPY_CONFIG=/path/to/other-config claude |
Verify the current state:
# which state? (or just: dippy-toggle.sh status)
ls -la ~/.dippy/OFF ~/.dippy/ALLOW_ONLY 2>/dev/null
# end-to-end check — a denied command (empty = bypassed or allow-only; JSON deny = active)
printf '%s' '{"permission_mode":"default","tool_name":"Bash","tool_input":{"command":"helm install foo bar"}}' \
| ~/stuff/gitrepos/gh/vtmocanu/skills/claude-permissions/dippy-with-auto-fallback.sh
# end-to-end check — a whitelisted command (JSON allow = active or allow-only; empty = bypassed)
printf '%s' '{"permission_mode":"default","tool_name":"Bash","tool_input":{"command":"cat /etc/hosts"}}' \
| ~/stuff/gitrepos/gh/vtmocanu/skills/claude-permissions/dippy-with-auto-fallback.sh
Note: Native settings.json capabilities (for context, not to use): settings.json supports wildcards at any position since v2.1.0 (Jan 2026), has allow/ask/deny directives, per-project overrides via
settings.local.json, and MCP tool permissions. Dippy's unique advantages: guidance messages on ask/deny rules, last-match-wins ordering (vs deny>ask>allow fixed priority), plain text config with comments, and file redirect controls (deny-redirect).
Key Locations
| File | Scope | Path | Manages |
|---|---|---|---|
| Global Dippy config | All projects | ~/mackup/confs/.dippy/config (symlinked to ~/.dippy/config) |
Bash, MCP, redirects |
| Project Dippy config | Single project | .dippy in project root |
Bash, MCP overrides |
| Global settings.json | All projects | ~/mackup/confs/claude/settings.json |
Read, WebFetch, Skill, hooks |
| Project settings.local.json | Single project | .claude/settings.local.json |
Read, WebFetch overrides |
Dippy config precedence (highest to lowest):
$DIPPY_CONFIGenv var (if set).dippyin project root (searches upward like.git)~/.dippy/configglobal
Within a config file: Last match wins. Broad allows first, specific denies after.
Project .dippy merges with global ~/.dippy/config. Rules from both files are evaluated together with last match wins. A project rule can override a global rule for the same command pattern.
Dippy Config Format
A ready-to-adapt starter config ships with this skill as config.example — generic safe-defaults (read-only allows, write/destructive ask/deny, secret-write deny-redirect guards, the no-interpreter rule, and the auto-mode [ASK] convention). Copy it to ~/.dippy/config (or a project .dippy) and tailor it to your toolchain.
Directives Reference
| Directive | Syntax | Behavior |
|---|---|---|
allow |
allow <pattern> |
Auto-approve matching commands |
ask |
ask <pattern> "message" |
Prompt user for approval |
deny |
deny <pattern> "message" |
Block with reason message |
allow-redirect |
allow-redirect <path-pattern> |
Permit file writes to path |
deny-redirect |
deny-redirect <path-pattern> "message" |
Block file writes to path |
allow-mcp |
allow-mcp <tool-pattern> |
Auto-approve MCP tool |
ask-mcp |
ask-mcp <tool-pattern> "message" |
Prompt for MCP tool |
deny-mcp |
deny-mcp <tool-pattern> "message" |
Block MCP tool |
after |
after <pattern> "message" |
Post-execution feedback (needs PostToolUse hook) |
after-mcp |
after-mcp <pattern> "message" |
Post-execution MCP feedback |
alias |
alias <source> <target> |
Map wrapper scripts to canonical names |
set default |
set default allow / set default ask |
Default for unknown commands |
set log |
set log <path> |
Enable audit logging |
set log-full |
set log-full |
Include full command text in logs |
Pattern Matching
- Prefix match (default):
allow git statusmatchesgit status,git status -s, etc. - Exact match: Append
|anchor:allow git status|matches only the literal command - Wildcards:
*(anything incl. spaces),?(one char),[abc](char class) - Path patterns (redirects):
**recursive,*single directory level
GOTCHA: Patterns with glob characters (
*,?,[) lose implicit prefix matching. Dippy only adds a trailing*for prefix matching when the pattern has NO glob characters. If your pattern contains any glob char, it must match the entire command string viafnmatch. For example,ask tea issue* closematchestea issues closebut NOTtea issues close 42.v0.2.7 fix: Trailing
*now matches empty strings (bare commands). Soask tea issue* close *matches bothtea issues closeandtea issues close 42. Always add trailing*to glob patterns that should match with or without extra args.Corollary for
curlpatterns: Always use trailing*after the flag you're matching:ask curl * -X POST *catches bothcurl url -X POSTandcurl url -X POST -d '{}'. Without trailing*, the pattern only matches when POST is the last token.GOTCHA: pattern tokens align with command tokens, so a path rule needs a
-*slot for the flags. This is the single biggest source of rules that look right and never fire.ask rm /*.git/*matchesrm /repo/.git/configbut NOTrm -rf /repo/.git/config— the-rfoccupies a token the pattern has no slot for. Write both forms, or lead with-*:ask rm /*.git/* "deletes git internals" # rm <path> ask rm -* /*.git/* "deletes git internals" # rm -rf <path>Corollary — a path pattern token must be absolute. A token without a
/(*.git,*objects) is a plain glob that spans the rest of the command. A token with a/is matched against one argument, and a relative one can never match an absolute argument. Inside such a token*does cross slashes, so it matches at any depth. Verified against v0.2.7 on 2026-08-02 via an isolatedDIPPY_CONFIG, targetrm -rf /Users/me/repo/.git/objects:
Pattern Matches? Why ask rm -* /*.git/*yes absolute path token, *crosses/ask rm -* /*/.git/*yes same ask rm *.gityes no slash → plain glob, spans the command; only when .gitends itask rm *.git/*no has a slash → path token, but relative ask rm */.git/*no same ask rm /*.git/*no absolute, but no -*slot for-rfask rm **/.git/*no **is redirect-pattern syntax onlyRedirects are a different matcher and unaffected:
deny-redirect **/.env*works as documented.Test every rule you write, in isolation:
printf 'set default allow\nask rm <pattern> "HIT"\n' > /tmp/p.dippythen pipe a hook payload throughDIPPY_CONFIG=/tmp/p.dippy dippy --claude.set default denyis not valid syntax (onlyallow/ask) and silently yields an empty ruleset, which looks exactly like "DIPPY_CONFIGis ignored"; a nonexistent config path also falls back to the global file without warning.SECURITY: Never
allowinterpreter commands. Rules likeallow bash,allow python3,allow nodeare prefix matches that auto-approvebash -c '...',python3 -c '...',node -e '...', bypassing ALL inner command rules. Dippy does not trace into-c/-earguments (single-layer execution). Remove these and let them fall through toset default ask.
MCP Tool Naming
MCP tools follow: mcp__<server>__<action>. Examples:
allow-mcp mcp__grafana__*- all Grafana toolsdeny-mcp mcp__*__delete_*- all delete actions across serversallow-mcp mcp__context7__query-docs- specific tool
Comments
Full-line (# comment) and inline (allow git # comment) supported.
settings.json Format (Read/WebFetch/Skill only)
Permissions live in permissions.allow (array of strings):
{
"permissions": {
"allow": [
"Read(*)", // allow reading any file
"Read(~/**)", // path-specific
"WebSearch", // no pattern needed
"WebFetch",
"WebFetch(domain:github.com)", // domain-scoped
"Skill(docx)" // specific skill
]
}
}
| Tool | Pattern syntax | Example |
|---|---|---|
Read |
Read(glob) - file path glob |
Read(~/**) |
WebFetch |
WebFetch(domain:host) or just WebFetch |
WebFetch(domain:example.com) |
WebSearch |
WebSearch |
WebSearch |
Skill |
Skill(name) |
Skill(docx) |
Important: Do NOT add
Bash(...)ormcp__*entries to settings.json. Those are managed exclusively by Dippy.
Workflow: Permit a Bash Command
Critical rule: NEVER run the command, only update permissions.
- Analyze the command the user wants permitted
- Determine scope: global (
~/.dippy/config) or project (.dippy)?- Default to global if unclear
- Check for duplicates before adding:
- Read the target Dippy config
- Check if the command is already covered by a broader rule (e.g.,
allow gitalready coversgit status) - Check if a more specific rule already exists that would conflict
- If duplicate found, inform user and skip
- Decompose chained commands: pipes create separate commands, each needs its own rule
- Apply safety defaults (see Safety section below)
- Add the rule in the correct section of the config (organized by category)
- Show the user exactly what was added and where
- Ask if they want to test the newly permitted command
Duplicate-Checking Logic
Before adding any rule:
- Global covers it? If
allow gitis in global config, don't addallow git statusanywhere - Project already has it? Don't add a rule that already exists in the project
.dippy - Broader project rule? If project has
allow kubectl, don't addallow kubectl get - Would a project rule shadow a global deny? If global has
deny git push --force, addingallow git push --forceto project.dippywould override it. Warn the user.
Workflow: Permit an MCP Tool
- Identify the tool name:
mcp__<server>__<action> - Determine scope: global or project
- Check for duplicates (same logic as Bash commands, using
allow-mcp/deny-mcp) - Add the
allow-mcprule - Verify the
mcp__.*PreToolUse hook matcher exists in settings.json (required for Dippy to intercept MCP calls)
Workflow: Permit Read/WebFetch/Skill
These still use settings.json since Dippy doesn't handle them:
- Determine scope: global (
~/mackup/confs/claude/settings.json) or project (.claude/settings.local.json) - Read the target settings file
- Add the permission entry to
permissions.allow - Validate JSON:
jq . <settings-file> > /dev/null && echo "JSON valid" - Show the user what was added
Safety: Read-Only Defaults
By default, only permit read operations. For commands with write effects, ask the user to confirm.
Auto-permit (read-only, safe to allow)
| Category | Commands |
|---|---|
| Kubernetes | kubectl get/describe/logs/explain/events/top/diff/cluster-info/api-resources |
| Git | git status/log/diff/fetch/remote/branch (listing) |
| Helm | helm template/show/list/get/search/history/pull |
| Flux | flux get/check/diff/logs/build |
| Docker | docker ps/images/logs/inspect/info |
| Infra | tofu plan/show/state (via infisical wrapper) |
| CLI tools | ls, cat, jq, grep, rg, fd, tree, eza, curl -s -X GET |
Never auto-permit interpreters:
bash,python3,node,sh,ruby,perl. Prefix-matching these auto-approves-c/-eflags, bypassing all inner command rules. Let them fall through toset default ask.
Require confirmation (write/destructive, use deny or ask)
kubectl apply/delete/edit/patch/rollout restart/scale/draingit push/commit/rebase/reset --hard/clean -fhelm install/upgrade/uninstall/rollback(for GitOps workflows, preferdenywith guidance:deny helm install "Use GitOps: create/edit the HelmRelease in your GitOps repo instead")flux suspend/resume/deletedocker rm/rmi/system prunetofu apply/destroyrm -rf,rm -r,sudo,chowncurlwith POST/PUT/PATCH/DELETE methods,-d/--data,-F/--form,-T/--upload-file- Important: Use
*wildcard to catch write flags regardless of position, with trailing*to match extra args:ask curl * -X POST *(notask curl -X POST). The URL often comes before the method flag, and additional flags may follow.
When a command has write effects, present options:
"This command can modify state. Recommended:
- Add as
askrule (prompts before execution, safer)- Add as
allowrule (auto-approves, needs your confirmation)- Add as
denywith guidance message (blocks with reason)"
Workflow: Review Permissions
# Show global Dippy config
cat ~/.dippy/config
# Show project Dippy config (if exists)
cat .dippy 2>/dev/null || echo "No project .dippy"
# Show settings.json permissions (Read/WebFetch/Skill)
jq '.permissions.allow' ~/mackup/confs/claude/settings.json
# Show project settings.local.json
jq '.permissions.allow' .claude/settings.local.json 2>/dev/null || echo "No project settings"
# List all project .dippy files across repos
fd -H -t f '^\\.dippy$' ~/stuff/gitrepos/wxs/
Workflow: Clean Up Stale Permissions
- Read the Dippy config (global and/or project)
- Identify rules for tools/commands no longer used
- Ask user before removing any entries
- Remove confirmed entries
- For settings.json changes, validate JSON after editing
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Bash command prompts unexpectedly | Missing allow rule in Dippy config |
Add rule to ~/.dippy/config or .dippy |
| Command denied with message | Matched a deny rule |
Check rule ordering (last match wins) |
| MCP tool prompts unexpectedly | Missing allow-mcp rule |
Add allow-mcp rule to Dippy config |
| MCP rules ignored entirely | Missing mcp__.* hook matcher |
Add PreToolUse matcher in settings.json |
| Permission added but still denied | Rule ordering: a later deny overrides your allow |
Move your allow after the deny, or make the deny more specific |
| Works in one project, not another | Project .dippy rule may override a global rule (last match wins) |
Check both global and project configs for conflicting rules |
| Read/WebFetch denied | Missing entry in settings.json (not Dippy) | Add to permissions.allow in settings.json |
| JSON parse error after settings edit | Malformed JSON | Run jq . to find syntax errors |
| Afterthought not firing | Missing PostToolUse hook | Add PostToolUse Bash matcher in settings.json |
No rules fire at all: deny ignored, guidance messages gone |
Kill-switch left on — ~/.dippy/OFF exists, wrapper exits before running dippy |
dippy-toggle.sh on (or rm ~/.dippy/OFF; see Three states); takes effect immediately, no restart |
allow rules fire but deny and [ASK] do not, and afterthoughts still work |
ALLOW-ONLY state left on — ~/.dippy/ALLOW_ONLY exists, wrapper forwards only allow |
dippy-toggle.sh on restores full guardrails; dippy-toggle.sh status reports the state |
dippy-toggle.sh allow-only reports ALLOW-ONLY but deny rules still block |
settings.json points at a wrapper clone predating the ALLOW_ONLY branch (added 2026-08-04) |
Update your clone of dippy-with-auto-fallback.sh; dippy-toggle.sh status warns about this automatically |
| Afterthought not firing in auto mode only | Wrapper predating the hook_event_name check: its auto-mode filter forwards only allow/deny/[ASK]-tagged ask, and dippy emits afterthoughts as plain text with no permissionDecision, so they were dropped. Fixed 2026-07-26 — the wrapper now pipes any non-PreToolUse event straight to dippy, so pointing PostToolUse at either the wrapper or bare dippy works |
Update your clone of dippy-with-auto-fallback.sh |
| Rule with glob chars doesn't match commands with extra args | Patterns with */?/[ lose implicit trailing * prefix matching; must match entire command |
Add explicit trailing * to your glob pattern (v0.2.7+ matches bare commands too) |
bash -c / python3 -c bypasses rules |
allow bash prefix-matches all bash -c '...' commands; Dippy doesn't trace into -c args |
Remove allow bash/allow python3/allow node; let them fall to set default ask |
Hook Configuration Reference
The following hooks must exist in ~/mackup/confs/claude/settings.json for Dippy to function. The PreToolUse Bash + mcp__.* matchers invoke the auto-fallback wrapper (dippy-with-auto-fallback.sh, bundled with this skill), not bare dippy, so that auto-mode ask handling works as described in Auto-mode fallback wrapper. The PostToolUse hook (afterthoughts) calls dippy directly.
Hook command paths must be absolute. Claude Code does NOT expand
~in hookcommandstrings — the~/...paths shown below are for readability; in your actualsettings.jsonuse the fully expanded path (e.g./home/you/stuff/gitrepos/gh/vtmocanu/skills/claude-permissions/dippy-with-auto-fallback.sh). Point the hook at your local clone of this repo, not at the installed~/.claude/skills/claude-permissions/copy (that directory is overwritten on everynpx skills update).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/stuff/gitrepos/gh/vtmocanu/skills/claude-permissions/dippy-with-auto-fallback.sh" }
]
},
{
"matcher": "mcp__.*",
"hooks": [
{ "type": "command", "command": "~/stuff/gitrepos/gh/vtmocanu/skills/claude-permissions/dippy-with-auto-fallback.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "dippy" }
]
}
]
}
}
The wrapper ships with this skill as ./dippy-with-auto-fallback.sh. Read that file directly when you need its exact logic — it is the source of truth for the auto-mode behavior summarized in Auto-mode fallback wrapper above. To use it, clone this repo locally and point both PreToolUse hook commands at your clone's copy (absolute path, per the note above).
Note: The
mcp__.*matcher uses regex (not glob). Other PreToolUse hooks (zellaude, clawd, dot-agent-deck) run alongside the wrapper but are unrelated to permissions.