Files
molecule-core/.gitea/workflows/lint-forbidden-env-keys.yml
T
hongming-ceo-delegated b2addd32f8
ci-required-drift / drift (pull_request) Has been cancelled
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Block integration-tester contamination artifacts / Block staging-trigger / invalid manifest contamination (pull_request) Successful in 16s
CI / Python Lint & Test (pull_request) Successful in 20s
Concierge Creates Workspace Hermetic / Concierge Creates Workspace Hermetic (pull_request) Successful in 44s
E2E Peer Visibility (literal MCP list_peers) / detect-changes (pull_request) Successful in 30s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 29s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 29s
E2E Ephemeral CP Happy Path / detect (pull_request) Successful in 1m16s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Successful in 25s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge user_tasks (pull_request) Successful in 33s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Creates Workspace (pull_request) Successful in 27s
CI / Detect changes (pull_request) Successful in 2m3s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m20s
E2E Staging SaaS (full lifecycle) / E2E Staging Workspace Requests (core#2606) (pull_request) Successful in 32s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge (compile+skip) (pull_request) Successful in 33s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Platform Agent (pull_request) Successful in 32s
E2E Staging SaaS (full lifecycle) / E2E Staging Plugin Install Lifecycle (pull_request) Successful in 35s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 24s
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m20s
Lint forbidden hand-written mcp__ tool-id literals / Scan for hand-written mcp__ tool-id literals (pull_request) Successful in 16s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 33s
E2E Chat / detect-changes (pull_request) Successful in 2m28s
lint-bp-context-emit-match / lint-bp-context-emit-match (pull_request) Successful in 51s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 36s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 37s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m4s
Lint publish-runner timeout-minutes / Lint publish-runner timeout-minutes (pull_request) Successful in 33s
lint-no-coe-on-required / lint-no-coe-on-required (pull_request) Successful in 49s
lint-phantom-gate / lint-phantom-gate (pull_request) Successful in 42s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 25s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 38s
Lint schedule budget / Zero-cron ratchet (pull_request) Successful in 41s
lint-staging-tenant-cd-gate-chain / lint-staging-tenant-cd-gate-chain (pull_request) Successful in 33s
lint-setup-go-cache / lint-setup-go-cache (pull_request) Successful in 40s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m17s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 25s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 39s
reserved-path-review / reserved-path-review (pull_request_target) Failing after 22s
template-delivery-e2e / detect-changes (pull_request) Successful in 33s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Successful in 1m6s
lint-env-coupling-dismissal / lint-env-coupling-dismissal (pull_request) Successful in 2m21s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m58s
PR Diff Guard / PR diff guard (pull_request_target) Successful in 1m40s
E2E Staging SaaS (full lifecycle) / Prune stale e2e DNS records (pull_request) Successful in 15s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 17s
CI / Platform (Go) (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
template-delivery-e2e / Template-asset delivery (fresh seo-agent — config+prompts via asset channel, seo-all via plugin reconcile) (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
E2E Chat / E2E Chat (pull_request) Successful in 9s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Successful in 1m15s
E2E Ephemeral CP Happy Path / E2E Ephemeral CP Happy Path (pull_request) Successful in 9m43s
CI / Canvas Deploy Status (pull_request) Successful in 7s
CI / all-required (pull_request) Successful in 15s
audit-force-merge / audit (pull_request_target) Has been skipped
fix(ci): fail closed when the tenant-token lint's surface pattern matches nothing (H5)
`scan-tenant-token-write` in .gitea/workflows/lint-forbidden-env-keys.yml
built its candidate set by grepping SURFACE_PATTERN across the tree, then:

    if [ "${#CANDIDATES[@]}" -eq 0 ]; then
      echo "OK No tenant-writer-surface files found in tree (unexpected, but not a lint failure)."
      exit 0
    fi

Zero candidates is exactly what a BROKEN input produces — a moved,
renamed or refactored package, or a typo in SURFACE_PATTERN. The lint
would scan nothing, prove nothing, and report PASS. Its own comment said
"unexpected", which is the tell. Under main's status_check_contexts=['*']
that green is merge-authorising.

Fail closed instead: zero candidates now goes RED naming the mechanism
and the remedy (update SURFACE_PATTERN if the code genuinely moved).

Also fixes the RED arm's diagnostic, found while mutation-proving: HITS
always begins with "--- <file> ---", so bare `printf "$HITS"` parsed the
"--" as an option ("printf: --: invalid option") and died at exit 2 under
`set -e` before printing WHICH file tripped the lint. `printf '%b'` fixes
it — the guard fired but could never name its finding. Pre-existing on
main; not introduced here.

Mutation-proof (verbatim in PR body): baseline GREEN with 28 candidates;
surface pattern mutated to match nothing -> RED exit 1; real violation
injected -> RED exit 1 and now names file:line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 03:33:18 -07:00

378 lines
19 KiB
YAML

name: Lint forbidden tenant-env keys
# RFC#523 Layer 3 (task #146): scan workspace_secrets-writer Go code
# under workspace-server/ for new code that hardcodes a forbidden
# operator-scope env var NAME (GITEA_TOKEN, CP_ADMIN_API_TOKEN,
# RAILWAY_TOKEN, INFISICAL_OPERATOR_TOKEN, MOLECULE_OPERATOR_*, …).
#
# Catches the class "a new writer accidentally widens the propagation
# set" — e.g. a future env-mutator plugin that sets envVars["GITEA_TOKEN"]
# directly. Today the L1 runtime guard would abort the provision, but
# this lint surfaces the offending code at PR review time instead of
# at first provision attempt.
#
# Companion layers:
# - L1: workspace-server/internal/handlers/workspace_provision_forbidden_env.go
# (fail-closed abort at provision time)
# - L2: workspace/entrypoint.sh top-of-file env-grep + exit 1
#
# Open-source-template-friendly: the deny pattern is generic. A fork
# can copy this workflow and replace OPERATOR_KEY_PATTERN with its
# own operator-scope key names.
#
# Path-filter discipline:
# This workflow runs on every PR (no paths: filter — see
# feedback_path_filtered_workflow_cant_be_required). The scan itself
# targets workspace_secrets-writer paths via grep -r; it's fast
# (sub-second) so unconditional run is fine.
#
# ── 2026-06-01 CI-scheduler-fanout consolidation (fix/ci-scheduler-fanout) ──
# The RFC#523 sibling lint formerly in its own file
# `lint-no-tenant-gitea-token.yml` (the broader "no repo-host token into
# any tenant-writer surface" scan) is now a SECOND job in THIS workflow
# (`scan-tenant-token-write`). Both are sub-second Go-source greps that
# fired as two separate workflow runs on every PR — pure scheduler
# fan-out. Folding the sibling in here drops one workflow run + one
# checkout per PR while keeping BOTH scans firing unconditionally on
# every PR (the no-paths discipline above is preserved — neither job is
# paths-filtered). The moved job keeps its exact `name:` so its emitted
# status context is unchanged in substance; its `# bp-exempt:` directive
# moves with it (Tier 2g). The old `Lint no tenant GITEA or GITHUB token
# write / …` context is retired (a disappearing context needs no
# directive; only NEW emitters do).
concurrency:
# Auto-cancel a superseded run: a newer commit/event supersedes the old
# one instead of leaving it queued forever (root fix for waiting-run pileup).
# PR events group by PR number; push events group by ref.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging]
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
scan:
name: Scan workspace_secrets writers for forbidden env keys
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Scan for forbidden operator-scope env key NAMES in writer paths
run: |
set -euo pipefail
# Forbidden EXACT-MATCH env var names. Kept in lockstep with
# workspace-server/internal/handlers/workspace_provision_forbidden_env.go
# forbiddenTenantEnvKeys. The Go-side test
# TestIsForbiddenTenantEnvKey_ExactMatches is the source of
# truth — if Go-side adds a key, also add it here (and
# vice-versa). Drift between the two is the failure mode this
# entire 3-layer guardrail is designed to catch.
FORBIDDEN_KEYS=(
"GITEA_TOKEN" "GITEA_PAT"
"GITHUB_TOKEN" "GITHUB_PAT" "GH_TOKEN"
"GITLAB_TOKEN" "GL_TOKEN"
"BITBUCKET_TOKEN"
"CP_ADMIN_API_TOKEN" "CP_ADMIN_TOKEN"
"INFISICAL_OPERATOR_TOKEN" "INFISICAL_BOOTSTRAP_TOKEN"
"RAILWAY_TOKEN" "RAILWAY_PERSONAL_API_TOKEN"
"HETZNER_TOKEN" "HETZNER_API_TOKEN"
)
# Forbidden PREFIX patterns — operator-scope families.
FORBIDDEN_PREFIXES=(
"MOLECULE_OPERATOR_"
)
# Writer paths: Go source under workspace-server/ that
# writes to the env-vars map or to workspace_secrets DB rows.
# Tests, the forbidden-env source itself, and the silent-
# strip denylist are exempt (they LIST the keys by design).
SCAN_ROOT="workspace-server/internal"
# Exempt paths fall in two classes:
# 1. The deny-set definitions + the silent-strip denylist:
# they LIST the forbidden names by design.
# 2. Pre-RFC#523 persona-merge / config-read paths that
# already handle these names correctly (the silent-
# strip downstream + the new L1 fail-closed cover the
# runtime risk; these reads are unchanged).
# New code MUST NOT be added to this list without reviewer
# signoff and a one-line justification in this diff.
EXEMPT_PATHS=(
# Class 1 — deny-set definitions
"workspace-server/internal/handlers/workspace_provision_forbidden_env.go"
"workspace-server/internal/handlers/workspace_provision_forbidden_env_test.go"
"workspace-server/internal/provisioner/provisioner.go"
"workspace-server/internal/provisioner/provisioner_test.go"
# Class 3 — secret redaction table: the quoted forbidden names here
# are category labels for regexps that *strip* secrets from memory
# content, not env-var injection sinks. core#2918.
"workspace-server/internal/handlers/memories.go"
# Class 2 — pre-existing persona-fallback / org-helper paths
# that set the GITEA_TOKEN fallback lane (stripped downstream
# by provisioner.buildContainerEnv per forensic #145). The
# new L1 fail-closed runs BEFORE these writers, so any
# operator-scope leak via global/workspace_secrets is
# already caught. See applyAgentGitHTTPCreds doc-comment.
"workspace-server/internal/handlers/agent_git_identity.go"
"workspace-server/internal/handlers/org_helpers.go"
"workspace-server/internal/handlers/org.go"
# Class 2 — CP→platform admin auth (NOT a tenant env write;
# this is the control-plane HTTP auth header source).
"workspace-server/internal/provisioner/cp_provisioner.go"
)
# Build a single grep -F pattern: every forbidden key wrapped
# in quotes (Go string-literal form, which is how env-map
# writes appear). e.g. envVars["GITEA_TOKEN"] = ... or
# `"GITEA_TOKEN":` in a literal-map declaration.
#
# We deliberately match the quoted form so a comment that
# happens to spell the name without quotes (e.g. "see
# GITEA_TOKEN below") doesn't trip the lint.
PATTERN=""
for k in "${FORBIDDEN_KEYS[@]}"; do
PATTERN="${PATTERN}\"${k}\"\n"
done
for p in "${FORBIDDEN_PREFIXES[@]}"; do
# Prefix match needs a regex; switch to grep -E below for
# this slice. Kept conceptually here so the deny set lives
# in one place; scan is run twice (literal + prefix).
true
done
# Build exempt-paths grep filter — `grep -v -f` style.
EXEMPT_FILTER=$(mktemp)
trap 'rm -f "$EXEMPT_FILTER"' EXIT
for p in "${EXEMPT_PATHS[@]}"; do
echo "$p" >> "$EXEMPT_FILTER"
done
# --- Exact-match scan ---
HITS=""
for k in "${FORBIDDEN_KEYS[@]}"; do
# Only .go files; skip _test.go for the writer-path scan
# since tests legitimately reference the names. The
# writer-path lint targets PRODUCTION code only.
found=$(grep -rn --include='*.go' --exclude='*_test.go' "\"${k}\"" "$SCAN_ROOT" 2>/dev/null \
| grep -v -F -f "$EXEMPT_FILTER" || true)
if [ -n "$found" ]; then
# Filter out the LABEL side of a redaction regexp-tuple —
# {regexp.MustCompile(`<re>`), "<KEY>"} — which is a
# security CONTROL (the label is just the canonical
# deny-listed env-var name used to tag what the redaction
# strips), not an actual env-injection. The pattern is
# anchored on the per-iteration $k; lines that don't
# contain the regexp.MustCompile(...), "<k>" shape
# (envVars["<k>"] = ..., os.Getenv("<k>"), etc.) pass
# through unchanged. core#2918.
found=$(echo "$found" | grep -vE 'regexp\.MustCompile\([^)]+\)\s*,\s*"'"${k}"'"' || true)
fi
if [ -n "$found" ]; then
HITS="${HITS}${found}\n"
fi
done
# --- Prefix scan ---
for prefix in "${FORBIDDEN_PREFIXES[@]}"; do
found=$(grep -rnE --include='*.go' --exclude='*_test.go' "\"${prefix}[A-Z0-9_]+\"" "$SCAN_ROOT" 2>/dev/null \
| grep -v -F -f "$EXEMPT_FILTER" || true)
if [ -n "$found" ]; then
# Same redaction-tuple-LABEL filter as the exact-match scan
# (the deny-prefix family can show up in redaction labels too).
found=$(echo "$found" | grep -vE 'regexp\.MustCompile\([^)]+\)\s*,\s*"'"${prefix}"'[A-Z0-9_]+"' || true)
fi
if [ -n "$found" ]; then
HITS="${HITS}${found}\n"
fi
done
if [ -n "$HITS" ]; then
echo "::error::RFC#523 Layer 3: forbidden operator-scope env var name(s) hardcoded in tenant-workspace writer paths:"
printf "$HITS"
echo ""
echo "These env-var NAMES are on the operator-scope deny list (see"
echo "workspace-server/internal/handlers/workspace_provision_forbidden_env.go)."
echo "If your code legitimately needs to inject one of these for a"
echo "non-tenant code path, add the file to EXEMPT_PATHS in this"
echo "workflow with a one-line justification — reviewer signoff required."
exit 1
fi
echo "OK No forbidden operator-scope env key names hardcoded in writer paths."
# bp-exempt: advisory RFC#523 lint; PR review gate is review-driven, not BP-driven.
# (Carried with the workflow-name rename in PR mc#1593 so the renamed
# context emission satisfies lint_required_context_exists_in_bp Tier 2g.)
scan-tenant-token-write:
name: Scan for repo-host token write into tenant workspace surface
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Find Go files referencing a tenant-writer surface AND a repo-host token
run: |
set -euo pipefail
# Repo-host token NAMES — the threat-model subset. Operator-fleet
# tokens (CP_ADMIN_API_TOKEN, RAILWAY_TOKEN, INFISICAL_*) are
# caught by lint-forbidden-env-keys.yml's broader deny set; this
# lint focuses on the git-host class so a single co-occurrence
# match has a low false-positive rate.
FORBIDDEN_KEYS=(
"GITEA_TOKEN"
"GITEA_PAT"
"GITHUB_TOKEN"
"GITHUB_PAT"
"GH_TOKEN"
)
# Tenant-writer surface markers. A file matches the surface set
# if it references ANY of these strings. This is the "is this
# code path writing into a tenant workspace?" heuristic.
# Curated to catch the actual code shapes used in this repo
# (verified by grep against current main 2026-05-19):
# - "workspace_secrets" / "global_secrets" → DB table writes
# - "seedAllowList" → CP-side seed table
# - "/settings/secrets" → tenant HTTP API write
# - "envVars[" → in-memory env map write
# - "containerEnv" → docker-run env-set
# - "userData" → EC2 user-data script
# - "provisionPayload" / "provisionContext" → provision-request shape
SURFACE_PATTERN='workspace_secrets|global_secrets|seedAllowList|/settings/secrets|envVars\[|containerEnv|userData|provisionPayload|provisionContext'
# Files that legitimately reference these names AND a surface
# marker, but do so for guard / strip / test / doc-comment
# reasons. New entries require reviewer signoff and a one-line
# justification in the diff.
EXEMPT_FILES=(
# RFC#523 L1 deny-set source-of-truth + tests
"workspace-server/internal/handlers/workspace_provision_forbidden_env.go"
"workspace-server/internal/handlers/workspace_provision_forbidden_env_test.go"
# Forensic-#145 silent-strip denylist (defense-in-depth, by design lists the names)
"workspace-server/internal/provisioner/provisioner.go"
"workspace-server/internal/provisioner/provisioner_test.go"
# Pre-RFC#523 persona-fallback / org-helper paths. The L1
# fail-closed runs BEFORE these writers; downstream silent-strip
# also covers them. See applyAgentGitHTTPCreds doc-comment.
"workspace-server/internal/handlers/agent_git_identity.go"
"workspace-server/internal/handlers/org_helpers.go"
"workspace-server/internal/handlers/org.go"
# CP→platform admin auth (NOT a tenant env write).
"workspace-server/internal/provisioner/cp_provisioner.go"
)
# Build an extended-regex alternation of forbidden keys.
KEY_ALT="$(IFS='|'; echo "${FORBIDDEN_KEYS[*]}")"
# Find candidate files: Go non-test sources that contain a
# tenant-writer surface marker.
mapfile -t CANDIDATES < <(
grep -rlE --include='*.go' --exclude='*_test.go' \
"${SURFACE_PATTERN}" . 2>/dev/null \
| sed 's|^\./||' \
| sort -u
)
# FAIL CLOSED on an empty candidate set.
#
# This lint's only subject is "the set of Go files that touch a
# tenant-writer surface". If SURFACE_PATTERN matches nothing, the
# lint has lost sight of its subject: it cannot prove the absence
# of a repo-host-token write, it can only report that it looked
# nowhere. Passing here would make the PASS arm identical to what
# a BROKEN input (a moved/renamed/refactored package, a typo in
# SURFACE_PATTERN) produces — i.e. a vacuous gate.
#
# On main this scan finds 28 candidate files, so an empty set is
# never the normal state; it means the pattern has drifted from
# the code.
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
echo "::error::Task #146 lint has lost sight of its subject: SURFACE_PATTERN matched ZERO Go files."
echo ""
echo "SURFACE_PATTERN: ${SURFACE_PATTERN}"
echo ""
echo "This lint proves 'no tenant-writer-surface file quotes a repo-host"
echo "token name'. With zero candidate files it has scanned NOTHING and"
echo "can prove nothing, so it fails closed rather than reporting a"
echo "vacuous PASS."
echo ""
echo "Remedy: the tenant-writer code has almost certainly moved, been"
echo "renamed, or been refactored. Update SURFACE_PATTERN in"
echo ".gitea/workflows/lint-forbidden-env-keys.yml so it once again"
echo "matches the code shapes that write into a tenant workspace"
echo "(env-var map writes, workspace_secrets/global_secrets DB rows,"
echo "container env, EC2 user-data, provision payloads)."
exit 1
fi
echo "Tenant-writer-surface candidate files: ${#CANDIDATES[@]}"
HITS=""
for f in "${CANDIDATES[@]}"; do
# Skip exempt files.
skip=0
for ex in "${EXEMPT_FILES[@]}"; do
if [ "$f" = "$ex" ]; then skip=1; break; fi
done
[ "$skip" = "1" ] && continue
# File contains a surface marker; now grep for a forbidden
# key NAME. We require a QUOTED-literal match to avoid
# firing on a comment like "// also handle GITEA_TOKEN".
#
# The literal form catches:
# - os.Getenv("GITEA_TOKEN")
# - envVars["GITEA_TOKEN"] = ...
# - {envKey: "GITEA_TOKEN", tenantKey: "GITEA_TOKEN"}
# but not:
# - // see GITEA_TOKEN below (no quotes)
found=$(grep -nE "\"(${KEY_ALT})\"" "$f" 2>/dev/null || true)
# Filter out the LABEL side of a redaction regexp-tuple —
# {regexp.MustCompile(`<re>`), "<KEY>"} — which is a
# security CONTROL, not an actual tenant-writer surface
# injection. Same rationale as the scan job's
# LABEL_EXCLUDE filter (core#2918). The per-iteration
# KEY_ALT is the alternation; the LABEL_EXCLUDE alternation
# matches any of them in the redaction-tuple-LABEL slot.
if [ -n "$found" ]; then
found=$(echo "$found" | grep -vE 'regexp\.MustCompile\([^)]+\)\s*,\s*"('"${KEY_ALT}"')"' || true)
fi
if [ -n "$found" ]; then
HITS="${HITS}--- ${f} ---\n${found}\n"
fi
done
if [ -n "$HITS" ]; then
echo "::error::Task #146 lint: repo-host token name(s) quoted in a tenant-writer-surface file:"
# printf '%b' — NOT printf "$HITS". HITS always begins with
# "--- <file> ---", which bare printf parses as an option
# terminator ("printf: --: invalid option"), so under `set -e`
# the step died at exit 2 and the operator never saw WHICH file
# or line tripped the lint. The RED arm fired but named nothing.
printf '%b' "$HITS"
echo ""
echo "These files reference a tenant-writer surface (workspace_secrets,"
echo "seedAllowList, /settings/secrets, containerEnv, userData, etc.)"
echo "AND quote a repo-host token name (GITEA_TOKEN/GITHUB_TOKEN/…)."
echo "Per RFC#523 threat model, tenant workspaces MUST NOT receive"
echo "operator-scope repo-host tokens. If your code legitimately needs"
echo "to reference one of these names in a tenant-writer file (e.g."
echo "a deny-set definition or silent-strip list), add the file to"
echo "EXEMPT_FILES with a one-line justification — reviewer signoff"
echo "required."
exit 1
fi
echo "OK No tenant-writer-surface file co-mentions a repo-host token literal."