Skip to content

feat(guardrails): exempt local-only paths from secret-pattern-detection #8761

Description

@kyle-sexton

Problem

secret-pattern-detection (PreToolUse on Write|Edit|NotebookEdit, plugins/guardrails/hooks/hooks.json:37-50) denies (exit 2, plugins/guardrails/hooks/secret-pattern-detection.sh:255) any write that puts a high-confidence token into a file it scans. Its only user control is the on/off switch secret_pattern_detection_enabled (plugins/guardrails/.claude-plugin/plugin.json:21, read at secret-pattern-detection.sh:32). The path allowlist secret_path_allowlisted() (secret-pattern-detection.sh:127-142) is fixed in code.

A user who deliberately keeps credentials in local notes that never get committed has no way to allow that without switching off the whole guard, which also drops the protection that matters: a token written into a tracked source file. Two cases come up:

  1. A notes directory under home that is not a git repository (for example ~/.claude/references/ or ~/.claude/.work/). In a session rooted at home, the scope guard clears the project root (secret-pattern-detection.sh:324-369, "home, or an ancestor of home"), so every file anywhere is scanned and these writes are denied.
  2. A gitignored .work/ folder inside a checkout or worktree. The file is under the project root (:371-375), the fixed allowlist does not cover it, so it is scanned and denied.

The guard denies outright; the attended-session "ask" that 0.52.0 added through hooks/refuse.sh covers only block-dangerous-git and block-root-delete-target, not this guard.

Evidence

Verified on origin/main (guardrails 0.55.3):

  • plugin.json has no path option for this guard. Its precedent is block_root_delete_target_allowed_roots (plugin.json:105; parser in hooks/block-root-delete-target.sh around :2068-2110; contract in that file's header :66-90): comma-separated absolute directories, compared by real path, strictly-under only, rejecting relative, empty, UNC, glob, .. and line-break entries and any entry that resolves to a filesystem root, HOME, or a directory holding HOME.
  • hardcoded-path-check.sh:364 already skips a file git -C "$CLAUDE_PROJECT_DIR" check-ignore -q reports ignored. secret-pattern-detection.sh has no such rung.
  • secret_path_allowlisted() is shared with the GitHub MCP lane (secret-pattern-detection.sh:227, push_files / create_or_update_file), where paths are repo-relative and the content leaves the machine.
  • The commit-time backstop exists: the pre-commit-content git-hook entry (added 0.52.0) scans the lines a commit adds for secrets, judges force-added gitignored files too, and keeps its own copy of the exemptions (plugins/guardrails/git-hooks/pre-commit-content.sh:66-71, secret_exempt()); it reads no userConfig option today.
  • ~/.claude on the reporting machine is not a git repository, so a gitignore rung alone cannot cover case 1, and an absolute-root list alone cannot cover case 2 (worktrees are created continually under different roots).
  • No existing issue covers this (searched open and closed: "secret-pattern-detection exempt", "secret gitignored", "secret-pattern-detection"). Related closed work: guardrails: secret-pattern-detection blocks a temp-tree Write that block-hook-bypass exempts for Bash #4471 (temp-tree parity), fix(guardrails): scan secrets when the project root is home or not a repo #4369 (scan when the project root is home), fix(guardrails): scan NotebookEdit cell source in secret-pattern-detection #4486 (NotebookEdit).

Item claim corrected: the item said the guard refuses writes "under $CLAUDE_PROJECT_DIR". In a repository-rooted session a write to ~/.claude/references/ is outside the project and exits unscanned at :374; it is blocked only in a home-rooted session, where the root is cleared and everything is scanned. The session scratchpad already passes through the temp-tree decline (:389 on).

Not verified: the measured frequency of these denials; the item reports the need, not a count.

Proposed approach

Two opt-in options, both default off/empty, so the default posture is unchanged:

  1. secret_pattern_detection_allowed_roots (string). Comma-separated absolute directories; a Write, Edit or NotebookEdit target whose resolved physical path is strictly under one is not scanned. Same parse and refusal rules as block_root_delete_target_allowed_roots, without the name-prefix form (no case for it here). Covers case 1.
  2. secret_pattern_detection_skip_gitignored (boolean, default false). When true, a target that the git repository containing it reports ignored (git -C <nearest existing ancestor of the target> check-ignore -q <target>) is not scanned. Covers case 2 without listing every worktree. Ask the repository holding the file, not $CLAUDE_PROJECT_DIR, so a home-rooted session works too.

Apply both only in the local lane, after the scope guard and before the content scan, near the secret_path_allowlisted "$ALLOW_FILE" call at :387. Do not add them to secret_path_allowlisted().

Alternatives considered:

  • A single path list with glob components (<root>/*/.work): pattern rules widen the exemption in ways a real-path strictly-under check does not, and it still needs updating for each new worktree root.
  • Skip gitignored files by default, as hardcoded-path-check does: changes the default for every consumer, and a gitignored file can still leak through means other than a commit. Leave it opt-in.
  • Ask when attended instead of deny: does not help subagents or unattended runs, where most writes happen.

Files: plugins/guardrails/hooks/secret-pattern-detection.sh, secret-pattern-detection.test.sh, .claude-plugin/plugin.json (two userConfig entries), README.md (option table and "Option details", regenerated by scripts/sync-plugin-options-docs.py), a changelog fragment and version bump.

Acceptance criteria

  • With both options unset, every existing secret-pattern-detection.test.sh case passes unchanged, and the hook spawns no extra process (no realpath, no git) on any write.
  • With secret_pattern_detection_allowed_roots set to an absolute directory D, a Write of a known test token to D/sub/x.md exits 0, and the same Write to a sibling of D, to D itself, and to a path that reaches D only through .. exits 2.
  • A symlink under D pointing outside D is scanned (exit 2); a path outside D whose real path, through a link, is under D is not scanned.
  • Entries that are relative, empty, UNC, contain a glob character or .., or resolve to a filesystem root, HOME or an ancestor of HOME grant nothing (the write is scanned).
  • With secret_pattern_detection_skip_gitignored=true, a Write of a test token to a gitignored path in a test repository exits 0, the same Write to a tracked or untracked, not-ignored path exits 2, and a target in no repository is scanned.
  • Both options hold in a home-rooted session (project root cleared) and in a repository-rooted session.
  • The GitHub MCP lane (push_files, create_or_update_file) is unaffected: a path under an allowed root or a gitignored path in the payload is still scanned and denied.
  • pre-commit-content is unaffected: a file under an allowed root that is force-added and committed with a test token still fails the commit.
  • scripts/sync-plugin-options-docs.py --check, scripts/check-changelog-fragments.sh --check-required origin/main and the guardrails test suites pass, on Linux and on Windows Git Bash.

Constraints and gotchas

  • This loosens a security guard; the option names and the gitignore rung need the maintainer's decision before implementation (hence needs-decision).
  • Hook budget: perf(guardrails): Edit/Write guards spawn 25-87 processes per fire #4390 and perf(guardrails, eol-normalizer, typos-format): about 440 ms of hooks per Edit/Write across three node-to-bash hops #6635 are open on process count and latency per Write/Edit. Resolve the target only when an option is set, and do the root comparison with builtins where possible (see docs/conventions/hook-budget/README.md).
  • Windows: real-path comparison must handle drive-letter case, /c/ versus C:/ spellings and 8.3 short names; the temp-tree decline (:389-467) and the delete guard's resolver are prior art. ~ is not expanded in either existing option; keep entries absolute.
  • Keep the exemptions out of pre-commit-content.sh's secret_exempt(); that check is the backstop that makes a local exemption safe.
  • That backstop exists only in clones where /guardrails:setup apply install-git-hooks ran, and each clone can decline it (0.52.0 changelog). Both option descriptions should say so.
  • With skip_gitignored on, anything that can write .git/info/exclude can make a path ignored without a tracked diff. hardcoded-path-check already trusts check-ignore the same way, and pre-commit-content still judges a force-added file, but name this in the option docs.
  • On the reporting machine, two project-scope installs pin older guardrails (0.47.6 and 0.46.8) while user scope is 0.55.3; a fix reaches those sessions only after the pins update.
  • Deny messages must not tell the model to set the option (0.48.0 rule: allow-list tokens are phrased as the user's action). If the message names it at all, say only the user can set it, as block-root-delete-target does.
  • Users set the options with /plugin configure guardrails@<marketplace> or claude plugin install guardrails@<marketplace> --config ... (plugins/guardrails/README.md:1057-1062).

Context

Source: local handoff item 2026-10-08-guardrails-secret-pattern-path-exemption.md (retired into this issue). The reporting user chose to keep credentials unmasked in local, never-committed notes and wants the guard kept on for everything else. Example values on a Windows host: secret_pattern_detection_allowed_roots=C:/Users/<you>/.claude/references,C:/Users/<you>/.claude/.work and secret_pattern_detection_skip_gitignored=true (for .work/ in each checkout and worktree). Related: #4471, #4369, #4486, ADR 0072 (check placement), 0.52.0 changelog (pre-commit-content).

Activity

  1. added
    status: needs-decisionAwaiting a human or maintainer judgment call.
    priority: mediumReal value, no hard deadline; normal backlog flow.
    area: securitySecurity-relevant: vulnerability, hardening, or disclosure follow-up.
    work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.
    on Oct 21, 2026
  2. github-actions commented on Oct 21, 2026

    @github-actions
    Contributor

    Proposed work class: work-class: scoped
    Basis: automated intake triage; the lanes App applied this class.

    Size: Medium
    Complexity: Medium
    Uncertainty: Known
    Flags: none
    Blocked by: none

    I understood this as a request for two opt-in guardrails options, both off by default. secret_pattern_detection_allowed_roots would exempt absolute local directories from secret scanning. secret_pattern_detection_skip_gitignored would exempt gitignored targets. Both would apply only in the local Write/Edit lane. The GitHub MCP lane and the commit-time pre-commit-content backstop stay unchanged. The approach, files and acceptance criteria are specific enough to start from. I found no duplicate among the open issues. #4390 and #6635 (hook process budget) are related constraints, not blockers.

    Automated triage, applied by the lanes App.

  3. added
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 21, 2026
  4. removed
    status: needs-decisionAwaiting a human or maintainer judgment call.
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 25, 2026
  5. kyle-sexton commented on Oct 25, 2026

    @kyle-sexton
    MemberAuthor

    Released for re-triage under the owner's 2026-10-24 rule: an issue stops for a person only for an intent or vision question, a security call, a host or attended run, or spend outside prepaid credit. The hold label came off and intake triage runs again now; it re-applies a hold, with a numbered question, when one of those applies.

    To hold this issue again, add needs-human (or status: needs-decision) and say why in a comment.

  6. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 25, 2026
  7. added
    status: readyTriaged, unblocked, and fully specified; eligible to pick up.
    admission-deferredReady for the issue lane; work-unblock admits it when queued work is under the queued cap
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    and removed
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    admission-deferredReady for the issue lane; work-unblock admits it when queued work is under the queued cap
    on Oct 25, 2026
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

    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.area: securitySecurity-relevant: vulnerability, hardening, or disclosure follow-up.priority: mediumReal value, no hard deadline; normal backlog flow.status: readyTriaged, unblocked, and fully specified; eligible to pick up.work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.

    Fields

    No fields configured for Feature.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions