chore(security): gate plain-text internal domains out of state narrative files#615
Conversation
…ive files state.md / state_history.md are public, free-form, and written after every merge — the highest-risk place for an operator's internal hostnames to leak into a public repo. Add a scrub-at-write-time + allowlist-gate defense (no encoding — reversible encoding is not redaction): - scripts/check_internal_domains.py — pure-stdlib scanner. Flags a dotted token only when its final label is a real TLD (curated to exclude file extensions) or an internal pseudo-TLD (.internal/.corp/.local/...), unless covered by the allowlist; RFC 2606/6761 reserved names (.example/.test) never flag. Also flags RFC 1918 private-host IPs while letting universal constants through (loopback, RFC 5737 doc ranges, link-local incl. the 169.254.169.254 metadata IP, network/broadcast addresses). - scripts/allowed_public_domains.txt — allowlist of genuinely-public domains (subdomain-covering); the bar to add is "is this public info?". - Two-layer enforcement mirroring the gitleaks/env-file guards: pre-commit hook `internal-domains-guard` (primary — blocks entry into any commit object) + `internal domains guard` job in secrets-defense.yml (backstop for web-UI / --no-verify / other-machine commits; outside pr.yml's paths-ignore). - docs/04_security/internal-domain-hygiene.md — the policy (placeholders, optional gitignored secrets/domain_aliases.yaml mapping, no-encoding rule, the no-dot blind spot, post-push incident guidance). - Placeholder rule added to CLAUDE.md's state.md section + the two skills that write state.md (impl-execute, pipeline). - Scrubbed the two pre-existing domain-shaped examples in state_history.md to guard-clean forms (internal.corp -> internal.example; *.<internal-domain>). 15 unit tests pin both edges (must-flag real leak shapes; must-not-flag allowlisted/subdomain/reserved/code-identifier/placeholder). The committed state files pass the gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
There was a problem hiding this comment.
Code Review
This pull request establishes an internal-domain hygiene policy to prevent plain-text operator domains, sub-domains, and private-host IPs from leaking into public narrative files like state.md and state_history.md. It introduces a Python scanner (check_internal_domains.py), an allowlist of public domains, comprehensive unit tests, and a pre-commit hook (internal-domains-guard) mirrored in CI. Existing narrative files have also been scrubbed of internal domains. The review feedback correctly identifies broken relative links to the new security documentation in the skill markdown files, which need to be adjusted to go up three directory levels instead of two.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| - Add the feature to `## Most recent meaningful changes` with a summary paragraph (component changes, test counts, key decisions). | ||
| - Update `**Current focus:**` line to include the feature as merged. | ||
| - Update `## Current branch / execution context` to reflect the new state. | ||
| - **Never write a plain-text operator domain, sub-domain, or private-host IP** into `state.md` / `state_history.md` — these are public files. Use a human-readable placeholder (`<operator-es-cluster>`, `<corp-proxy>`, `<internal-host>`) or an RFC-reserved example name (`foo.example`). No encoding. The `internal-domains-guard` pre-commit hook + CI job will block the commit otherwise. See [`docs/04_security/internal-domain-hygiene.md`](../../docs/04_security/internal-domain-hygiene.md). |
There was a problem hiding this comment.
The relative link to internal-domain-hygiene.md is broken. Since this file is located at .claude/skills/impl-execute/SKILL.md (which is 3 levels deep from the repository root), the relative path needs to go up three levels (../../../) instead of two (../../) to correctly resolve to the root-level docs/ directory.
| - Update `pipeline_status.md` to `Implementation: Complete` | ||
| - Update `implementation_plan.md` status to `Complete (PR #<N>)` | ||
| - Update `state.md`: add to recent changes, update current focus + branch context | ||
| - Update `state.md`: add to recent changes, update current focus + branch context. **Never write a plain-text operator domain / sub-domain / private-host IP** — use a placeholder (`<operator-es-cluster>`, `<corp-proxy>`) or RFC-reserved example name; the `internal-domains-guard` hook + CI will block the commit otherwise (see `docs/04_security/internal-domain-hygiene.md`). |
There was a problem hiding this comment.
The relative link to internal-domain-hygiene.md is broken. Since this file is located at .claude/skills/pipeline/SKILL.md (which is 3 levels deep from the repository root), the relative path needs to go up three levels (../../../) instead of using a direct relative path without any leading ../ segments.
…emini) - Fix relative-link depth in the two skill files: SKILL.md files are 3 levels deep, so links to docs/ need ../../../ not ../.. (Gemini review). Also fixed a pre-existing broken data-model.md link in impl-execute SKILL.md, same class. - Remove the maintainer org apex (soundminds.ai) from the domain allowlist: it had 0 occurrences in the state files and allowlisting an apex passes ALL its subdomains, so an internal host under it (ci.<org>) would slip the gate. Keep relyloop.com (public product site, 7 real occurrences). Document this allowlisted-apex-subdomain residual in the policy doc. Gate still passes on the committed state files; 15 guard tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
Review adjudication (pre-merge)Gemini Code Assist — 2 findings, both accepted + fixed in
Self-review — one additional tightening (not from Gemini):
All doc links verified to resolve; gate passes on the committed state files; 15 guard tests green. Pre-existing red check (NOT this PR): |
Summary
Implements the write-time-scrub + allowlist-gate design for keeping operator internal domains out of the public narrative files (
state.md/state_history.md). No encoding anywhere — reversible encoding is not redaction; it hides data from your own scanners while leaving it one decode away for an attacker.The threat: these files are public, free-form, and rewritten after every merge, so they're the likeliest place an internal hostname (
es.prod.internal.example-inc.com), a customer domain, or a private-host IP leaks. Once pushed, that's effectively unrecallable (forks, dangling SHAs, archives).What's in it
scripts/check_internal_domains.py— pure-stdlib scanner. To avoid drowning in dotted code identifiers (asyncio.gather,vi.mock), it flags a dotted token only when the final label is a real TLD (curated to exclude file extensions) or an internal pseudo-TLD (.internal/.corp/.local/…), unless the domain is allowlisted. RFC 2606/6761 reserved names (.example/.test/.invalid/.localhost) never flag — use them for fictional hosts. Also flags RFC 1918 private-host IPs, while letting universal constants through (loopback, RFC 5737 doc ranges, link-local incl. the169.254.169.254metadata IP that SSRF narratives need to name, and.0/.255network/broadcast addresses).scripts/allowed_public_domains.txt— allowlist of genuinely-public domains (an entry covers its subdomains). The bar to add: "is this public information?".env-filename guards):internal-domains-guard— primary; blocks the literal from ever entering a commit object.internal domains guardjob insecrets-defense.yml— backstop for commits made via the GitHub web UI,--no-verify, or another machine. Lives insecrets-defense.yml(notpr.yml) sopaths-ignorecan't skip it.docs/04_security/internal-domain-hygiene.md— the policy: placeholders (<operator-es-cluster>), the optional gitignoredsecrets/domain_aliases.yamlalias mapping, the no-encoding rule, the no-dot blind spot, and post-push incident guidance.state.mdsection and the two skills that writestate.md(impl-execute,pipeline).state_history.md(both were illustrative, not real infra):internal.corp→internal.example,*.soundminds.internal→*.<internal-domain>.Design decision: scrub, don't encode
Per the discussion that prompted this: encoding (base64/hex/hash/encrypt) fails the "never exposed" requirement — the data is still there, and it blinds gitleaks/this scanner too. The right model is scrub-to-placeholder at write time, keep the real-name mapping in a gitignored file, enforce with an allowlist gate. This PR is exactly that.
Known blind spot (documented, by design)
A bare hostname with no dot (
es-prod-01) is indistinguishable from a word and can't be pattern-detected. That's what the write-time placeholder discipline (CLAUDE.md + skills) is for — the gate is the backstop, not the whole defense.Testing
15 unit tests pin both edges (must-flag: internal FQDN / customer domain / subdomain /
.corp; must-not-flag: allowlisted / subdomain-of-allowlisted / suffix-not-substring / reserved / dotted-code-identifier / angle-bracket placeholder; IP classification incl. universal-constant exemptions). A regression test asserts the committedstate.md+state_history.mdstay clean. Fullbackend/tests/unit/scripts/suite green (314); all pre-commit hooks pass.Note on already-pushed content
The two scrubbed examples remain in past commit objects (history), but they're illustrative not real infrastructure, so no history surgery is warranted. If a genuinely-sensitive operator domain is ever found already-pushed, the policy doc's incident section covers it.
🤖 Generated with Claude Code