chore(security): externalise confidentiality term list

Moves the list of private organisation / pilot / codename
strings the security gate blocks OUT of the repo entirely.

Before: tools/security/check-staged.sh + the Today AI prompt
+ docs/today-pipeline.md held the strings in plaintext. The
whole point of the gate is to keep certain strings out of
committed artefacts, so holding them in a committed
artefact was self-defeating — anyone with read access to
the repo trivially recovered the very list we tried to
protect.

After:

- The gate reads a runtime file
  `${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}`
  at scan time. One regex per line, `#`-prefixed comments,
  matched case-insensitively against staged diffs and
  commit messages. Repos contain no copy.
- Pre-commit / commit-msg modes log a warning + skip the
  confidential-terms scan if the file is missing (fresh
  checkouts shouldn't trip until the operator bootstraps
  the list).
- CI mode (`check-staged.sh ci`) FAILS when the file is
  missing — runners are expected to be bootstrapped by
  their deploy step.
- The unit-test harness uses a synthetic placeholder term
  (`SYNTHETIC_BANNED_TERM_XYZZY`) injected via a temp
  banned-terms file, so the test never references real
  customer names.
- docs/today-pipeline.md + tools/today/prompt.template.md
  point at the runtime file instead of enumerating terms.

Operator bootstrap (one-time, per machine):

  mkdir -p ~/.fai-security && chmod 700 ~/.fai-security
  printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt
  chmod 600 ~/.fai-security/banned-terms.txt

Gate self-tests: 18 passed, 0 failed.

Signed-off-by: flemming-it <sf@flemming.it>
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-25 21:06:17 +02:00
parent 21c2a0f411
commit efa9871a75
4 changed files with 84 additions and 20 deletions

View file

@ -63,16 +63,49 @@ FILE_ALLOWLIST=(
'fixtures?/.*\.(pem|key)$'
)
# Memory: confidentiality.md — these are PRIVATE. Never in public
# code / docs / commits / examples. Word-boundary matched to avoid
# false-positives on substrings.
CONFIDENTIAL_TERMS=(
'\bITDZ\b'
'\bKammergericht\b'
'\bHTW\b'
'J∆I'
'\bJAI\b'
)
# Confidential terms are loaded at runtime from a file outside
# the repo (~/.fai-security/banned-terms.txt by default; override
# with $FAI_BANNED_TERMS_FILE). The list itself is private — the
# whole point of the gate is to keep certain strings out of any
# committed artefact, so the list of strings to gate on must not
# live in a committed artefact either.
#
# File format: one regex per line. Lines starting with `#` and
# blank lines are ignored. Each line is matched as a
# case-insensitive grep -E pattern with `-i`; use `\b…\b` for
# word boundaries.
#
# Bootstrap on a fresh machine:
# mkdir -p ~/.fai-security
# chmod 700 ~/.fai-security
# printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt
# chmod 600 ~/.fai-security/banned-terms.txt
#
# When the file is missing the gate logs a warning but does NOT
# fail — running on a fresh checkout where the operator hasn't
# yet bootstrapped the list shouldn't block them. Real CI runs
# explicitly verify the file is present and refuse otherwise
# (see scan_ci_mode below).
CONFIDENTIAL_TERMS=()
_load_confidential_terms() {
local file="${FAI_BANNED_TERMS_FILE:-$HOME/.fai-security/banned-terms.txt}"
if [ ! -f "$file" ]; then
return 1
fi
while IFS= read -r line; do
# Strip trailing comments + leading/trailing whitespace.
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -z "$line" ] && continue
CONFIDENTIAL_TERMS+=("$line")
done < "$file"
return 0
}
if ! _load_confidential_terms; then
printf "%s⚠ confidentiality list not found at ${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}; gate will skip the confidential-terms scan%s\n" \
"${YEL:-}" "${RST:-}" >&2
fi
# Memory: feedback_no_marketing_speak.md.
BANNED_PHRASES=(
@ -142,7 +175,10 @@ scan_diff() {
local diff="$1"
[ -z "$diff" ] && return
scan_secrets "$diff"
scan_terms_ci "confidential term" "$diff" "${CONFIDENTIAL_TERMS[@]}"
# `${ARR[@]+"${ARR[@]}"}` is the safe-under-set-u expansion
# for arrays that may be empty (CONFIDENTIAL_TERMS is empty
# when the operator hasn't bootstrapped ~/.fai-security/).
scan_terms_ci "confidential term" "$diff" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}"
}
@ -208,7 +244,7 @@ scan_message_mode() {
fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)"
fi
scan_terms_ci "confidential term in commit message" "$msg" "${CONFIDENTIAL_TERMS[@]}"
scan_terms_ci "confidential term in commit message" "$msg" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
scan_terms_ci "banned phrase in commit message" "$msg" "${BANNED_PHRASES[@]}"
}
@ -217,6 +253,16 @@ scan_ci_mode() {
if ! git rev-parse --quiet --verify "$base" >/dev/null 2>&1; then
base=$(git rev-list --max-parents=0 HEAD | tail -1)
fi
# In CI the confidentiality list is non-optional: a missing
# file means an unbootstrapped runner is reviewing changes,
# and we'd be silently letting confidential terms through.
# The deploy step that wires up the runner is responsible for
# placing the file at $FAI_BANNED_TERMS_FILE.
if [ "${#CONFIDENTIAL_TERMS[@]}" -eq 0 ]; then
fail "CI mode requires \$FAI_BANNED_TERMS_FILE (or ~/.fai-security/banned-terms.txt) to exist"
fail " bootstrap the runner with a private confidentiality list before re-running"
return
fi
local files diff
files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD)
diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \