feat: hard-block AI/vendor-attribution references from commits, PRs, and Boards items - #220
Merged
Conversation
Single source every hard-block layer below reads from, so they can't drift out of sync (issue #219).
commit-msg scans the full commit message; pre-commit gains a second check scanning added lines of the staged diff. Both scope to Hollard/Azure DevOps repos only, via the same origin-URL signal git/gitconfig already uses for work identity — GitHub repos (dotfiles, wiki, brain) stay unscanned. Fails closed only when the wordlist itself is missing/unreadable in a scoped-in repo (issue #219).
Extends the git-commit-only Claude-Session trailer check into a full scanner covering git commit, PR create/update, and Azure Boards updates against the shared wordlist — the exact case that leaked for real. Also denies git --no-verify/-n outright, since it bypasses the global git hooks carrying the same scan (issue #219).
New PreToolUse hook, ported in structure from block-dangerous-git.sh. Merged into hooks.json as its own entry, independently skip-guarded from the dangerous-git entry so one guardrail's install problem can't break the other (issue #219).
New tool_call extension alongside git-guardrails.ts. Command shape is matched against the quote-scrubbed command; the wordlist is matched only against quoted-span content, so a banned term inside a commit message still blocks without false-positiving on this repo's own claude/codex path segments (issue #219).
Symlinks the shared wordlist into Claude and Pi, copies it into Codex (matching each tool's existing projection convention). Fixes the Codex hooks.json bash-path rewrite to be filename-aware per script instead of hardcoding block-dangerous-git.sh's path, and splits the PreToolUse merge into independent per-script passes so a missing wordlist only skips its own guardrail's entry (issue #219).
…ommand The wordlist was matched against the entire raw command string, so a clean commit touching claude/ or codex/ paths (e.g. this repo's own files) self-denied on the literal word inside the path. Now scans quoted spans plus recognized message-bearing flag values (-m/--title/ --body/--description/--fields/--route-parameters) instead. Also drops the git push -n deny (that flag means --dry-run, not --no-verify; push has no -n short form for --no-verify) and corrects a stale doc comment about repo-local wordlist resolution.
Same self-block fix as the Claude port: scope the wordlist scan to message-bearing flag values, not the whole command. Also fixes the no-python fallback tier's JSON extraction, which stopped at the first escaped quote — present in virtually every double-quoted -m/--title/ --body value — silently truncating the command before the banned term was ever reached. The existing fallback-tier tests didn't catch this: their PATH-prepend simulation of 'no python' didn't actually shadow python on this machine (fixed via cygpath -u), so they were unknowingly exercising the real python tier.
The wordlist previously scanned only quoted-span content, so any unquoted value (git commit -m Claude, --fields key=Generated-with- Claude) bypassed the scan entirely -- the layer's actual target surface with no backstop. Now also extracts unquoted single- and multi-value flag content. Also bounds NO_VERIFY_PATTERNS' wildcard to the same shell segment so a chained command like 'git commit -m "fix" && git log -n 1' no longer false-blocks on the unrelated log subcommand's -n.
'^\+([^+]|$)' meant to exclude only the +++ file header, but it also excluded any genuinely added line whose own content starts with a literal + (e.g. '++i; // ...'), silently skipping it from the scan. Anchor on the literal +++ header shape instead. Also adds --no-color as defense against color.diff=always breaking the ^+ anchor.
Both guardrail merge passes dropped a whole foreign PreToolUse entry if any hook inside it matched the pass's own script pattern, instead of removing just that hook. Mirrors the per-hook filtering the SessionStart merge already uses for the same reason.
…pass
That var is layer 2's own human bypass hatch for git/templates/hooks/
{commit-msg,pre-commit} (matching the existing SKIP_GITLEAKS
convention). An agent invoking Bash directly can set it just as
easily as a human, silently defeating layer 2's scan -- denied
outright now, same treatment as --no-verify.
Same hardening as the Claude port.
Same hardening as the Claude and Codex ports, folded into NO_VERIFY_PATTERNS so the existing test harness's static source extraction covers it without a separate exported const.
…ssage extraction - claude/no-claude-session-trailer.sh: gate on git remote origin URL (dev.azure.com + HollardInsuranceRetail) before any wordlist check, matching layer 2's own scoping — fixes self-blocking on this repo's own commits containing legitimate scope words like "codex"/"claude" - extend recognized message-bearing flags (-am, --discussion, --text, --query-parameters); drop the blanket quoted-span scan in favor of recognized-flag values only - switch quoted-value extraction to escape-aware PCRE; segment-scope -n/SKIP-var checks with a same-segment gap; anchor SKIP-var pattern to assignment shape - tests/no-claude-session-trailer.Tests.ps1: add repo-scoping, new-flag, dropped-scan, escape-aware quoting, and segment-scoped -n/SKIP-var test coverage (49 tests)
… list with Claude - codex/ai-reference-guard.sh: gate on git remote origin URL (dev.azure.com + HollardInsuranceRetail) before any wordlist check, matching layer 2's own scoping - extend recognized flags (-am, --discussion, --text, --query-parameters, plus gh pr create/edit -t/-b); replace bare-token value regex with one that consumes quoted spans atomically, fixing a gap in --query-parameters key="value with spaces" - flatten multiline -m values before extraction so a real multiline trailer shape can't evade single-line matching - replace the bare SKIP-var substring match with an anchored assignment-shape pattern, closing a quoted-value evasion gap - tests/codex-ai-reference-guard.Tests.ps1: run every case through a Hollard-origin throwaway repo by default; add repo-scoping, new-flag, multiline -m, SKIP quoted-value, and missing git push -n allow test coverage (59 tests)
…evasion - pi/extensions/ai-reference-guard.ts: gate on git remote origin URL (dev.azure.com + HollardInsuranceRetail) via isRepoScopedIn(), matching layer 2's own scoping - relocate the wordlist symlink outside the junctioned extensions/ dir (setup.ps1) so the junction doesn't write a real file into the tracked repo; update DEFAULT_WORDLIST_PATH to match - extend recognized flags (--discussion, --text, --query-parameters) - move the SKIP-var check to run against the raw (unscrubbed) command with an anchored assignment-shape pattern, fixing a quoted-value evasion (scrubQuoted previously deleted quoted content before the old check ran) - tests/pi-ai-reference-guard.Tests.ps1: add repo-scoping coverage against real throwaway git repos, new-flag, and SKIP quoted-value regression coverage (55 tests)
…AI-reference scan - git/templates/hooks/pre-commit: wrap the gitleaks logic in an if-block instead of top-level exits, so the script always falls through into the independent AI-reference section regardless of gitleaks' own skip/absence/outcome - narrow the +++ diff-header exclusion to ^\+\+\+ (a/|b/|/dev/null) so genuine added content starting with "++ " is no longer misclassified as a diff header and skipped - tests/git-templates-ai-reference-hook.Tests.ps1: add coverage for SKIP_GITLEAKS=1, gitleaks absent from PATH, and the ++-prefixed-content regression (20 tests)
… header match
- git/templates/hooks/{commit-msg,pre-commit}: swap the -f (exists) check for -r
(readable); on Windows Git Bash [ -r ] can still report true under an NTFS ACL deny,
so also guard on the extracted pattern set actually being non-empty (-s) as a
belt-and-braces fail-closed check
- pre-commit: pass --src-prefix=a/ --dst-prefix=b/ explicitly to git diff, so the
+++ header regex still matches under diff.noprefix/diff.mnemonicPrefix config
- tests/git-templates-ai-reference-hook.Tests.ps1: add unreadable-wordlist coverage
for both hooks and a diff.noprefix regression test (23 tests)
…evasions - codex/ai-reference-guard.sh: detect -n inside any clustered short-flag token (-nm etc.), not just an isolated -n; generalize the -m message flag to a cluster pattern too - add git -C <path> extraction and origin lookup, so scoping follows the command's actual repo target instead of the hook's inherited cwd - keep raw newlines for the segment-scoped -n/SKIP-var checks (widen the segment-gap and SKIP-var anchor classes to treat \n as a boundary) while still flattening newlines for multiline -m message-value extraction, closing both a newline SKIP-var evasion and a newline-chained-command false-positive - capture the full run of --fields/--route-parameters/--query-parameters key=value pairs instead of only the first token - tests/codex-ai-reference-guard.Tests.ps1: add clustered -n, -C scoping, newline-chained, and multi-value coverage (66 tests)
…i-value gaps - claude/no-claude-session-trailer.sh: detect -n inside any clustered short-flag token (-nm etc.); generalize the -m message flag to a cluster pattern too - add git -C <path> extraction and origin lookup, so scoping follows the command's actual repo target instead of the hook's inherited cwd - split the SKIP-var check: export/env-form assignments are matched anywhere in the command (they persist forward across shell segments in one bash invocation), plain prefix assignments stay segment-scoped - scan gh pr create/edit's -t/-b short flags, matching codex/pi's PR scoping - capture the full run of --fields/--route-parameters/--query-parameters key=value pairs instead of only the first token - tests/no-claude-session-trailer.Tests.ps1: add clustered -n, -C scoping, export-SKIP, and PR-short-flag coverage; repoint two -C-parsing fixtures at a real Hollard repo now that -C drives actual scoping (64 tests)
… wordlist path - pi/extensions/ai-reference-guard.ts: detect -n inside any clustered short-flag token (-nm etc.) in NO_VERIFY_PATTERNS - add extractDashCPath() and use git -C <path> for the origin lookup when the command carries -C, falling back to cwd otherwise - widen skipVarPattern's anchor to treat a newline as a segment boundary (multiline flag + \n in the anchor class), closing a newline-separated SKIP-var evasion - harden DEFAULT_WORDLIST_PATH resolution: try a relative-to-script candidate and a homedir-derived candidate in order, since junction resolution behavior for import.meta.url through the extensions/ directory junction is not guaranteed to land on the same path Node would resolve outside a junction - tests/pi-ai-reference-guard.Tests.ps1: add clustered -n, -C scoping, multiline SKIP-var, and real wordlist-path-resolution coverage against the actual default export (67 tests)
…se-positive
- pi/extensions/ai-reference-guard.ts: replace execSync's shell-interpolated
`git -C ${dashCPath} ...` template with execFileSync("git", [argv]) for both the
-C and plain-cwd origin lookups — a shell-interpolated string let a crafted -C
value (quotes + shell metacharacters) execute arbitrary commands before the tool
call this hook gates was ever authorized; execFileSync's argv form never invokes
a shell, so no metacharacter in the extracted path can be interpreted specially
- widen NO_VERIFY_PATTERNS' segment-gap regex to exclude newline ([^;&|]* ->
[^;&|\n]*), fixing a false-positive deny on newline-separated commands where a
later line's unrelated -n flag was wrongly attributed to an earlier git commit
- tests/pi-ai-reference-guard.Tests.ps1: add a live injection-repro test (constructs
a malicious -C value, asserts no marker file is created) and a real-embedded-newline
regression test (70 tests)
…n lookup - claude/no-claude-session-trailer.sh: the cycle-3 -C scoping fix captured the raw token including surrounding quotes (git -C "." ... extracted the literal 3-char string "."), so the origin lookup always failed and the whole hook failed open via its || exit 0 fallback -- skipping the wordlist, -n, and SKIP-var checks entirely. Now captures a quoted-or-bare token and strips one matching layer of quotes before the lookup, matching codex/ai-reference-guard.sh's existing unquoting. - tests/no-claude-session-trailer.Tests.ps1: add quoted -C regression coverage (67 tests). Notes inline, not fixed here (separate pre-existing bug, out of scope): a quoted multi-word -C value also breaks commit_re's own flag-argument capture, which only consumes one non-whitespace token -- tracked as a residual gap, not introduced by this fix.
…t_re - claude/no-claude-session-trailer.sh: flag_group's optional flag-argument capture only consumed one non-whitespace token, so a quoted multi-word -C value (e.g. git -C "C:/Hollard Repo" commit ...) broke commit_re's own match entirely -- silently skipping the wordlist, -n, and SKIP-var checks for any such command. Widened the capture to also accept a quoted (possibly multi-word) token, matching the quoted-token support already added for -C extraction itself in cycle 4. - tests/no-claude-session-trailer.Tests.ps1: add a Hollard-origin repo whose path contains a space and real deny/allow coverage through it, replacing the prior cycle's inline note that this was a known, deliberately out-of-scope limitation (70 tests)
…lse commit match - claude/no-claude-session-trailer.sh: the cycle-5 flag_arg_token_re widening (for quoted multi-word -C values) let its bare-token alternative also match a token starting with an unbalanced quote, e.g. git -c "x commit y" log -n 1 -- the bare-token branch consumed just "x, leaving the rest of the quoted config value (including the literal word "commit") to falsely satisfy commit_re's trailing match, then the later -n falsely denied an ordinary git log. Excluding a leading quote character from the bare-token alternative forces such a token through a real quoted alternative instead, which fails safely rather than leaking. - tests/no-claude-session-trailer.Tests.ps1: add false-positive + regression coverage for the unbalanced-quote case alongside the real quoted -C cases (74 tests)
- claude/README.md, codex/README.md, pi/README.md: describe the Hollard/Azure-DevOps repo-scoping (added cycle 2), -C-target-following origin lookup (cycle 3-4), the clustered -n short-flag detection and exported SKIP-var bypass deny (cycle 3), and correct pi/README.md's stale claim that the wordlist symlink lives inside the junctioned extensions/ directory (it was relocated outside it in cycle 2) plus its now-defensive two-candidate path resolution and execFileSync-based origin lookup (cycle 4)
…on Linux bash cygpath is a Git-for-Windows-only utility; a real (non-Git-Bash) bash on Ubuntu CI has no cygpath, so the conversion produced no stdout and .Trim() threw on $null. Only convert on Windows; Linux's stub dir path is already POSIX-native.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #219
What
Local hard-block layer that denies (not just discourages) an AI/vendor-attribution
reference from landing in a commit message, a PR title/body, or an Azure Boards item —
replacing the prompt-level "no AI references" rule in AGENTS.md that had already failed
once in practice.
Where it applies
Scoped to Hollard/Azure-DevOps repos only, at every layer: a repo's
git remote get-url originmust contain bothdev.azure.comandHollardInsuranceRetail(case-insensitive)before any check runs. GitHub repos (this one included) are never scanned.
Layers
ai-agents/_shared/banned-ai-terms.txt, one case-insensitiveextended regex per line, projected per tool's own existing convention (symlink for
Claude/Pi, copy for Codex).
commit-msg+pre-commit(viacore.hooksPath), scanning thefinal commit message and added diff lines respectively.
PreToolUsehook (no-claude-session-trailer.sh, generalized fromthe original trailer-only check).
PreToolUsehook (ai-reference-guard.sh), merged intohooks.json.tool_callextension (ai-reference-guard.ts).All four tool-facing guards deny
git/push --no-verify(and-non commit, includingclustered short-flags like
-nm) and an exportedSKIP_AI_REFERENCE_SCAN/SKIP_GITLEAKS=1outright, since those bypass the git-hook layer that carries the samescan; they also follow a
git -C <path>command to its actual target repo rather thantrusting the hook's inherited cwd.
Review history
Six
review-fix-loopcycles (Fable + Codex/sol as independent reviewers) ran against thisbranch before opening the PR. Notable finds, all fixed test-first:
Codex JSON-extraction truncation bug; Pi scanning quoted spans only; a
pre-commitadded-line misclassification; a
setup.ps1hooks-merge bug; missing SKIP-var deny.self-blocking on this repo's own conventional-commit messages); missed flag shapes;
a SKIP-var quoting evasion.
-nmno-verify evasion; a cwd-vs--C-target scoping gap; anexport-persisted SKIP-var evasion (Claude); missinggh pr create -t/-b(Claude);a newline-handling bug in Codex/Pi; multi-value-flag truncation; git hooks' unreadable
wordlist failing open.
-Cpath was interpolated into a shell-parsedexecSynccall, letting a crafted pathexecute arbitrary commands; fixed via
execFileSyncwith an argv array. Also: theClaude guard's
-Cquote-stripping didn't hold, and a Pi newline fix regressed.-Cpath breaking the Claude guard's own commit-shaperegex (this repo's own path has a space); a false positive that regression introduced
(an unbalanced quote in an unrelated flag's value leaking into a false commit match).
Known, deliberately accepted residual gaps
Not fix-round misses — inherent to scanning a command string before it runs, not the
process it starts:
git commit -F file,gh pr create --body-file,az devops invoke --in-file.substitution, arithmetic expansion, escape sequences) rather than literally present in
the command string, e.g.
SKIP_AI_REFERENCE_SCAN=$((1)).cd <path> && git ...shell-state tracking — only an explicitgit -C <path>isfollowed; a
cdisn't.azcommand.definition.
-Cfail-open on a lookup failure: if the target path's owngit remote get-url originfails (bad path, or a Hollard clone whoseoriginwas renamed), the guardcurrently allows the command rather than denying — the guard and the real command
resolve the same string, so this isn't an attacker-controlled bypass, but it is a real
gap for legitimate misconfiguration.
Verification
Full Pester suite: 693/695 passing (2 pre-existing failures, unrelated to this branch —
dangling skill-projection junctions on this machine's live
~/.claude/skills/~/.codex/skills, present before and after this branch). Full-tree PSScriptAnalyzer: 5pre-existing findings, none in files this branch touches.
git diff --check: clean.The final review cycle's Codex/sol pass ran statically only (its sandbox couldn't execute
Pester live); Fable's parallel pass did execute every repro live. Worth a human spot-check
of the residual gaps above, and of cycle 6's fix in particular, before merge.