Skip to content
prod e051e98
Browse

4 · Claude config — settings, modes, hooks

Objective — write the project-wide settings.json, pick a permission mode (strict / medium / yolo), set personal model/thinking/compact defaults in gitignored settings.local.json (never ~/.claude/settings.json), and wire the hard-block + vendor-edit guard hooks that no mode can override.

This step controls how much friction the agent operates with and installs the safety net that catches destructive commands regardless of mode. The split between what’s committed and what stays personal matters — get it wrong and you either leak a teammate’s friction choice or commit nothing at all.

FileCommitted?Purpose
.claude/settings.jsonC4Project-wide settings everyone shares — e.g. disabledMcpjsonServers for project .mcp.json servers
.claude/settings.local.json❌ gitignoredPersonal — permission mode plus per-project overrides for model, autoCompactEnabled, and env.MAX_THINKING_TOKENS (overrides global for this repo only)
.claude/.gitignore✅ committedIgnores settings.local.json and *.bak.*

1. Write .claude/settings.json — trim project MCP noise

Section titled “1. Write .claude/settings.json — trim project MCP noise”

Two MCP scopes — don’t conflate them:

ScopeWhereHow to trim
User / machineRegistered in machine setup §3 (claude mcp add -s user)/mcp toggles or user-level config — not this file
ProjectCommitted .mcp.json (e.g. Laravel Boost)disabledMcpjsonServers in .claude/settings.jsonClaude Code docs

Only disable servers you do not want auto-approved from the project .mcp.json. User-scope servers (ZajLibrary, Cloudflare, Stripe, …) are trimmed on the machine, not duplicated here.

  1. Read exact server names for project .mcp.json entries. If Cursor & other IDEs or kit seed.sh already created .mcp.json, run /mcp and use names exactly as shown. Hand path before Cursor wiring: use an empty array and revisit after Boost is registered.

    • /mcp lists each project-scoped server you registered in .mcp.json.
  2. Write .claude/settings.json — disable only project .mcp.json servers this repo does not need.

    {
    "disabledMcpjsonServers": [
    "server-key-from-mcp-json-if-unused"
    ]
    }
    • ✅ Valid JSON. For a typical CodeCanyon Laravel project you enable Laravel Boost from .mcp.json and leave the array empty or minimal. User-scope MCPs from machine setup are out of scope for this key.
  3. Verify on disk — commit at end of this page (C4).

    Terminal window
    test -f .claude/settings.json && python3 -m json.tool .claude/settings.json >/dev/null && git add -n .claude/settings.json
    # Expected: JSON validates; dry-run lists the file — commit in §7 below
    • .claude/settings.json is in place (written now; committed at C4).

Keep the personal permission mode and timestamped backups out of git so each developer’s friction choice stays local.

.gitignore
settings.local.json
*.bak.*
# Expected: settings.local.json and rotated backups stay untracked
  • ✅ Once git exists (Create the project bootstrap), git status no longer surfaces settings.local.json or *.bak.* files.

3. Pick a permission mode — strict / medium / yolo

Section titled “3. Pick a permission mode — strict / medium / yolo”

settings.local.json carries your permission mode. The kit ships three presets and a set-claude-mode.sh switcher that installs one — with a timestamped backup and an allow-list merge so your accumulated approvals survive the switch.

ModedefaultModePostureUse when
strictplanRead-only. No Bash/Edit/Write. Proposes diffs.Security/deploy work, code review, unfamiliar code
mediumacceptEditsAuto-allows edits + safe git/composer/npm/artisan. Asks for unknown Bash.Default for active dev
yolobypassPermissionsAuto-allows almost everything — but a PreToolUse hook hard-blocks destructive commands.Local throwaway / heavy refactors. Never on prod branches or with real creds.
  1. Apply a mode from the project root.

    Terminal window
    ./.claude/claude-mode/bin/set-claude-mode.sh medium # strict | medium | yolo | show
    # Expected: writes settings.local.json with a timestamped backup + allow-list merge
    • ✅ The switcher reports the mode applied and leaves a *.bak.* backup.
  2. Quit and restart the agent (👤 user) — settings.local.json is read at session start, not hot-reloaded.

    • ✅ The agent reloads with the new mode active.
  3. Verify in the new session.

    Terminal window
    ./.claude/claude-mode/bin/set-claude-mode.sh show
    # Expected: prints the active mode (e.g. "medium")
    • ✅ A borderline command behaves correctly — in medium, git status auto-approves, git push --force is denied, rm /tmp/x asks.

4. Set personal model + token defaults — merge into settings.local.json

Section titled “4. Set personal model + token defaults — merge into settings.local.json”

Do not write these to ~/.claude/settings.json. Machine-wide MCP and gh auth belong on machine setup; model, thinking budget, and auto-compact are project/personal choices that belong here — gitignored, per developer, and scoped to this repo via Claude Code precedence: enterprise → CLI → .claude/settings.local.json.claude/settings.json → global.

Run after §3 so the switcher has already created .claude/settings.local.json (permissions + mode). Then deep-merge the productivity keys only — same pattern as the old global merge, but local so your global hooks, plugins, and default model stay untouched.

  1. Ensure jq is present.

    Terminal window
    command -v jq &>/dev/null && echo "jq present" || brew install jq
    # Expected: "jq present"
    • command -v jq succeeds.
  2. Deep-merge model + token keys into the existing local file (never replace the whole file — that drops permissions from §3).

    Terminal window
    LOCAL=".claude/settings.local.json"
    test -f "$LOCAL" || { echo "run set-claude-mode.sh first (§3)"; exit 1; }
    jq '.model = "opus"
    | .autoCompactEnabled = true
    | .env = ((.env // {}) + {"MAX_THINKING_TOKENS": "12000"})' \
    "$LOCAL" > "${LOCAL}.tmp" && mv "${LOCAL}.tmp" "$LOCAL"
    # Expected: permissions block from §3 intact; model + compact + env merged
    • jq '.model, .autoCompactEnabled, .env.MAX_THINKING_TOKENS, .defaultMode' "$LOCAL" prints your model, true, "12000", and the mode from §3.
    • ✅ Swap "opus" for "sonnet" (or another supported id) if you prefer cost over depth — this file is gitignored and never imposed on teammates.
  3. Restart the agent again so model + env load with the permission mode from §3.

    • ✅ A fresh session uses the merged local file; global ~/.claude/settings.json was not modified.
  • model — e.g. Opus for agentic Laravel work; overrides global for this project only.
  • MAX_THINKING_TOKENS — caps extended-thinking spend (merged into existing env, not replacing other vars).
  • autoCompactEnabled — compacts context before overflow.

See also the kit’s .claude/settings.local.json.example (AI System kit).

The friction should match the risk of the work. A sensible default mapping across the playbook:

WorkRecommended modeWhy
Setup, active dev, app config (Phases 1–3, 6, 8–9)mediumEdits flow; unknown Bash still pauses
Heavy local refactors on a throwaway branchyoloSpeed; the hard-block hook still guards. Never with real creds or on a prod branch
Deploy, staging, security, audit, pre-customer, production (Phases 4–5, 7, 10–11, P)strictRead-only by default; the agent proposes diffs you approve

To change posture for the current session only (without editing settings.local.json or restarting), use the in-session /permission-mode command. It’s the right tool for a one-off — e.g. dropping to plan to review a risky diff, then back. The set-claude-mode.sh switcher is for the persistent default that survives restarts.

{
"_mode": "medium",
"defaultMode": "acceptEdits",
"permissions": {
"allow": ["Read","Grep","Glob","Edit","Write",
"Bash(git status*)","Bash(git add *)","Bash(git commit *)",
"Bash(composer install*)","Bash(npm run *)","Bash(php artisan *)",
"Bash(vendor/bin/pest*)","Bash(vendor/bin/pint*)"],
"deny": ["Bash(rm -rf /)","Bash(sudo *)","Bash(git push --force*)",
"Bash(php artisan migrate:fresh*)","Bash(php artisan db:wipe*)",
"Write(.env*)","Edit(.env*)"],
"ask": ["Bash(rm *)","Bash(git push*)","Bash(ssh *)","Bash(mysql *)",
"Bash(php artisan migrate:rollback*)","Bash(php artisan db:seed*)"]
}
}

The full strict / medium / yolo presets ship in the kit’s claude-mode/resources/templates/.

The 1Password path in Project constitution §5 only works if the agent can read safe templates and run safe op discovery/render commands. .env.example and .env.tpl are committed placeholders/references, not secrets; the real secret values live in .env or inside 1Password.

Use this default posture in the installed AI-System settings:

ActionDefaultWhy
Read(./.env.example)AllowNeeded to enumerate intended env keys; contains placeholders only
Read(./.env.tpl) / Read(./.env.*.tpl)AllowContains op:// references only, no values
Bash(op vault ls*) / Bash(op item ls*)AllowDiscovers vault/item names without printing field values
Bash(op inject*)AllowRenders .env from references; command output should not print values
Edit(.env*) / Write(.env*)Deny or askReal env files contain secrets; writing them should stay controlled
Bash(op item get * --format=json*)Ask or denyCan print full item contents, including secrets

Ship this project allow block in .claude/settings.local.json (merge into your chosen mode preset) so Project constitution §5 Option A can run unattended after the global posture is fixed:

{
"permissions": {
"allow": [
"Read(./.env.example)",
"Read(./.env.tpl)",
"Read(./.env.*.tpl)",
"Bash(op vault ls*)",
"Bash(op item ls*)",
"Bash(op inject*)"
],
"deny": [
"Write(.env*)",
"Edit(.env*)",
"Bash(op item get * --format=json*)"
]
}
}

Human/installer global fix (pick one, then restart Claude Code):

VariantChange in ~/.claude/settings.jsonpermissions.deny
Dev-friendly (owner default)Remove Read(./.env) and Read(./.env.*); keep write blocks + the hook
ConservativeReplace Read(./.env.*) with explicit real-env paths only (.env, .env.local, .env.production, .env.staging, .env.*.local) so .env.example / .env.tpl stay readable

5. Wire the hard-block hook — block-destructive.sh

Section titled “5. Wire the hard-block hook — block-destructive.sh”

The reason yolo is safe: a PreToolUse hook runs above the permission system and blocks destructive commands even under bypassPermissions. It’s wired into the yolo preset.

  1. Wire it into the yolo preset so it fires on every Bash call.

    {
    "defaultMode": "bypassPermissions",
    "hooks": {
    "PreToolUse": [{
    "matcher": "Bash",
    "hooks": [{ "type": "command", "command": ".claude/claude-mode/hooks/block-destructive.sh" }]
    }]
    }
    }
    # Expected: the hook runs on every Bash tool call, above the permission system
    • ✅ The hook reads the command from stdin and exits 2 (hard block) on patterns like rm -rf /, sudo, git push --force, chmod 777, curl | bash, migrate:fresh|wipe|reset, DROP TABLE, ::truncate(, and deletions of database/migrations or owned packages. Exit 2 is the highest-precedence signal — it can’t be overridden from inside a running session.
  2. Test the hook standalone (no agent needed).

    Terminal window
    printf '%s\n' '{"tool_name":"Bash","tool_input":{"command":"php artisan migrate:fresh"}}' \
    | ./.claude/claude-mode/hooks/block-destructive.sh
    echo "exit=$?" # → exit=2
    printf '%s\n' '{"tool_name":"Bash","tool_input":{"command":"git status"}}' \
    | ./.claude/claude-mode/hooks/block-destructive.sh
    echo "exit=$?" # → exit=0
    # Expected: the hook parses the modern tool_name/tool_input payload,
    # blocks a migration-reset command, and allows a safe read-only command.

    The payload is only JSON passed to the hook. It does not run php artisan migrate:fresh; the hook reads the command string and exits before any shell execution.

    • ✅ The destructive test payload reports exit=2 (hard block); git status reports exit=0 (auto-approve).
  1. PreToolUse hook exit 2 → hard block (overrides everything, even bypass mode)
  2. permissions.deny → block
  3. PreToolUse hook exit 1 → force prompt
  4. permissions.ask → prompt
  5. permissions.allow → auto-approve
  6. PreToolUse hook exit 0 → auto-approve
  7. defaultMode behavior
Section titled “6. Add the vendor-edit guard hook (optional but recommended)”

CodeCanyon work lives or dies on not silently editing vendor code. A lightweight PostToolUse hook reminds you to wrap in-place vendor edits in ZAJ:BEGIN/END markers and log them in _CUSTOMIZATIONS.md.

The hook fires whenever a file under vendor/, app/ core, or a vendor view is touched. The kit ships hooks/vendor-edit-reminder.sh and the wiring; it’s advisory (exit 0 with a stderr note), not a block.

  • ✅ Editing a vendor/ file triggers the reminder note without blocking the edit.

Secret-handling reference (apply across every phase)

Section titled “Secret-handling reference (apply across every phase)”

This block is the single source of truth for how secrets are handled in any AI-assisted workflow (Claude Code, Cursor, Copilot CLI — any agent that reads terminal output). It’s seeded as feedback_secret_handling.md in Rules & skills so the agent auto-loads it; the canonical copy lives here.

The 5 rules:

  1. Exit-code verification, not value verification. To check “does this secret exist?”, use exit code 0/1 — never print the value.

    Terminal window
    # ✅ GOOD — exit code only, length-only feedback
    [ -n "$TOKEN_VAR" ] && echo "✅ present (${#TOKEN_VAR} chars, value hidden)" || echo "❌ missing"
    some-getter >/dev/null 2>&1 && echo "✅ configured" || echo "❌ missing"
    # ❌ BAD — leaks the secret into the conversation log
    echo "$TOKEN_VAR"
    echo "$TOKEN_VAR" | head -c 15
    composer config --global github-oauth.github.com # a GETTER — prints the token
    cat ~/.config/composer/auth.json
  2. Length is safe to print; any substring is not. ${#VAR} prints the character count only. Even 4 characters of a real token (ghp_, gho_, sk_live_) identifies the provider and type, and ~15 characters is enough to find it in logs. Never print any slice of a secret.

  3. Redirect stdout when setting secrets. Many CLIs echo back the value they just stored.

    Terminal window
    # ✅ GOOD — stdout + stderr silenced
    composer config --global github-oauth.github.com "$(gh auth token)" >/dev/null 2>&1
    # ❌ BAD — composer prints the token back after setting it
    composer config --global github-oauth.github.com "$(gh auth token)"
  4. Command substitution and exported vars are safe; echoing them is not. $(gh auth token) and $CF_API_TOKEN expand at execution and are passed as an argument / env var — not printed; shell history stores the literal $(gh auth token), not the value. The danger is only a downstream command that round-trips the value back: echo, printf, cat, a curl without -o /dev/null, a getter CLI. Treat the expansion as safe; treat any tool that might print the value as hostile.

  5. Never ask the user to “paste the token to verify it.” Asking for a secret in chat is the leak. Run the exit-code check and report the length. If the user offers to paste, refuse and point them at the length check.

Patterns by secret type:

SecretWhere it lives✅ Safe verification❌ Dangerous commands
GitHub token~/.config/composer/auth.jsoncomposer config --global github-oauth.github.com >/dev/null 2>&1 && echo "✅"composer config --global github-oauth.github.com (prints it)
Cloudflare API token~/.zshrc env var[ -n "$CF_API_TOKEN" ] && echo "✅ (${#CF_API_TOKEN} chars)"echo $CF_API_TOKEN, echo $CF_API_TOKEN | head -c N
Database password.env DB_PASSWORD=…grep -q '^DB_PASSWORD=' .env && echo "✅ set"grep DB_PASSWORD .env, cat .env
Stripe secret key.env STRIPE_SECRET=…grep -q '^STRIPE_SECRET=sk_' .env && echo "✅ looks like a key"grep STRIPE .env, cat .env
SMTP password.env MAIL_PASSWORD=…grep -q '^MAIL_PASSWORD=.\+' .env && echo "✅ set"grep MAIL .env
Webhook signing secret.env, provider dashboardgrep -q '^STRIPE_WEBHOOK_SECRET=whsec_' .env && echo "✅"grep WEBHOOK .env
Redis / Upstash token.env REDIS_PASSWORD=…grep -q '^REDIS_PASSWORD=.\+' .env && echo "✅ set"grep REDIS .env
1Password-stored secret1Password vaultop item get "<item>" --fields label=<field> >/dev/null && echo "✅"op item get "<item>" --format=json (prints everything)
  1. Stage and commit C4 — settings + committed .gitignore; add vendor-edit hook paths if wired outside claude-mode/.

    Terminal window
    git branch --show-current # must be develop
    git add .claude/settings.json .claude/.gitignore
    # If vendor-edit guard lives outside claude-mode/: git add .claude/hooks/
    git commit -m "feat(ai): claude settings + destructive/vendor-edit guards"
    git log --oneline -4 # C4 atop C3, C2, C1
    # Expected: C4 on develop; settings.local.json not staged
    • git check-ignore .claude/settings.local.json still succeeds; C4 appears in git log.

Do not mark this step done until every box below is checked.

  • 🔀 settings.json on disk — trimmed project MCP list using disabledMcpjsonServers where needed; C4 committed.
  • 🤖 .claude/.gitignore in place — ignores settings.local.json and *.bak.*; git status no longer surfaces them.
  • 🔀 Personal defaults merged.claude/settings.local.json has your chosen model, autoCompactEnabled, and env.MAX_THINKING_TOKENS via jq merge; global ~/.claude/settings.json untouched; permissions from §3 intact.
  • 🔀 Permission mode applied + verifiedset-claude-mode.sh show reports the mode in a new session; a borderline command behaves correctly.
  • 🤖 Block-destructive hook testedphp artisan migrate:fresh exits 2, git status exits 0 via the standalone JSON-payload test.
  • 🤖 Vendor-edit guard wired (optional) — touching a vendor/ file triggers the advisory reminder.
  • 🤖 C4 committed on develop.claude/settings.json + .claude/.gitignore; settings.local.json gitignored.