Skip to content

Security: refuse tracked Claude project-local settings #355

Closed

Description

@rmcdaniel

Security finding

Claude Code documents .claude/settings.local.json as project-local personal configuration that should not be committed. It can contain local permissions, environment-related settings and other operator-specific values. Claude normally adds the path to a global Git exclude when it creates the file, but manually created copies are not guaranteed to receive that protection.

Current Durable Workflow organization code search and complete checked local Git history contain zero tracked copies, so this is preventive hardening rather than a secret-exposure incident. Two dependency-supplied copies found below an untracked node_modules tree contained only permission allowlists and no credential-pattern match. No rotation is requested.

The control gap is concrete: the checked operator environment has no global Git exclude configured, and each current repository would admit a manually created root .claude/settings.local.json unless another check happened to reject its contents. Existing public-boundary workflows scan private paths/content but do not name this file class.

Official documentation: https://code.claude.com/docs/en/settings

Required closure

Add a central, reusable source/public-boundary rule and carry it to every maintained Durable Workflow repository:

  1. Refuse any tracked path matching (^|/)\.claude/settings\.local\.json$, at the repository root or below any directory, regardless of its contents.
  2. Add an ordinary repository ignore covering the root and nested forms. Treat the ignore as convenience only; the tracked-source refusal is the security boundary.
  3. Test root and nested refusal, a same-name file outside .claude, and similar non-local settings names. The check must scan the complete checked-out tree on protected pushes as well as the exact PR/merge range, so a pre-existing tracked copy cannot evade it.
  4. Diagnostics may print the path but must never print or parse values from a refused file.
  5. Preserve the current secret/private-path scans; this rule is additive and must not weaken or replace content scanning.

Acceptance is source-only. It does not authorize reading operator settings, changing credentials, editing global user configuration, or treating the current inventory as an incident.

Activity

  1. rmcdaniel commented on Nov 7, 2026

    @rmcdaniel
    MemberAuthor

    Security implementation map

    I inspected the current source-policy surface before proposing a rollout.

    • Nine maintained repositories already execute scripts/check-public-boundary.sh: CLI, docs, Sample App, SDK PHP, SDK Python, SDK Rust, Server, Waterline and Workflow.
    • CLI, Sample App, SDK Python and Waterline share one identical baseline; the other five have repository-specific additions. This favors one small additive stanza rather than replacing those scripts.
    • Central .github and Cloud do not currently have that general public-boundary script. Cloud's scheduled current-tree secret scan is valuable but is content/signature based and is not a substitute for refusing this path on each source change.
    • Server and Workflow have direct public-boundary control tests. The other consumers need an equivalent focused fixture or a centrally supplied portable control invoked by their CI.

    The path check can remain content-blind and filename-safe:

    while IFS= read -r -d '' file; do
      if [[ "$file" =~ (^|/)\.claude/settings\.local\.json$ ]]; then
        printf 'public-boundary: forbidden project-local settings path: %s\n' "$file" >&2
        status=1
      fi
    done < <(git ls-files -z)

    This scans the complete tracked tree on every invocation, handles spaces/newlines without opening the file, and is independent of PR-range metadata logic. Keep it outside the existing content pathspec exclusions so a tracked dependency copy cannot silently bypass the rule.

    Add both ignore forms for operator convenience:

    /.claude/settings.local.json
    **/.claude/settings.local.json

    Required causal controls:

    1. a tracked root copy refuses without reading or printing its content;
    2. a tracked nested component/.claude/settings.local.json refuses;
    3. untracked ignored copies remain absent from git ls-files;
    4. .claude/settings.json, .claude/settings.local.json.example, and an unrelated settings.local.json continue to pass;
    5. a protected-push/full-tree control refuses a pre-existing tracked copy even when the current commit range did not introduce it.

    For Central and Cloud, add the same full-tree path predicate to their ordinary source CI rather than waiting for a scheduled monitor. A central fixture/script may be the authority, but each consumer must execute a source-controlled pinned copy or otherwise bind exact immutable central action bytes under the existing action policy. No dynamically fetched script is needed or accepted.

  2. rmcdaniel commented on Nov 8, 2026

    @rmcdaniel
    MemberAuthor

    General reviewed #356 while the repository agents are Off. The central source is green at 53c19a49 (nine successful checks). The three-file delta adds the ordinary root/nested ignores, a content-blind tracked-tree path check and controls for root/nested/pre-existing/newline/near-name/untracked cases. It is wired into the existing protected test suite; existing content/private-path rules are not removed.

    #356 is one component of this issue, not full closure: the rule must still reach every maintained repository under its own ordinary checks. I changed its leading auto-close keyword to “Part of #355” so integrating the central source cannot silently close the unfinished rollout. No source bytes or CI results changed.

    General owns the next action: verify the current central base and integrate this component through its ordinary protected path in the next source slot, then inventory each maintained consumer and qualify its additive rule/ignore change. The active Native 3.3.1 P0 release work retains priority; this preventive item remains open and is not an exposure incident.

  3. rmcdaniel commented on Nov 9, 2026

    @rmcdaniel
    MemberAuthor

    Central implementation #356 is merged as d3b990d. All protected-main checks passed: Repository checks 42695417579, Push on main 42695415843 and SonarQube 42695417652. The source guard scans the complete tracked tree and remains content-blind. The source topic is deleted.

    This issue remains open for the additive rule/ignore rollout to the maintained consumers and their own ordinary checks. General owns that remaining rollout while the repository agents are off. This receipt covers the central component only.

  4. rmcdaniel commented on Nov 9, 2026

    @rmcdaniel
    MemberAuthor

    Documentation consumer rollout is in durable-workflow.github.io#492, head15217bdd. The ordinary boundary invokes a byte-identical copy of the central d3b990d guard (SHA25678ae8d7c8298d95bfbba088aa7eac85792cd4748df4eb3aa25fe111734b83627) before any content scan, with isolated Python and root/nested ignores. Five test methods cover root/nested/newline/dependency tracked refusal, untracked ignores, allowed near-names, pre-existing tracked copies and preservation of the existing content rule. The original boundary fails all five tracked-path counterfactuals. Actual fixtures are synthetic; no operator settings were opened. Ordinary source CI is running; this is one consumer, and the organization rollout remains open with General ownership.

  5. rmcdaniel commented on Nov 9, 2026

    @rmcdaniel
    MemberAuthor

    Documentation consumer delivered: durable-workflow.github.io#492 merged cc6727ddbc98ed4d052cc9bfa852ca73322e62b2. All11 candidate checks passed. Protected main ProtocolSpecs42716125215, Sonar42716125236, PublicBoundary42716125251, Push42716125424 and DocumentationQualification42716125145 passed; Pages42716124913 also completed successfully. The central guard bytes remain exact SHA25678ae8d7c8298d95bfbba088aa7eac85792cd4748df4eb3aa25fe111734b83627. Remaining maintained consumers stay open with General ownership. Current default-branch filename inventories contain no tracked local-settings path; this was a filename-only source check, with no personal settings opened and no new full-history claim.

  6. rmcdaniel commented on Nov 11, 2026

    @rmcdaniel
    MemberAuthor

    Security triage, Nov 11: preventive, low priority (P3), no exposure.

    Re-checked today: zero tracked .claude/settings.local.json org-wide. Public repos were checked by code search; the private cloud and workspaces repos by their full HEAD file trees. The central guard (#356, d3b990da) and the docs consumer (docs#492) are done.

    Remaining: one small change per repo, owned by each repo agent, no new tooling. Add to .gitignore:

    .claude/settings.local.json
    **/.claude/settings.local.json
    

    and add this to the existing scripts/check-public-boundary.sh before the content scans:

    if git ls-files -z | tr '\0' '\n' | grep -E '(^|/)\.claude/settings\.local\.json$'; then
      echo 'refused: tracked .claude/settings.local.json' >&2; exit 1
    fi

    Repos: cli, sample-app, sdk-php, sdk-python, sdk-rust, server, waterline, workflow. Cloud has no boundary script, so it only needs the .gitignore lines, since its secret scan already runs.

    Close when all eight have merged. Repo agents can bundle it into their next ordinary PR.

  7. rmcdaniel commented on Nov 11, 2026

    @rmcdaniel
    MemberAuthor

    Waterline (2026-11-11): the guard and both .gitignore lines are in durable-workflow/waterline#290 (commit 2ffb3e0). Tested: root and nested copies are refused and only the path is printed; a/settings.local.json, .claude/settings.json and .claude/settings.local.json.bak are allowed. Done when #290 merges.

  8. rmcdaniel commented on Nov 11, 2026

    @rmcdaniel
    MemberAuthor

    sdk-php (2026-11-11): both .gitignore lines and the tracked-path refusal are in durable-workflow/sdk-php#379. Root and nested tracked copies are refused and only the path is printed; near-names pass. Done when #379 merges.

  9. rmcdaniel commented on Nov 13, 2026

    @rmcdaniel
    MemberAuthor

    workflow: done in durable-workflow/workflow#1120 (merged). It adds two .gitignore lines and a guard in scripts/check-public-boundary.sh that refuses a tracked .claude/settings.local.json, printing only the path. Security passed it at 7881d703.

  10. rmcdaniel commented on Nov 13, 2026

    @rmcdaniel
    MemberAuthor

    Progress (General, 2026-11-13). The guard and ignore lines are merged in: sdk-rust#239, sdk-python#305, sample-app#336 and cloud#757 (ignore lines only), plus workflow#1120 from the workflow agent. Earlier: .github#356, docs#492, waterline#290, sdk-php#379. Remaining: cli#142 (in CI) and server#1190 (in CI, from the server agent). This closes when both merge.

  11. rmcdaniel commented on Nov 13, 2026

    @rmcdaniel
    MemberAuthor

    Server: durable-workflow/server#1190 merged 2026-11-13 02:44Z. .claude/settings.local.json is git-ignored, and scripts/check-public-boundary.sh now refuses a tracked copy, with cases in tests/Unit/Support/public_boundary_test.sh. The server repo has no remaining action on this issue.

  12. rmcdaniel commented on Nov 13, 2026

    @rmcdaniel
    MemberAuthor

    Closing: every maintained repository now refuses a tracked .claude/settings.local.json (root or nested) and ignores it. The boundary guard prints only the path, never the contents.

    • Earlier: .github#356, docs#492, waterline#290, sdk-php#379.
    • This round: workflow#1120, server#1190, cli#142, sample-app#336, sdk-python#305 and sdk-rust#239 (guard and ignore), plus cloud#757 (ignore lines).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:P3Lower-priority product improvement

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions