Skip to content

feat: hard-block AI/vendor-attribution references from commits, PRs, and Boards items - #220

Merged
jinyeow merged 31 commits into
mainfrom
feat/ai-reference-hardblock
Sep 4, 2026
Merged

feat: hard-block AI/vendor-attribution references from commits, PRs, and Boards items#220
jinyeow merged 31 commits into
mainfrom
feat/ai-reference-hardblock

Conversation

@jinyeow

@jinyeow jinyeow commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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 origin must contain both dev.azure.com and HollardInsuranceRetail (case-insensitive)
before any check runs. GitHub repos (this one included) are never scanned.

Layers

  1. Shared wordlistai-agents/_shared/banned-ai-terms.txt, one case-insensitive
    extended regex per line, projected per tool's own existing convention (symlink for
    Claude/Pi, copy for Codex).
  2. Global git hookscommit-msg + pre-commit (via core.hooksPath), scanning the
    final commit message and added diff lines respectively.
  3. Claude CodePreToolUse hook (no-claude-session-trailer.sh, generalized from
    the original trailer-only check).
  4. Codex CLIPreToolUse hook (ai-reference-guard.sh), merged into hooks.json.
  5. Pi — a tool_call extension (ai-reference-guard.ts).

All four tool-facing guards deny git/push --no-verify (and -n on commit, including
clustered short-flags like -nm) and an exported SKIP_AI_REFERENCE_SCAN/
SKIP_GITLEAKS=1 outright, since those bypass the git-hook layer that carries the same
scan; they also follow a git -C <path> command to its actual target repo rather than
trusting the hook's inherited cwd.

Review history

Six review-fix-loop cycles (Fable + Codex/sol as independent reviewers) ran against this
branch before opening the PR. Notable finds, all fixed test-first:

  • Cycle 1: whole-command wordlist matching self-blocking on this repo's own paths; a
    Codex JSON-extraction truncation bug; Pi scanning quoted spans only; a pre-commit
    added-line misclassification; a setup.ps1 hooks-merge bug; missing SKIP-var deny.
  • Cycle 2: added the Hollard/ADO repo-scoping described above (the real fix for
    self-blocking on this repo's own conventional-commit messages); missed flag shapes;
    a SKIP-var quoting evasion.
  • Cycle 3: a clustered -nm no-verify evasion; a cwd-vs--C-target scoping gap; an
    export-persisted SKIP-var evasion (Claude); missing gh pr create -t/-b (Claude);
    a newline-handling bug in Codex/Pi; multi-value-flag truncation; git hooks' unreadable
    wordlist failing open.
  • Cycle 4: a CRITICAL command-injection vulnerability in the Pi guard — an extracted
    -C path was interpolated into a shell-parsed execSync call, letting a crafted path
    execute arbitrary commands; fixed via execFileSync with an argv array. Also: the
    Claude guard's -C quote-stripping didn't hold, and a Pi newline fix regressed.
  • Cycles 5-6: a quoted multi-word -C path breaking the Claude guard's own commit-shape
    regex (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:

  • File-argument/heredoc content the hooks don't dereference: git commit -F file,
    gh pr create --body-file, az devops invoke --in-file.
  • Shell-expansion evasion: a value only assumed at execution time (command
    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 explicit git -C <path> is
    followed; a cd isn't.
  • MCP-mediated Azure Boards writes that don't go through a shelled-out az command.
  • Text pasted directly into the ADO/GitHub web UI — outside every layer here by
    definition.
  • -C fail-open on a lookup failure: if the target path's own git remote get-url origin fails (bad path, or a Hollard clone whose origin was renamed), the guard
    currently 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: 5
pre-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.

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 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.
@jinyeow
jinyeow merged commit 005fc57 into main Sep 4, 2026
5 checks passed
@jinyeow
jinyeow deleted the feat/ai-reference-hardblock branch September 4, 2026 08:26
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.

Hard-block AI/Claude/Codex/Copilot references at the point of write

1 participant