The seed runtime's v1.0.0 freezes its exported Python surface and explicitly not its detection behaviour. The manifest pinned commit and version per measurement block but never recorded that the thing pinned is a version rather than a frozen classification. manifest 0.5.2 -> 0.6.0, one new field next to active_content_provenance. asymmetry, bounded by what the fixtures actually assert: all seven carry pattern_id only, so three of the four calibration dimensions cannot move them. Both pins named, not one. Their statement is attributed, not restated as ours. Also closes the omission 0.7.3 named: the same misquote in docs/secret-egress-divergence.md:75-76. Not the fix the note implied - those lines are one single-backtick span across a line break, so the outer delimiter is promoted to double backticks instead. spec section 7 deliberately untouched and named in CHANGELOG. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yZobrgUiRtpLSWx8i7u2Z
18 KiB
Secret-egress divergence — commons vs the Python guard
Status: informative. Nothing here is normative and nothing here changes a data file. It records a measured disagreement between two tables that were believed to be two cuts of one source, and turns out not to be. Under this repository's behaviour-preservation invariant, a divergence found here is reported, not fixed.
Produced 2026-08-13. Every number below came from a command; the scripts live in the session
scratchpad rather than in this repository, because executable code here would breach the
charter. They are reproducible from the method column. The guard was read via
git archive v0.7.0, never from its working copy.
The premise this document was opened to test does not survive it. The open question was recorded as "19 entries here against the guard's 25, cut at different granularity" — one table, two granularities. That is not what the two files are. They are ports of two different source tables in the same source repository, and the granularity difference is a consequence of that, not the cause. Everything below follows from correcting that premise.
What was compared
| Side | Artefact | Version / coordinate |
|---|---|---|
| commons | signatures/secret-egress.json |
file version 0.3.0, 19 entries |
| guard | llm-ingestion-pipeline-security src/llm_ingestion_guard/output.py _SECRET_PATTERNS |
tag v0.7.0 = commit be9759b, 25 entries |
| seed A | llm-security hooks/scripts/pre-edit-secrets.mjs SECRET_PATTERNS |
commit 47905da, 19 entries — what commons ported |
| seed B | llm-security knowledge/secrets-patterns.md |
commit 47905da, blob a7ed469, 33 entries — what the guard ported |
The guard's v0.7.0 is the guard's current behaviour. git diff v0.7.0..aff3511 -- src/
is empty, where aff3511 was the guard's head when this was measured. Pinning at the tag
therefore costs no currency; it is not a waypoint measurement.
Seed B was read, not accepted. The guard's module docstring asserts "Ported from the
llm-security knowledge/secrets-patterns.md seed". That assertion is a claim about a
third repository and would be an attribution, not a finding, if it were relayed. It was
measured instead: the file exists at the pinned commit on the public remote, and all 25 of
the guard's ids appear in it verbatim — 0 guard ids are absent from seed B. The docstring
is correct.
Both seeds are named in commons' own file. signatures/secret-egress.json's $comment
already says which of the two it took and that the other "is a separate PCRE-flavoured
agent-consumed variant that stays where it is". What was not known until now is that the
guard ported the other one.
Result
| Measure | Method | Result |
|---|---|---|
| Entry count, both sides | count entries | 19 and 25 |
| Seed B entry count | parse the .md at the pinned blob |
33 |
| Guard ids present in seed B | set membership on id |
25/25 |
| Seed B ids the guard did not port | set difference | 8 |
| Guard vs seed B, field-identical | compare regex (after stripping seed B's (?i) inline rendering), flags and severity |
16/25 |
| — of the 9 remaining, escaping-only | unescape the guard's \" (a Python raw-string artefact) and compare for string identity |
4/4 identical |
| — of the 9 remaining, behaviourally real | differential match comparison | 5 — 4 connection strings, 1 capture-group change |
| commons vs guard, byte-identical patterns | unescape both sides' \/ and \", compare source + flags |
2/19 |
| Differential probe corpus | one witness per guard id, plus each side's exclusive shapes and the semantics witness | 36 probes |
| Shapes commons reports and the guard is silent on | differential | 7 witnesses, across 5 commons entries |
| Shapes the guard reports and commons is silent on | differential | 3 |
| Match-semantics divergence | one witness matching two entries on both sides | 1 label vs 2 labels |
16 field-identical + 4 escaping-only + 5 real = 25.
The 2/19 is the number that says these are not two cuts of one table. Only
GitHub Fine-Grained PAT ↔ github-pat-fine-grained and OpenAI Legacy API Key ↔
openai-api-key-legacy are byte-identical after unescaping. Even AWS Access Key ID is not:
commons has AKIA[0-9A-Z]{16} and the guard has the same run anchored, \bAKIA[0-9A-Z]{16}\b.
The earlier note calling that pair the one clean 1:1 was wrong, and was wrong by transcription
rather than by measurement.
Match semantics: the divergence that is not about membership
This is the finding a membership table would hide, and it is the one a consumer implementing from commons will get wrong first.
signatures/secret-egress.json declares match_semantics: "first match wins; patterns are evaluated in ascending `order`", marks ordering.normative: true, and names
last_entry_is_load_bearing: "JWT (three-part token)" — the JWT entry is placed last
precisely so a token inside an Authorization header is reported as the header, not as a
bare JWT.
The guard's scan_secret_egress runs finditer over all 25 patterns and adds a finding for
every match. Order carries no semantics there, and there is no first-match-wins layer.
Measured on one witness — an Authorization header whose value is a three-part JWT:
| Side | Finding set |
|---|---|
| commons, under its own declared contract | Authorization header with token — one label |
guard, scan_secret_egress at v0.7.0 |
egress:bearer-token, egress:jwt-token — two labels |
Both detect the credential. They disagree about what a report says, which is what a
conformance/expected.json encodes. Two runtimes that both "pass" here would still produce
different fixture files.
The commons side of this was not hand-rewritten: the evaluator compiles the patterns out of
the JSON, in order, applying re.I exactly where the file's own dialect.translation_notes
say to, and stops at the first hit. The guard side is the imported module. Neither table was
transcribed.
Membership, measured
Every row below comes from running a witness input through both sides, not from reading the two regexes side by side.
commons order / name |
guard ids observed | Relation |
|---|---|---|
0 AWS Access Key ID |
aws-access-key-id |
1:1, guard anchored |
1 AWS Secret Access Key |
— | guard silent |
2 Azure Connection String (AccountKey/SharedAccessKey/sig) |
azure-storage-key |
overlap; see below |
3 Azure AD ClientSecret |
azure-client-secret |
1:1 |
4 Azure AI Services Key |
— | guard silent |
5 GitHub Token |
github-pat-classic, github-oauth-token, github-server-token |
1:3, plus 2 prefixes neither guard id covers |
6 npm Token |
npm-token |
1:1 |
7 Anthropic API Key |
anthropic-api-key |
1:1 |
8 OpenAI Project Key |
openai-project-key |
1:1 |
9 GitHub Fine-Grained PAT |
github-pat-fine-grained |
1:1, byte-identical |
10 Google API Key |
gcp-api-key |
1:1 |
11 Private Key PEM Block |
rsa-private-key, ec-private-key, pkcs8-private-key |
1:3, minus one PEM label |
12 JWT Secret |
— | guard silent |
13 Slack/Discord Webhook URL |
— | guard silent |
14 Generic credential assignment |
generic-api-key, config-password, config-secret |
1:3 |
15 Authorization header with token |
bearer-token (+ jwt-token, see semantics) |
1:1 |
16 Database connection string |
postgres-connstr, mysql-connstr, redis-connstr |
1:3, minus the MongoDB SRV form |
17 OpenAI Legacy API Key |
openai-api-key-legacy |
1:1, byte-identical |
18 JWT (three-part token) |
jwt-token |
1:1 |
Guard ids with no commons entry firing on their own witness: gcp-service-account-json,
mongodb-connstr — and ec-private-key on the ENCRYPTED header.
Azure Connection String is listed as overlap rather than 1:1 deliberately. Commons'
entry is (?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,} — three alternatives, no
length pin. The guard's azure-storage-key is AccountKey=([A-Za-z0-9+/]{86}==) — one
alternative, exact length. The corpus witnessed only the AccountKey shape, where both fire.
SharedAccessKey= and sig= were not witnessed; seed B carries them under separate ids
(azure-servicebus-connstr, azure-sas-token) that the guard did not port. Read this row as
"one witnessed overlap", not as a coverage claim.
What each side misses that the other catches
Commons reports, guard silent — 7 witnesses across 5 commons entries:
| Witness shape | commons entry | Why the guard is silent |
|---|---|---|
ghu_ prefixed token |
GitHub Token |
guard ported ghp/gho/ghs; no id for ghu |
ghr_ prefixed token |
GitHub Token |
same |
aws_secret_access_key = <40 chars> |
AWS Secret Access Key |
seed B has aws-secret-access-key; guard did not port it |
Ocp-Apim-Subscription-Key assignment |
Azure AI Services Key |
absent from seed B entirely |
JWT_SECRET assignment |
JWT Secret |
absent from seed B entirely |
| Slack webhook URL | Slack/Discord Webhook URL |
absent from seed B entirely |
| Discord webhook URL | Slack/Discord Webhook URL |
same |
One row is one witness, so two commons entries appear twice: GitHub Token covers five
prefixes behind one name, and Slack/Discord Webhook URL covers two hosts. Counting rows
rather than entries would overstate how much of commons the guard is missing, and counting
entries rather than rows would hide that GitHub Token is only partly uncovered — its
ghp/gho/ghs prefixes map onto three guard ids just fine.
Three of the seven are the sharper finding: the Ocp-Apim-Subscription-Key, JWT_SECRET and
webhook shapes are not entries the guard declined to port, they are entries seed B does not
have. Seed A carries three shapes seed B never did.
Guard reports, commons silent — 3 shapes:
| Shape | guard id | Why commons is silent |
|---|---|---|
"type": "service_account" |
gcp-service-account-json |
seed A has no GCP service-account marker |
-{5}BEGIN ENCRYPTED PRIVATE KEY-{5} |
ec-private-key |
commons' PEM alternation is (?:RSA | EC | DSA | OPENSSH )?; ENCRYPTED is not in it |
mongodb+srv://user:pw@host |
mongodb-connstr |
commons' scheme run is (?:postgres|mysql|mongodb|redis):// — the +srv suffix breaks the literal |
The mongodb+srv miss is worth naming precisely: commons is not missing MongoDB, it is
missing the SRV form, which is the form Atlas hands out. Plain mongodb:// is caught.
This asymmetry is not a scoreboard. Each side is faithful to its own seed. Every shape in the left table is present in seed A and absent from seed B; every shape in the right table is the reverse. Neither port is wrong about its source. The seeds disagree.
False-positive suppression: a layer commons has no field for
The guard applies value-based suppression to the five entries that capture a value
(_is_fp_value): structural placeholders (your-, <, >, ***), word-boundary
placeholder words (example, changeme, todo, …), variable references (${, $(,
os.environ, process.env, …), all-same-character values, and values under 8 characters.
Measured:
| Witness | commons (first match) | guard |
|---|---|---|
password: 'your-password-here' |
Generic credential assignment |
— suppressed |
api_key: '${MY_API_KEY_VALUE}' |
Generic credential assignment |
— suppressed |
Commons has no field that could carry this. dialect.translation_notes warns in prose that
the generic entries are "shape matches, not proofs of a live credential" and assigns the
trade-off to the consumer's policy — which is a correct statement of ownership and is also
why two consumers reading commons will produce different reports on the same placeholder.
Seed B carries the suppression semantics per entry in a false_positive_notes field; seed A
carries name and pattern only, so commons had nothing to extract. This is a gap in the seed,
not an omission in the extraction.
The connection-string bound
The guard bounds the password run in all four connection-string patterns at
MAX_CONNSTR_VALUE = 256, and its module explains why in full: an unbounded run in front of
a required literal makes every start position rescan the tail when the literal never arrives.
They measured 8.2 s at 100 000 characters on crafted redis://: input and extrapolated to
hours at their own 1 000 000-character cap. Seed B's connection-string patterns are unbounded;
this is one of the 5 real guard-vs-seed-B drifts, and it is a deliberate, documented one.
Commons' Database connection string is (?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+
— shape-analogous to what the guard bounded. Measured against the exact boundary:
| Password length | commons | guard |
|---|---|---|
| 12 | matches | egress:postgres-connstr |
| 256 | matches | egress:postgres-connstr |
| 257 | matches | — |
| 300 | matches | — |
Read this as two facts, not one verdict. Commons has recall the guard traded away above 256
characters. Commons also carries the runtime shape the guard's measurement was about — and
carries it in an unanchored form ([^\s]+@[^\s]+ rather than the guard's
[^:@\s]+:…@[^\s'"]+), so the two are not the same pattern under load and no timing claim
about commons is made here. Nothing is changed on that basis. The entry is faithful to
seed A, the file that owns it is llm-security's, and the behaviour-preservation invariant
puts the decision there. It is reported, and the guard's measurement is cited so the owner
does not have to redo it.
Severity and ids: what commons does not carry
Seed B carries id and severity per entry; the guard preserved both, and all 25 severities
are field-identical to the seed. Seed A carries neither, so commons carries neither, and its
evidence_limits says so explicitly: "No severity, and no per-entry disposition, was
supplied … so neither is invented here."
That restraint was right and it has a consequence: commons has no id space for this table.
Its entries are keyed by human-readable name ("GitHub Token"), while the guard emits
egress:<id> labels. A conformance/expected.json scoped to secret egress cannot be written
against commons today, because a fixture names labels and commons has none to name.
The 25 guard ids are not guard-internal labels. They are seed B's ids, adopted verbatim,
which was measured above (25/25 present in the seed). That makes the id space question a
question for llm-security first — they own both seeds and the id space in one of them — and
for the guard second. Per this repository's naming rule, no id is proposed here. The rule
that carrier:* established applies exactly: naming an id in a shared space is the exception,
it requires both runtimes asked first, and publishing aliases.<runtime> is irreversible at
file granularity.
What this does not show
- It does not show that either table is wrong. Both are faithful ports. The disagreement
is between seed A and seed B, inside
llm-security, and only that repository can say whether two tables is intentional (one engine-consumed, one agent-consumed) or whether one supersedes the other. - It does not measure seed A's current state. Commons' fidelity to seed A was verified at
commit
47905daand this document adds nothing to that. (Seed B was read at the same commit, which is a shared coordinate and not the commit the guard ported from. That was going to be a caveat — a seed-B entry that moved between the guard's port and47905dawould show up here as guard drift. It is dissolved by measurement instead:git log -- knowledge/secrets-patterns.mdin a deepened mirror returns exactly one commit at or before47905da,f153f96, dated 2026-04-08, and the guard'soutput.pywas first committed 2026-07-04. The seed had been still for three months when the port was written and has not moved since. Reading it at47905dareads what the guard ported from, so the 5 real drifts are guard-side by measurement rather than by inference.) - It does not compare coverage. The probe corpus has one witness per guard id plus each
side's exclusive shapes — 36 inputs. It is built to expose membership and semantics, not to
estimate recall.
SharedAccessKey=andsig=Azure shapes, and seed B's 8 unported ids, have no witness here. - It does not measure the runtimes' entry points. Both sides were driven at table level:
commons through an evaluator compiled from its own JSON under its own declared contract, the
guard through
scan_secret_egressdirectly. Whatscan_outputcomposes around it — decode-and-rescan re-labelling findings asdecoded:egress:*, the oversize cap — is not in scope and would change the finding sets. - It does not touch
manifest.json.scope_planned.blockersstill names this divergence as the blocker for egress cases. Whether this document dissolves that blocker or merely describes it is a separate decision, and it depends on answers this document does not have.
Consequence for conformance/
An egress case is not mintable today, and the reason has changed. It was recorded as "the two tables are cut at different granularity". The measured reasons are three, and they are independent:
- No id space on the commons side. A fixture names labels. Commons has names, not ids. This is the hard blocker and it is the subject of the outgoing question to both runtimes.
- Match semantics disagree. Even with an id space, the Bearer-plus-JWT witness produces a one-label expectation under commons' declared contract and a two-label one from the guard. A fixture would have to encode one of them.
- Membership disagrees in both directions, and the disagreement is inherited from two different seeds rather than from a porting error — so it cannot be closed by re-measuring either port.
None of the three is dissolved by a measurement this repository can run alone. Per
conformance/manifest.json → entry_points_by_scope, a new scope also needs an entry point,
a findings accessor and a fixture presentation for every runtime before its first case, and
those three slots are empty for egress on both runtimes. That requirement stands independently
of everything above.