A commit-time secret guard that fails the build on a credential-shaped string, wired into the repo's local gate and the Gitea Actions pipeline. The 2026-09-17 purge removed six live credentials that had sat here for weeks (several in .md prose); nothing blocked that class of commit, so a warning in a stream nobody reads was the only signal. This makes it exit 1.
Backlog row: commit-time-secret-guard-20260917 (captain approved 2026-09-22: "definitely add the ci check that blocks committed credentials now").
Shape
File
Role
scripts/secret-scan.sh
guard: --tree (default), --path DIR, --staged, --diff REF; exit 1 on finding, 2 on config error
scripts/secret-patterns.tsv
checked-in pattern list (sk-, sk-or-v1-, sk_live_, literal Bearer, PVEAPIToken=, raw Authorization, PEM blocks, prose credentials: lines, password/api_key/secret/token assignments carrying a literal value)
scripts/secret-allowlist.tsv
exceptions, one entry per deliberate synthetic example, each with a reason; a missing reason is a hard error (fail closed)
tests/test_secret_scan.sh
20 cases, incl. the --staged commit-time path
scripts/prose-lint.sh
the local gate now runs the scan
.gitea/workflows/pr-pipeline.yaml
explicit Committed-credential scan step + self-test in the lint job
Design notes:
Prose is scanned exactly like code — cred-prose matches - Admin credentials: `admin` / `<value>` , which a PASSWORD=-only rule would miss. That is exactly where the original exposures were.
Nothing is trained to ignore the word "synthetic". The 2026-09-17 purge's «vault: <project>/<env> <SECRET>» markers and the sk-synthetic-* examples are listed as explicit, reasoned allowlist entries rather than filtered by a general "vault"/"synthetic" rule. A new occurrence needs a new reviewed entry.
A scan never echoes a credential. Findings print only the text before the match, then <redacted> — so a value the regex stopped short of (e.g. a backticked password after credentials:) is still never written to the log.
Portable to the runner. The Gitea runner executes job steps inside the runner container (Alpine / BusyBox grep, no node/python). The guard uses only bash + grep/sed/awk + git; verified in-container: bash 5.3.3, BusyBox grep -EIi/\b/«…», awk match()/RSTART, mapfile -d, nocasematch, ${var/pat/repl} all work.
CI filename note
The brief said .gitea/workflows/ci.yml; this repo has no ci.yml — the only workflow is .gitea/workflows/pr-pipeline.yaml. The guard went into the lint job there (a required status context, and the gate job needs it), so a finding fails the merge gate. No duplicate workflow was created.
Acceptance evidence
1a. The guard FAILS on planted pattern-matching secrets (self-test)
$ bash tests/test_secret_scan.sh
── secret-scan self-test ──
✅ scanner parses with bash -n (exit 0)
✅ planted sk-or-v1 key fails the guard (exit 1)
✅ planted sk-or-v1 key names the openrouter-key rule
✅ planted literal Bearer token fails the guard (exit 1)
✅ planted Bearer token names the bearer-token rule
✅ planted Proxmox token fails the guard (exit 1)
✅ planted Proxmox token names the proxmox-token rule
✅ planted PEM private key fails the guard (exit 1)
✅ planted PEM key names the private-key rule
✅ planted prose credential line fails the guard (exit 1)
✅ planted prose line names the cred-prose rule
✅ planted password assignment fails the guard (exit 1)
✅ planted password assignment names the secret-assign rule
✅ env refs, sentinels and variable names are not credentials (exit 0)
✅ current repo tree passes the guard (exit 0)
✅ tree run reports the allowlisted exceptions it applied
✅ allowlisted text at an unlisted path still fails (exit 1)
✅ staged credential fails at commit time (--staged) (exit 1)
✅ staged credential names the openrouter-key rule
✅ allowlist entry with no reason fails closed (exit 2)
✅ secret-scan self-test passed (20 cases)
exit=0
1b. The test BITES on the pre-fix revision
8245716^ is the commit before the 2026-09-17 purge, i.e. the revision where the live credentials still lived. The guard fails on it (22 findings), naming the real credential shapes — the OpenRouter keys, the Mumuni LiteLLM/Zulip keys, the Proxmox token, and the Stirling credentials. Output is verbatim, including the masking:
# Guard run against the PRE-FIX revision: 8245716^ = the commit before the
# 2026-09-17 purge (live credentials still in the tree).
$ bash scripts/secret-scan.sh --path $PRE_FIX_TREE
── secret scan (path): 76 files under /tmp/tmp.k0QYcqPj0R ──
❌ stirling-pdf-agent-access.prose.md:47 [secret-assign] credential assignment carrying a literal value
| -H "X-<redacted>
❌ scripts/daily-infra-report.py:19 [proxmox-token] Proxmox API token literal
| AUTH = "Authorization: <redacted>
❌ scripts/daily-infra-report.py:19 [auth-header] Authorization header carrying a raw literal value
| AUTH = "<redacted>
❌ litellm-api-keys.prose.md:147 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| MUMUNI_LITELLM_API_KEY=<redacted>
❌ litellm-api-keys.prose.md:147 [secret-assign] credential assignment carrying a literal value
| MUMUNI_LITELLM_<redacted>
❌ litellm-api-keys.prose.md:148 [secret-assign] credential assignment carrying a literal value
| MUMUNI_ZULIP_<redacted>
❌ infrastructure-control.prose.md:639 [proxmox-token] Proxmox API token literal
| AUTH="Authorization: <redacted>
❌ infrastructure-control.prose.md:639 [auth-header] Authorization header carrying a raw literal value
| AUTH="<redacted>
❌ hermes-key-enforcement.prose.md:104 [secret-assign] credential assignment carrying a literal value
| <redacted>
❌ hermes-key-enforcement.prose.md:112 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| api_key: <redacted>
❌ hermes-key-enforcement.prose.md:112 [secret-assign] credential assignment carrying a literal value
| <redacted>
❌ hermes-key-enforcement.prose.md:185 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| grep -rn 'LITELLM_API_KEY=<redacted>
❌ hermes-key-enforcement.prose.md:185 [secret-assign] credential assignment carrying a literal value
| grep -rn 'LITELLM_<redacted>
❌ agent-zero-openrouter-key.prose.md:93 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| | **Full Key** | `«redacted:<redacted>
❌ agent-zero-openrouter-key.prose.md:93 [openrouter-key] OpenRouter API key
| | **Full Key** | `«redacted:<redacted>
❌ agent-zero-openrouter-key.prose.md:104 [openrouter-key] OpenRouter API key
| | 2026-09-01 | fix-401 | Old key `<redacted>
❌ agent-zero-fix-summary.md:19 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| **Old Key**: `<redacted>
❌ agent-zero-fix-summary.md:19 [openrouter-key] OpenRouter API key
| **Old Key**: `<redacted>
❌ agent-zero-fix-summary.md:20 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| **New Key**: `<redacted>
❌ agent-zero-fix-summary.md:20 [openrouter-key] OpenRouter API key
| **New Key**: `<redacted>
❌ agent-zero-fix-summary.md:51 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| sed -i "s|^API_KEY_OPENROUTER=.*|API_KEY_OPENROUTER=<redacted>
❌ agent-zero-fix-summary.md:51 [openrouter-key] OpenRouter API key
| sed -i "s|^API_KEY_OPENROUTER=.*|API_KEY_OPENROUTER=<redacted>
❌ SECRET SCAN FAILED — 22 credential-shaped string(s) in path content.
Fix: remove the credential and read it from the vault/env.
Only a deliberate synthetic example may be added to scripts/secret-allowlist.tsv,
one entry per file/rule/literal, with a reason. Never allowlist a live credential.
exit=1
2. The guard stays QUIET on the allowlisted synthetic examples
The self-test also proves the exception is path-explicit, not word-based: the exact - Admin credentials: `«vault: ...»` line that is allowlisted in infrastructure-control.prose.mdfails when copied to an unlisted path.
3. bash -n / linter clean, current tree passes
(both in the block above) and the local gate:
$ bash scripts/prose-lint.sh # the local gate, now including the scan
Cross-contract: 10 total warnings across all checks
── 4. Secret scan (committed credentials) ──
── secret scan (tree): 85 files under /root/.treehouse/prose-contracts-9ce5f3/3/prose-contracts ──
✅ secret scan clean (tree; 36 allowlisted exception(s), 22 inert value(s) ignored)
✅ No committed credentials
═══════════════════════════════════
✅ LINT PASSED (10 warning(s))
exit=0
Runner verification
runner-ct110 (act_runner, GITEA_RUNNER_LABELS=ubuntu-latest,ubuntu-22.04) runs job steps inside the runner container itself. Inspected live: the container has no python/node-based dependency need, and does have bash 5.3.3 plus BusyBox grep/awk with every feature the guard uses (-EIi, \b, «…» alternation, awk match()/RSTART, mapfile -d, nocasematch). Same tools the existing lint job already uses.
Residual items for the contract owner
Two literals are allowlisted because they are already-documented history, but they are worth a proper redaction by whoever owns those contracts:
litellm-api-keys.prose.md:276 — a truncated real Zulip key prefix (9 characters, printed as … here deliberately); not usable at 9 chars, but ideally replaced with a «vault: ...» reference.
litellm-self-heal.prose.md:119 — sk-syslog-local-master-key, documented as deprecated/no-live-usage.
Scope / authorization
Scoped to the guard, its patterns/allowlist, its tests, the local-gate call, the CI step, and one AGENTS.md pointer. scripts/prose-lint.sh is in prose-auth-check.sh's restricted map (authorized abiba) — the auth job currently no-ops in CI (GITEA_ACTOR unset; confirmed in run 438's auth log: "Could not determine PR author … Skipping auth check"). Flagging in case Abiba wants to review the restricted-file touch.
## What
A commit-time secret guard that **fails** the build on a credential-shaped string, wired into the repo's local gate and the Gitea Actions pipeline. The 2026-09-17 purge removed six live credentials that had sat here for weeks (several in `.md` prose); nothing blocked that class of commit, so a warning in a stream nobody reads was the only signal. This makes it exit 1.
Backlog row: `commit-time-secret-guard-20260917` (captain approved 2026-09-22: *"definitely add the ci check that blocks committed credentials now"*).
## Shape
| File | Role |
|------|------|
| `scripts/secret-scan.sh` | guard: `--tree` (default), `--path DIR`, `--staged`, `--diff REF`; exit 1 on finding, 2 on config error |
| `scripts/secret-patterns.tsv` | checked-in pattern list (`sk-`, `sk-or-v1-`, `sk_live_`, literal `Bearer`, `PVEAPIToken=`, raw `Authorization`, PEM blocks, prose `credentials:` lines, `password`/`api_key`/`secret`/`token` assignments carrying a literal value) |
| `scripts/secret-allowlist.tsv` | exceptions, **one entry per deliberate synthetic example, each with a reason**; a missing reason is a hard error (fail closed) |
| `tests/test_secret_scan.sh` | 20 cases, incl. the `--staged` commit-time path |
| `scripts/prose-lint.sh` | the local gate now runs the scan |
| `.gitea/workflows/pr-pipeline.yaml` | explicit `Committed-credential scan` step + self-test in the `lint` job |
Design notes:
* **Prose is scanned exactly like code** — `cred-prose` matches ``- Admin credentials: `admin` / `<value>` ``, which a `PASSWORD=`-only rule would miss. That is exactly where the original exposures were.
* **Nothing is trained to ignore the word "synthetic".** The 2026-09-17 purge's ``«vault: <project>/<env> <SECRET>»`` markers and the `sk-synthetic-*` examples are listed as explicit, reasoned allowlist entries rather than filtered by a general "vault"/"synthetic" rule. A new occurrence needs a new reviewed entry.
* **A scan never echoes a credential.** Findings print only the text *before* the match, then `<redacted>` — so a value the regex stopped short of (e.g. a backticked password after `credentials:`) is still never written to the log.
* **Portable to the runner.** The Gitea runner executes job steps inside the runner container (Alpine / BusyBox grep, no node/python). The guard uses only bash + `grep`/`sed`/`awk` + `git`; verified in-container: bash 5.3.3, BusyBox grep `-EIi`/`\b`/`«…»`, `awk match()/RSTART`, `mapfile -d`, `nocasematch`, `${var/pat/repl}` all work.
### CI filename note
The brief said `.gitea/workflows/ci.yml`; this repo has no `ci.yml` — the only workflow is `.gitea/workflows/pr-pipeline.yaml`. The guard went into the `lint` job there (a required status context, and the `gate` job `needs` it), so a finding fails the merge gate. No duplicate workflow was created.
## Acceptance evidence
### 1a. The guard FAILS on planted pattern-matching secrets (self-test)
```text
$ bash tests/test_secret_scan.sh
── secret-scan self-test ──
✅ scanner parses with bash -n (exit 0)
✅ planted sk-or-v1 key fails the guard (exit 1)
✅ planted sk-or-v1 key names the openrouter-key rule
✅ planted literal Bearer token fails the guard (exit 1)
✅ planted Bearer token names the bearer-token rule
✅ planted Proxmox token fails the guard (exit 1)
✅ planted Proxmox token names the proxmox-token rule
✅ planted PEM private key fails the guard (exit 1)
✅ planted PEM key names the private-key rule
✅ planted prose credential line fails the guard (exit 1)
✅ planted prose line names the cred-prose rule
✅ planted password assignment fails the guard (exit 1)
✅ planted password assignment names the secret-assign rule
✅ env refs, sentinels and variable names are not credentials (exit 0)
✅ current repo tree passes the guard (exit 0)
✅ tree run reports the allowlisted exceptions it applied
✅ allowlisted text at an unlisted path still fails (exit 1)
✅ staged credential fails at commit time (--staged) (exit 1)
✅ staged credential names the openrouter-key rule
✅ allowlist entry with no reason fails closed (exit 2)
✅ secret-scan self-test passed (20 cases)
exit=0
```
### 1b. The test BITES on the pre-fix revision
`8245716^` is the commit **before** the 2026-09-17 purge, i.e. the revision where the live credentials still lived. The guard fails on it (22 findings), naming the real credential shapes — the OpenRouter keys, the Mumuni LiteLLM/Zulip keys, the Proxmox token, and the Stirling credentials. Output is verbatim, including the masking:
```text
# Guard run against the PRE-FIX revision: 8245716^ = the commit before the
# 2026-09-17 purge (live credentials still in the tree).
$ bash scripts/secret-scan.sh --path $PRE_FIX_TREE
── secret scan (path): 76 files under /tmp/tmp.k0QYcqPj0R ──
❌ stirling-pdf-agent-access.prose.md:47 [secret-assign] credential assignment carrying a literal value
| -H "X-<redacted>
❌ scripts/daily-infra-report.py:19 [proxmox-token] Proxmox API token literal
| AUTH = "Authorization: <redacted>
❌ scripts/daily-infra-report.py:19 [auth-header] Authorization header carrying a raw literal value
| AUTH = "<redacted>
❌ litellm-api-keys.prose.md:147 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| MUMUNI_LITELLM_API_KEY=<redacted>
❌ litellm-api-keys.prose.md:147 [secret-assign] credential assignment carrying a literal value
| MUMUNI_LITELLM_<redacted>
❌ litellm-api-keys.prose.md:148 [secret-assign] credential assignment carrying a literal value
| MUMUNI_ZULIP_<redacted>
❌ infrastructure-control.prose.md:639 [proxmox-token] Proxmox API token literal
| AUTH="Authorization: <redacted>
❌ infrastructure-control.prose.md:639 [auth-header] Authorization header carrying a raw literal value
| AUTH="<redacted>
❌ hermes-key-enforcement.prose.md:104 [secret-assign] credential assignment carrying a literal value
| <redacted>
❌ hermes-key-enforcement.prose.md:112 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| api_key: <redacted>
❌ hermes-key-enforcement.prose.md:112 [secret-assign] credential assignment carrying a literal value
| <redacted>
❌ hermes-key-enforcement.prose.md:185 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| grep -rn 'LITELLM_API_KEY=<redacted>
❌ hermes-key-enforcement.prose.md:185 [secret-assign] credential assignment carrying a literal value
| grep -rn 'LITELLM_<redacted>
❌ agent-zero-openrouter-key.prose.md:93 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| | **Full Key** | `«redacted:<redacted>
❌ agent-zero-openrouter-key.prose.md:93 [openrouter-key] OpenRouter API key
| | **Full Key** | `«redacted:<redacted>
❌ agent-zero-openrouter-key.prose.md:104 [openrouter-key] OpenRouter API key
| | 2026-09-01 | fix-401 | Old key `<redacted>
❌ agent-zero-fix-summary.md:19 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| **Old Key**: `<redacted>
❌ agent-zero-fix-summary.md:19 [openrouter-key] OpenRouter API key
| **Old Key**: `<redacted>
❌ agent-zero-fix-summary.md:20 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| **New Key**: `<redacted>
❌ agent-zero-fix-summary.md:20 [openrouter-key] OpenRouter API key
| **New Key**: `<redacted>
❌ agent-zero-fix-summary.md:51 [openai-key] OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
| sed -i "s|^API_KEY_OPENROUTER=.*|API_KEY_OPENROUTER=<redacted>
❌ agent-zero-fix-summary.md:51 [openrouter-key] OpenRouter API key
| sed -i "s|^API_KEY_OPENROUTER=.*|API_KEY_OPENROUTER=<redacted>
❌ SECRET SCAN FAILED — 22 credential-shaped string(s) in path content.
Fix: remove the credential and read it from the vault/env.
Only a deliberate synthetic example may be added to scripts/secret-allowlist.tsv,
one entry per file/rule/literal, with a reason. Never allowlist a live credential.
exit=1
```
### 2. The guard stays QUIET on the allowlisted synthetic examples
```text
$ bash -n scripts/secret-scan.sh scripts/prose-lint.sh tests/test_secret_scan.sh
(clean)
$ shellcheck -S warning scripts/secret-scan.sh tests/test_secret_scan.sh scripts/prose-lint.sh
(clean)
$ bash scripts/secret-scan.sh
── secret scan (tree): 85 files under /root/.treehouse/prose-contracts-9ce5f3/3/prose-contracts ──
✅ secret scan clean (tree; 36 allowlisted exception(s), 22 inert value(s) ignored)
exit=0
```
The self-test also proves the exception is **path-explicit, not word-based**: the exact ``- Admin credentials: `«vault: ...»` `` line that is allowlisted in `infrastructure-control.prose.md` **fails** when copied to an unlisted path.
### 3. `bash -n` / linter clean, current tree passes
(both in the block above) and the local gate:
```text
$ bash scripts/prose-lint.sh # the local gate, now including the scan
Cross-contract: 10 total warnings across all checks
── 4. Secret scan (committed credentials) ──
── secret scan (tree): 85 files under /root/.treehouse/prose-contracts-9ce5f3/3/prose-contracts ──
✅ secret scan clean (tree; 36 allowlisted exception(s), 22 inert value(s) ignored)
✅ No committed credentials
═══════════════════════════════════
✅ LINT PASSED (10 warning(s))
exit=0
```
## Runner verification
`runner-ct110` (act_runner, `GITEA_RUNNER_LABELS=ubuntu-latest,ubuntu-22.04`) runs job steps inside the runner container itself. Inspected live: the container has no python/node-based dependency need, and does have bash 5.3.3 plus BusyBox grep/awk with every feature the guard uses (`-EIi`, `\b`, `«…»` alternation, `awk match()/RSTART`, `mapfile -d`, `nocasematch`). Same tools the existing `lint` job already uses.
## Residual items for the contract owner
Two literals are allowlisted because they are already-documented history, but they are worth a proper redaction by whoever owns those contracts:
* `litellm-api-keys.prose.md:276` — a truncated **real** Zulip key prefix (9 characters, printed as `…` here deliberately); not usable at 9 chars, but ideally replaced with a `` «vault: ...» `` reference.
* `litellm-self-heal.prose.md:119` — `sk-syslog-local-master-key`, documented as deprecated/no-live-usage.
## Scope / authorization
Scoped to the guard, its patterns/allowlist, its tests, the local-gate call, the CI step, and one AGENTS.md pointer. `scripts/prose-lint.sh` is in `prose-auth-check.sh`'s restricted map (authorized `abiba`) — the auth job currently no-ops in CI (`GITEA_ACTOR` unset; confirmed in run 438's auth log: *"Could not determine PR author … Skipping auth check"*). Flagging in case Abiba wants to review the restricted-file touch.
The 2026-09-17 purge removed six live credentials that had sat in this repo
for weeks, several in .md prose. Nothing blocked that class of commit, so a
warning in a stream nobody reads was the only signal. This adds a guard that
fails the build instead of warning.
Guard
- scripts/secret-scan.sh: bash + coreutils + grep/sed/awk + git only (the Gitea
Actions runner executes job steps inside the runner container — BusyBox grep,
no node/python). Modes: --tree (git-tracked, default), --path DIR (no git),
--staged (pre-commit), --diff REF. Exit 1 on a finding, 2 on config error.
- scripts/secret-patterns.tsv: checked-in pattern list — sk-, sk-or-v1-,
sk_live_, literal Bearer tokens, PVEAPIToken=, raw Authorization values, PEM
private-key blocks, prose credential lines, and password/api_key/secret/token
assignments carrying a literal value. Prose is scanned exactly like code.
- scripts/secret-allowlist.tsv: one entry per deliberate synthetic example, each
with a reason. A missing reason is a hard error (fail closed). The 2026-09-17
purge's `«vault: ...»` placeholders are listed explicitly rather than filtered
by a general "vault"/"synthetic" rule, so a new occurrence still needs a
reviewed, reasoned entry.
- A small inert-value classifier drops env refs, paths, dotted code access,
variable names and right-truncated redactions; it does not know the words
"synthetic"/"example", so a fabrication is always an explicit exception.
- Findings are printed with the credential masked; a scan never echoes a full
secret into the log.
Wiring
- .gitea/workflows/pr-pipeline.yaml lint job: explicit "Committed-credential
scan" step plus the self-test. A finding fails the required
`pr-pipeline / lint` context, which the merge gate depends on.
- scripts/prose-lint.sh (the local gate): a "Secret scan" section, so
`bash scripts/prose-lint.sh` before pushing is equivalent to CI.
Tests
- tests/test_secret_scan.sh: 20 cases. Plants pattern-matching fixtures in temp
trees (outside every allowlisted path) and asserts the guard FAILS, including
the --staged commit-time path; asserts the tree is quiet; asserts allowlisted
text at an unlisted path still fails (path-explicit, not word-based); asserts
a reasonless allowlist entry exits 2.
Verified: guard run against 8245716^ (the pre-fix revision, before the purge)
fails on the real OpenRouter/LiteLLM/Zulip/Proxmox/Stirling credentials; guard
run over the current tree is clean.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
A commit-time secret guard that fails the build on a credential-shaped string, wired into the repo's local gate and the Gitea Actions pipeline. The 2026-09-17 purge removed six live credentials that had sat here for weeks (several in
.mdprose); nothing blocked that class of commit, so a warning in a stream nobody reads was the only signal. This makes it exit 1.Backlog row:
commit-time-secret-guard-20260917(captain approved 2026-09-22: "definitely add the ci check that blocks committed credentials now").Shape
scripts/secret-scan.sh--tree(default),--path DIR,--staged,--diff REF; exit 1 on finding, 2 on config errorscripts/secret-patterns.tsvsk-,sk-or-v1-,sk_live_, literalBearer,PVEAPIToken=, rawAuthorization, PEM blocks, prosecredentials:lines,password/api_key/secret/tokenassignments carrying a literal value)scripts/secret-allowlist.tsvtests/test_secret_scan.sh--stagedcommit-time pathscripts/prose-lint.sh.gitea/workflows/pr-pipeline.yamlCommitted-credential scanstep + self-test in thelintjobDesign notes:
cred-prosematches- Admin credentials: `admin` / `<value>`, which aPASSWORD=-only rule would miss. That is exactly where the original exposures were.«vault: <project>/<env> <SECRET>»markers and thesk-synthetic-*examples are listed as explicit, reasoned allowlist entries rather than filtered by a general "vault"/"synthetic" rule. A new occurrence needs a new reviewed entry.<redacted>— so a value the regex stopped short of (e.g. a backticked password aftercredentials:) is still never written to the log.grep/sed/awk+git; verified in-container: bash 5.3.3, BusyBox grep-EIi/\b/«…»,awk match()/RSTART,mapfile -d,nocasematch,${var/pat/repl}all work.CI filename note
The brief said
.gitea/workflows/ci.yml; this repo has noci.yml— the only workflow is.gitea/workflows/pr-pipeline.yaml. The guard went into thelintjob there (a required status context, and thegatejobneedsit), so a finding fails the merge gate. No duplicate workflow was created.Acceptance evidence
1a. The guard FAILS on planted pattern-matching secrets (self-test)
1b. The test BITES on the pre-fix revision
8245716^is the commit before the 2026-09-17 purge, i.e. the revision where the live credentials still lived. The guard fails on it (22 findings), naming the real credential shapes — the OpenRouter keys, the Mumuni LiteLLM/Zulip keys, the Proxmox token, and the Stirling credentials. Output is verbatim, including the masking:2. The guard stays QUIET on the allowlisted synthetic examples
The self-test also proves the exception is path-explicit, not word-based: the exact
- Admin credentials: `«vault: ...»`line that is allowlisted ininfrastructure-control.prose.mdfails when copied to an unlisted path.3.
bash -n/ linter clean, current tree passes(both in the block above) and the local gate:
Runner verification
runner-ct110(act_runner,GITEA_RUNNER_LABELS=ubuntu-latest,ubuntu-22.04) runs job steps inside the runner container itself. Inspected live: the container has no python/node-based dependency need, and does have bash 5.3.3 plus BusyBox grep/awk with every feature the guard uses (-EIi,\b,«…»alternation,awk match()/RSTART,mapfile -d,nocasematch). Same tools the existinglintjob already uses.Residual items for the contract owner
Two literals are allowlisted because they are already-documented history, but they are worth a proper redaction by whoever owns those contracts:
litellm-api-keys.prose.md:276— a truncated real Zulip key prefix (9 characters, printed as…here deliberately); not usable at 9 chars, but ideally replaced with a«vault: ...»reference.litellm-self-heal.prose.md:119—sk-syslog-local-master-key, documented as deprecated/no-live-usage.Scope / authorization
Scoped to the guard, its patterns/allowlist, its tests, the local-gate call, the CI step, and one AGENTS.md pointer.
scripts/prose-lint.shis inprose-auth-check.sh's restricted map (authorizedabiba) — the auth job currently no-ops in CI (GITEA_ACTORunset; confirmed in run 438's auth log: "Could not determine PR author … Skipping auth check"). Flagging in case Abiba wants to review the restricted-file touch.