From b6d2301a31d66b541f9ce8a180f0eb4f79c1eedc Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:03:09 +0800 Subject: [PATCH 01/12] fix(public-safety): split credential words from credential values by tier Refs #5136, directions 2 and 4. The four text owners rejected every mention of the bearer, password and secret words, so an internal-state field carrying "the Bearer token expired" failed while a raw GitHub token in the same field passed. Both verdicts came from one 11-pattern rule. The owner now categorizes the bare words separately from the shapes that carry a value, and the four owners select a policy that recognizes the words without rejecting them. Migrating them onto the classifier adds the value-shape detectors they never had, and the TypeScript owner gains the two in-policy shape arms ported from the same pattern sources so one corpus yields one verdict. Ordinary links are excluded from the internal-state policy rather than newly rejected: the rule these owners enforced never decided a URL, and that is per-face work still open in #5136. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- docs/public-private-boundary.md | 23 +++++ loopx/authority.py | 7 +- loopx/boundary_authority.py | 8 +- .../control_plane/goals/vision_checkpoint.ts | 40 +++++++-- loopx/feedback.py | 8 +- loopx/public_safe_text.py | 88 ++++++++++++++++--- 6 files changed, 148 insertions(+), 26 deletions(-) diff --git a/docs/public-private-boundary.md b/docs/public-private-boundary.md index ae9e00bc38..92478c4d31 100644 --- a/docs/public-private-boundary.md +++ b/docs/public-private-boundary.md @@ -125,6 +125,29 @@ authorization" stays public-safe. Both runtimes are pinned to the shared corpus in `tests/fixtures/public_safe_text_corpus.json`; extend that corpus rather than adding a per-file exception. +Detection and permission are separate. The owner names a category for every +recognized shape, and a surface picks the policy (the category set) its +destination needs: + +- Repository publication -- a PR-time scan of development code -- uses the full + set (`ALL_CATEGORIES`). A bare credential word is rejected there. +- LoopX's own operational state -- `feedback`, `authority`, + `boundary_authority`, and the Vision checkpoint -- uses + `TEXT_OWNER_CATEGORIES`, which recognizes a bare credential word without + rejecting it. "the Bearer token expired" describes a credential; it does not + carry one. + +The value arms stay in every policy, so the narrower tier releases prose only: an +assignment (`password=`, `secret:`, `token:`) or a scheme followed by a value +(`Bearer <8+ characters>`) is rejected by both tiers. A raw credential token with +no label at all -- a GitHub token, a private key block -- is rejected by both +tiers too, which the word-only rule never caught. + +A URL is in neither tier's internal-state policy yet. The rule these four owners +enforced before never rejected an ordinary link, so this split does not start to; +whether an internal-state field may carry one is decided per face, together with +the remaining caller migration in #5136. + ### Compact Artifacts Safe compact artifact: diff --git a/loopx/authority.py b/loopx/authority.py index 05850011a5..a8275a6531 100644 --- a/loopx/authority.py +++ b/loopx/authority.py @@ -15,7 +15,8 @@ from .control_plane.runtime.time import now_local_iso from .public_safe_text import ( PRIVATE_TEXT_PATTERNS as SHARED_PRIVATE_TEXT_PATTERNS, - find_private_text_match, + TEXT_OWNER_CATEGORIES, + classify_private_text, ) @@ -98,7 +99,9 @@ def public_safe_optional(label: str, value: str | None) -> str | None: def validate_public_safe_text(label: str, value: str | None) -> None: - if find_private_text_match(value) is not None: + # Internal-state policy: a credential *word* is recognized but not rejected + # here; shapes that carry a value still are (Refs #5136, direction 2). + if classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) is not None: raise ValueError(f"{label} contains a private-looking value; keep raw evidence in private payloads") diff --git a/loopx/boundary_authority.py b/loopx/boundary_authority.py index ed510fa480..ff75c67342 100644 --- a/loopx/boundary_authority.py +++ b/loopx/boundary_authority.py @@ -7,7 +7,8 @@ from .control_plane.todos.contract import normalize_required_write_scopes from .public_safe_text import ( PRIVATE_TEXT_PATTERNS as SHARED_PRIVATE_TEXT_PATTERNS, - find_private_text_match, + TEXT_OWNER_CATEGORIES, + classify_private_text, ) @@ -24,7 +25,10 @@ def _now() -> datetime: def _validate_public_safe_text(label: str, value: str | None) -> None: - if find_private_text_match(value) is not None: + # Checkpointed lease text is LoopX's own state, so it uses the internal-state + # policy: the words bearer/password/secret are recognized, not rejected, while + # every shape that carries a value still fails (Refs #5136, direction 2). + if classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) is not None: raise ValueError(f"{label} contains a private-looking value; keep raw evidence in private payloads") diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index 43c1a60427..f5b3db4c07 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -110,22 +110,48 @@ const GOAL_VISION_STATE_ALIASES: Readonly> = { // corpus in tests/fixtures/public_safe_text_corpus.json: ordinary governance // prose such as "needs owner authorization" must pass, while the header, // assignment, and quoted-JSON key credential shapes must be rejected. +// Refs #5136, direction 2 adds the second tier: a bare credential word is +// recognized but no longer rejected by this owner, because a checkpoint reason +// that says the bearer token expired describes a credential rather than carrying +// one. The value and assignment arms below are what keep a real credential out. +const BEARER_VALUE_MIN_LENGTH = 8; const AUTHORIZATION_CREDENTIAL_SHAPE = /\bAuthorization["']?\s*[:=]/i; const BASIC_CREDENTIAL_VALUE = /[Bb]asic\s+(?=[A-Za-z0-9+/=]*[a-z])(?=[A-Za-z0-9+/=]*[A-Z])[A-Za-z0-9+/=]{16,}/; -const PRIVATE_TEXT_PATTERNS = [ +const BEARER_VALUE_SHAPE = new RegExp( + String.raw`\bBearer\s+[A-Za-z0-9._~+/=-]{${BEARER_VALUE_MIN_LENGTH},}`, + "i", +); +const LABELED_CREDENTIAL_ASSIGNMENT = /\b(?:token|password|secret)\s*[:=]/i; +// Ported from the Python owner's two in-policy shape detectors, so one corpus +// yields one verdict in both runtimes (Refs #5136, direction 4). The third +// Python detector -- a raw remote location -- is deliberately not ported: it is +// outside the internal-state policy, and the per-face URL decision is the +// caller-migration work still open in #5136. +const SECRET_LIKE_SHAPE = new RegExp("(?:\\bbearer\\s+[a-z0-9._~+/=-]{16,}|\\b(?:access|api|secret)[_-]?key[\\\"']?\\s*[=:]\\s*[\\\"']?[^\\s`'\\\"<>]+|\\b(?:ak|sk)[\\\"']?\\s*[=:]\\s*[\\\"']?[^\\s`'\\\"<>]+|(?]{12,}|\\b(?:password|secret)[\\\"']?\\s*[=:]\\s*[\\\"']?[^\\s`'\\\"<>]{12,}|-{3,}\\s*BEGIN (?:[A-Z]+ )?PRIVATE KEY|\\btoken[\\\"']?\\s*[=:]\\s*[\\\"']?[^\\s`'\\\"<>]{12,})", "i"); +const LOCAL_PATH_SHAPE = new RegExp("(?]+|[A-Za-z]:[\\\\/][^\\s`'\\\"<>]+|\\\\\\\\[A-Za-z0-9_.-]+\\\\[^\\s`'\\\"<>]+)", "i"); +export const CREDENTIAL_WORD_PATTERNS: RegExp[] = [/\bBearer\b/i, /\bpassword\b/i, /\bsecret\b/i]; +export const PRIVATE_TEXT_PATTERNS: RegExp[] = [ /\/Users\//, /\/ext_data\//, /lark[o]ffice/i, // Equivalent matcher avoids matching its own policy source. /docs\.internal/i, /\bt-20\d{12}-[a-z0-9]+\b/, - /\bBearer\b/i, + BEARER_VALUE_SHAPE, AUTHORIZATION_CREDENTIAL_SHAPE, BASIC_CREDENTIAL_VALUE, - /\btoken\s*=/i, - /\bpassword\b/i, - /\bsecret\b/i, -] as const; + LABELED_CREDENTIAL_ASSIGNMENT, + ...CREDENTIAL_WORD_PATTERNS, +]; +// This owner validates LoopX's own state, so it rejects every arm except the +// words. The full PRIVATE_TEXT_PATTERNS list stays the publication-tier mirror of +// the Python owner's ALL_CATEGORIES, and the corpus test asserts the two lists +// differ by exactly these three arms so the tiers cannot drift apart silently. +export const INTERNAL_STATE_SHAPE_PATTERNS: RegExp[] = [SECRET_LIKE_SHAPE, LOCAL_PATH_SHAPE]; +export const INTERNAL_STATE_PRIVATE_TEXT_PATTERNS: RegExp[] = [ + ...PRIVATE_TEXT_PATTERNS.filter((pattern) => !CREDENTIAL_WORD_PATTERNS.includes(pattern)), + ...INTERNAL_STATE_SHAPE_PATTERNS, +]; interface VisionRefreshPrepareRequest { phase: "prepare"; @@ -260,7 +286,7 @@ function publicSafeTextGuidance(label: string): string { } function validatePublicSafeText(label: string, value: string): void { - for (const pattern of PRIVATE_TEXT_PATTERNS) { + for (const pattern of INTERNAL_STATE_PRIVATE_TEXT_PATTERNS) { if (pattern.test(value)) { throw new EffectRuntimeRequestError( `${label} contains a private-looking value; ${publicSafeTextGuidance(label)}`, diff --git a/loopx/feedback.py b/loopx/feedback.py index 3b3900fa0c..3d0dd69464 100644 --- a/loopx/feedback.py +++ b/loopx/feedback.py @@ -17,7 +17,8 @@ from .paths import resolve_runtime_root from .public_safe_text import ( PRIVATE_TEXT_PATTERNS as SHARED_PRIVATE_TEXT_PATTERNS, - find_private_text_match, + TEXT_OWNER_CATEGORIES, + classify_private_text, ) from .registry import registry_goals, resolve_state_file from .control_plane.actor_identity import normalize_owner_controller_actor @@ -123,7 +124,10 @@ def now_local() -> str: def validate_public_safe_text(label: str, value: str | None) -> None: - if find_private_text_match(value) is not None: + # The named policy, not a bare pattern sweep: this is LoopX's own state, so a + # mention of "Bearer"/"password"/"secret" is a fact about a credential, not + # one. Value and assignment shapes stay rejected (Refs #5136, direction 2). + if classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) is not None: raise ValueError( f"{label} contains a private-looking value; " + public_safe_text_guidance(label) diff --git a/loopx/public_safe_text.py b/loopx/public_safe_text.py index db3fbbf998..4693e87a8e 100644 --- a/loopx/public_safe_text.py +++ b/loopx/public_safe_text.py @@ -20,6 +20,13 @@ `tests/fixtures/public_safe_text_corpus.json` pins both runtimes to one contract. +The four owners validate LoopX's own state, so they share one policy, +`TEXT_OWNER_CATEGORIES`, which recognizes a bare credential word without +rejecting it. Repository-publication surfaces keep the full set +(`ALL_CATEGORIES`, reached through `find_private_text_match`), because a PR-time +scan of development code is held to the stricter bar the maintainer asked for in +#5136: internal state may be looser, publication may not. + `control_plane/runtime/public_safety.py` also consumes the shape definitions here (`SECRET_LIKE_SURFACE_PATTERN`, `LOCAL_PATH_SURFACE_PATTERN`, `REMOTE_LOCATION_SURFACE_PATTERN`) instead of owning a competing set, so a @@ -41,6 +48,7 @@ # separate decisions (Refs #5136, direction 2). # --------------------------------------------------------------------------- CATEGORY_CREDENTIAL = "credential" +CATEGORY_CREDENTIAL_WORD = "credential_word" CATEGORY_LOCAL_PATH = "local_path" CATEGORY_REMOTE_LOCATION = "remote_location" CATEGORY_ORG_MARKER = "org_marker" @@ -48,12 +56,23 @@ ALL_CATEGORIES: frozenset[str] = frozenset( { CATEGORY_CREDENTIAL, + CATEGORY_CREDENTIAL_WORD, CATEGORY_LOCAL_PATH, CATEGORY_REMOTE_LOCATION, CATEGORY_ORG_MARKER, } ) +# Refs #5136, direction 2: a *mention* of a credential word is not a credential. +# ``credential_word`` exists so a surface can recognize these words without +# rejecting them; the value and assignment forms stay in ``credential`` so +# narrowing one policy can never let ``password=hunter2`` out with the prose. +# The floor below is explicit and corpus-pinned on both sides: the shared +# shape detectors used to reject an ``Authorization: Bearer <12 chars>`` header +# only because their value minimum happened to be met, and a bare-word arm was +# the accidental backstop for the short values. +BEARER_VALUE_MIN_LENGTH = 8 + # Credential shape, not the plain English word. LoopX governance prose says # "owner authorization" constantly, so the trigger is the header/assignment @@ -75,6 +94,24 @@ r"[A-Za-z0-9+/=]{16,}", ) +# ``Bearer `` with a value long enough to be a token rather than the next +# English word. Kept separate from the word arm so a surface can stop rejecting +# the scheme name while still rejecting the scheme plus a value. +BEARER_VALUE_SHAPE_PATTERN = re.compile( + r"\b" + "Bear" + r"er\s+[A-Za-z0-9._~+/=-]{%d,}" % BEARER_VALUE_MIN_LENGTH, + re.I, +) + +# An assignment form of the three demoted words. It carries no value-length floor +# on purpose: ``password=``/``secret=``/``token=`` is a credential statement +# whatever follows it, and the previous behavior already rejected every one of +# them (and every bare mention), so this arm alone cannot tighten a surface that +# has kept ``credential_word`` out of its policy. +LABELED_CREDENTIAL_ASSIGNMENT_PATTERN = re.compile( + r"\b(?:" + "tok" + r"en|pass" + r"word|sec" + r"ret)\s*[:=]", + re.I, +) + # Refs #5136: relocated here from control_plane/runtime/public_safety.py so a # single owner defines each shape. public_safety re-exports these names, so its # ~8 direct importers and 30+ recursive-validation callers are unchanged. This @@ -183,9 +220,9 @@ class _CategorizedPattern: "internal ticket identifier", ), _CategorizedPattern( - re.compile(r"\b" + "Bear" + r"er\b", re.I), + BEARER_VALUE_SHAPE_PATTERN, CATEGORY_CREDENTIAL, - "bearer auth scheme word", + "bearer scheme carrying a value", ), _CategorizedPattern( _AUTHORIZATION_CREDENTIAL_SHAPE, @@ -196,18 +233,26 @@ class _CategorizedPattern: _BASIC_CREDENTIAL_VALUE, CATEGORY_CREDENTIAL, "basic-auth credential value" ), _CategorizedPattern( - re.compile(r"\b" + "tok" + r"en\s*=", re.I), + LABELED_CREDENTIAL_ASSIGNMENT_PATTERN, CATEGORY_CREDENTIAL, - "token assignment shape", + "credential-word assignment shape", + ), + # The three word arms are the false-positive set direction 2 asked to move + # out of the rejection rule. They stay recognized -- under their own category + # -- so a surface that wants the older, stricter verdict opts back in by name. + _CategorizedPattern( + re.compile(r"\b" + "Bear" + r"er\b", re.I), + CATEGORY_CREDENTIAL_WORD, + "bearer auth scheme word", ), _CategorizedPattern( re.compile(r"\b" + "pass" + r"word\b", re.I), - CATEGORY_CREDENTIAL, + CATEGORY_CREDENTIAL_WORD, "password word", ), _CategorizedPattern( re.compile(r"\b" + "sec" + r"ret\b", re.I), - CATEGORY_CREDENTIAL, + CATEGORY_CREDENTIAL_WORD, "secret word", ), ) @@ -224,19 +269,36 @@ class _CategorizedPattern: # these named (rather than inline regex ORs at each caller) is what lets one # detection owner serve surfaces with different disclosure boundaries. # --------------------------------------------------------------------------- -# The four text owners reject every recognized category (their historical -# behavior): credential shapes, local paths, remote locations, org markers. -TEXT_OWNER_CATEGORIES: frozenset[str] = ALL_CATEGORIES +# The four text owners validate LoopX's own operational state, and the +# maintainer set their strictness below the repository-publication surface: +# "the Bearer token expired" or "read the secret from the environment" describes +# a fact, it does not carry one (Refs #5136, direction 2). Two categories stay +# out of their policy. `credential_word` is the loosening direction 2 asked for. +# `remote_location` is not a loosening but a non-change: the word-only rule these +# owners enforced never rejected an ordinary URL, and deciding per face whether +# an internal-state field may carry one is the remaining caller-migration work in +# #5136, not a verdict this PR is authorized to add. +# The migration onto the classifier is still a net tightening where direction 2 +# asked for one: these owners now reject credential *values* that arrive with no +# label at all -- a raw GitHub token, a Slack token, a private key block -- which +# the word arms never covered, plus every local-path root rather than only +# `/Users/`. +TEXT_OWNER_CATEGORIES: frozenset[str] = ALL_CATEGORIES - { + CATEGORY_CREDENTIAL_WORD, + CATEGORY_REMOTE_LOCATION, +} # artifact_lifecycle historically OR-ed find_private_text_match (the text-owner # set) with SECRET_LIKE_SURFACE_PATTERN, downstream of a validate_public_safe_value # call that already rejected the local-path and credential shapes. That union # covers every category *except* a raw remote location: this projection has # always let an ordinary http(s) URL through. The policy preserves that exactly # rather than silently tightening it; widening it to remote_location is a -# separate, disclosed decision (Refs #5136, direction 2). -ARTIFACT_LIFECYCLE_CATEGORIES: frozenset[str] = frozenset( - {CATEGORY_CREDENTIAL, CATEGORY_LOCAL_PATH, CATEGORY_ORG_MARKER} -) +# separate, disclosed decision (Refs #5136, direction 2). It keeps +# ``credential_word`` on purpose: this surface never asked to be loosened, and a +# new category must not widen an existing named policy by absence. +ARTIFACT_LIFECYCLE_CATEGORIES: frozenset[str] = ALL_CATEGORIES - { + CATEGORY_REMOTE_LOCATION +} @dataclass(frozen=True) From fc4bc1d9aa00e36273a80629ba800316987c8596 Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:03:10 +0800 Subject: [PATCH 02/12] test(public-safety): pin both tiers and the accepted verdict delta The shared corpus gains an internal-state bucket for the prose the four owners now accept, samples for the credential values they newly reject, and a public URL that stays out of the decision. The classifier test freezes the previous 11-pattern rule and asserts the tier difference as two named lists, and the released class is enumerated over prefix x word x separator x value rather than sampled, so a form that slips between the arms fails. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- .../test_public_safe_text_classifier.py | 297 +++++++++++++++--- .../test_public_safe_text_owner_parity.py | 62 +++- .../public_safe_text_corpus.test.ts | 70 ++++- tests/fixtures/public_safe_text_corpus.json | 70 ++++- 4 files changed, 449 insertions(+), 50 deletions(-) diff --git a/tests/control_plane/test_public_safe_text_classifier.py b/tests/control_plane/test_public_safe_text_classifier.py index 5ec50bbbe6..2b43919734 100644 --- a/tests/control_plane/test_public_safe_text_classifier.py +++ b/tests/control_plane/test_public_safe_text_classifier.py @@ -1,20 +1,20 @@ -"""Refs #5136 direction 1: the consolidated text-classification owner. +"""Refs #5136 directions 1, 2 and 4: the text-classification owner and its tiers. -`loopx/public_safe_text.py` became the single home for "does this string look -private?". These tests pin the part the relocation added on top of the existing -corpus parity contract: +`loopx/public_safe_text.py` is the single home for "does this string look +private?". These tests pin what the owner decides: * detection returns an explicit *category* and a stable *reason*, not a bare regex object; * a named *policy* (a set of categories) decides what a given surface rejects, so recognizing a value never implies every surface must reject it; +* direction 2 splits that into two tiers: the four internal-state text owners + stop rejecting a bare credential *word*, while the publication tier + (``ALL_CATEGORIES``, reached through ``find_private_text_match``) keeps doing + so -- every one of those words still has a value- or assignment-shaped arm that + no tier accepts; * the direction-3 path-gap recognition (``~/`` and ``path:``-prefixed local references) is opt-in, so this consolidation does not silently tighten any surface that has not chosen it; -* the relocation is behavior-preserving: ``find_private_text_match`` and the - ``PRIVATE_TEXT_PATTERNS`` tuple are unchanged, and ``classify_private_text``'s - first text-pattern match is the *identical* pattern object the legacy helper - returns; * `ml_experiment`'s leading-``/``-or-``~`` rule stays a distinct, stricter per-field *alias* constraint that the shared classifier does not subsume. """ @@ -37,7 +37,9 @@ from loopx.public_safe_text import ( ALL_CATEGORIES, ARTIFACT_LIFECYCLE_CATEGORIES, + BEARER_VALUE_MIN_LENGTH, CATEGORY_CREDENTIAL, + CATEGORY_CREDENTIAL_WORD, CATEGORY_LOCAL_PATH, CATEGORY_ORG_MARKER, CATEGORY_REMOTE_LOCATION, @@ -46,6 +48,10 @@ REMOTE_LOCATION_SURFACE_PATTERN as OWNER_REMOTE_LOCATION, SECRET_LIKE_SURFACE_PATTERN as OWNER_SECRET_LIKE, TEXT_OWNER_CATEGORIES, + _AUTHORIZATION_CREDENTIAL_SHAPE, + _BASIC_CREDENTIAL_VALUE, + BEARER_VALUE_SHAPE_PATTERN, + LABELED_CREDENTIAL_ASSIGNMENT_PATTERN, _CATEGORIZED_PRIVATE_TEXT_PATTERNS, classify_private_text, find_private_text_match, @@ -66,10 +72,23 @@ _ORG_MARKER_PATH = "/".join(["", "ext_data", "run", "x"]) -def test_categories_are_the_four_named_decisions() -> None: +def _publication_rejects(value: str) -> bool: + """Recognized by the full category set, the repository-publication tier.""" + + return classify_private_text(value, categories=ALL_CATEGORIES) is not None + + +def _internal_rejects(value: str) -> bool: + """Recognized inside the policy the four internal-state text owners use.""" + + return classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) is not None + + +def test_categories_are_the_five_named_decisions() -> None: assert ALL_CATEGORIES == frozenset( { CATEGORY_CREDENTIAL, + CATEGORY_CREDENTIAL_WORD, CATEGORY_LOCAL_PATH, CATEGORY_REMOTE_LOCATION, CATEGORY_ORG_MARKER, @@ -77,21 +96,88 @@ def test_categories_are_the_four_named_decisions() -> None: ) -def test_text_owner_policy_rejects_every_recognized_category() -> None: - # The four text owners historically rejected every shape; their policy is - # the full set so the relocation changes no owner verdict. - assert TEXT_OWNER_CATEGORIES == ALL_CATEGORIES +def test_text_owner_policy_drops_the_words_and_leaves_urls_undecided() -> None: + # Direction 2 authorizes exactly one loosening: a credential *word*. The + # second exclusion is a non-change -- the rule these owners enforced before + # never rejected an ordinary URL either, and the per-face URL decision is the + # caller migration still open in #5136. Everything the word arms used to stand + # in for (an assignment, a scheme carrying a value) stays in policy. + assert ALL_CATEGORIES - TEXT_OWNER_CATEGORIES == frozenset( + {CATEGORY_CREDENTIAL_WORD, CATEGORY_REMOTE_LOCATION} + ) + assert CATEGORY_CREDENTIAL in TEXT_OWNER_CATEGORIES + assert CATEGORY_LOCAL_PATH in TEXT_OWNER_CATEGORIES + assert CATEGORY_ORG_MARKER in TEXT_OWNER_CATEGORIES + # Pinned so a later consolidation cannot quietly fold URLs into this tier. + assert _publication_rejects("https://example.com/a") + assert not _internal_rejects("https://example.com/a") + + +@pytest.mark.parametrize( + "value,category", + [ + ("the Bearer token expired", CATEGORY_CREDENTIAL_WORD), + ("the password is stored in the vault", CATEGORY_CREDENTIAL_WORD), + ("read the secret from the environment", CATEGORY_CREDENTIAL_WORD), + ("Bearer abc123def456", CATEGORY_CREDENTIAL), + ("password=hunter2", CATEGORY_CREDENTIAL), + ("secret: env", CATEGORY_CREDENTIAL), + ("token: abc123", CATEGORY_CREDENTIAL), + ], +) +def test_every_demoted_word_keeps_a_value_or_assignment_arm_in_policy( + value: str, category: str +) -> None: + # The tier split is only safe if no value-bearing form moves with the words. + # Each case is recognized, and the category names whether the internal-state + # owners let it out: a word does, the same word carrying a value does not. + match = classify_private_text(value, categories=ALL_CATEGORIES) + assert match is not None, value + assert match.category == category, value + released_to_prose = category == CATEGORY_CREDENTIAL_WORD + assert _internal_rejects(value) is not released_to_prose, value + + +def test_bearer_value_floor_is_pinned_on_both_sides() -> None: + # The floor is a named constant, so the boundary is a decision with a test + # rather than a regex artifact. One character below it is a word mention; at + # it and above it the scheme carries a value every tier rejects. + below = "Bearer " + "a" * (BEARER_VALUE_MIN_LENGTH - 1) + at = "Bearer " + "a" * BEARER_VALUE_MIN_LENGTH + above = "Bearer " + "a" * (BEARER_VALUE_MIN_LENGTH + 12) + below_match = classify_private_text(below, categories=ALL_CATEGORIES) + assert below_match is not None + assert below_match.category == CATEGORY_CREDENTIAL_WORD + assert classify_private_text(below, categories=TEXT_OWNER_CATEGORIES) is None + for value in (at, above): + strict = classify_private_text(value, categories=ALL_CATEGORIES) + assert strict is not None + assert strict.category == CATEGORY_CREDENTIAL + assert _internal_rejects(value) + + +def test_migrating_the_owners_onto_the_classifier_closes_a_shape_hole() -> None: + # This is a net tightening, and it is the other half of direction 2: the old + # text-owner rule had no arm for a credential value that arrives without an + # `Authorization:` label, so a raw GitHub token passed while the sentence + # "the secret is in the vault" failed. + private_key = "----" + "BEGIN RSA PRIVATE KEY-----" + other_roots = "/".join(["", "home", "dev", "x.json"]) + drive = "".join(["C:", "/", "Operators", "/state.json"]) + for value in (_GITHUB_TOKEN, private_key, other_roots, drive): + assert find_private_text_match(value) is None, value + assert _internal_rejects(value), value def test_artifact_lifecycle_policy_excludes_only_remote_location() -> None: # artifact_lifecycle has always let an ordinary http(s) URL through, so its # policy is every category *except* a raw remote location. Widening it is a - # separate, disclosed decision (Refs #5136 direction 2), not part of the - # behavior-preserving relocation. - assert ARTIFACT_LIFECYCLE_CATEGORIES == frozenset( - {CATEGORY_CREDENTIAL, CATEGORY_LOCAL_PATH, CATEGORY_ORG_MARKER} - ) + # separate, disclosed decision (Refs #5136 direction 2), not part of this + # change -- and it keeps the new word category on purpose, because this + # surface never asked to be loosened. + assert ARTIFACT_LIFECYCLE_CATEGORIES == ALL_CATEGORIES - {CATEGORY_REMOTE_LOCATION} assert CATEGORY_REMOTE_LOCATION not in ARTIFACT_LIFECYCLE_CATEGORIES + assert CATEGORY_CREDENTIAL_WORD in ARTIFACT_LIFECYCLE_CATEGORIES @pytest.mark.parametrize( @@ -161,9 +247,10 @@ def test_a_policy_narrows_the_text_owner_patterns_not_only_the_shapes() -> None: # and an empty policy must recognize nothing at all. bearer = "the Bearer token expired" ext_data = _ORG_MARKER_PATH - assert classify_private_text(bearer).category == CATEGORY_CREDENTIAL + assert classify_private_text(bearer).category == CATEGORY_CREDENTIAL_WORD assert classify_private_text(ext_data).category == CATEGORY_ORG_MARKER assert classify_private_text(bearer, categories=frozenset({CATEGORY_LOCAL_PATH})) is None + assert classify_private_text(bearer, categories=TEXT_OWNER_CATEGORIES) is None assert classify_private_text(ext_data, categories=frozenset({CATEGORY_CREDENTIAL})) is None assert classify_private_text(bearer, categories=frozenset()) is None assert classify_private_text(ext_data, categories=frozenset()) is None @@ -187,13 +274,36 @@ def test_path_gap_recognition_does_not_subsume_the_alias_rule() -> None: assert classify_private_text("~username/notes", include_path_gaps=True) is None -def test_classify_first_text_match_is_identical_to_find_private_text_match() -> None: - # Behavior-preserving pin over the real shared corpus: whenever the legacy - # helper returns a pattern, the classifier's first match is that same object - # (text patterns are checked first, in the pinned order). The classifier may - # additionally flag shape-only values the legacy helper never saw. +# The rule `find_private_text_match` applied on main, frozen here so the tier +# split is measured against what the repository actually did before this change +# rather than against the module's own post-change tuple. Same patterns, same +# order; the two credential shapes are the owner's own unchanged objects. +_LEGACY_BEARER_WORD = re.compile(r"\b" + "Bear" + r"er\b", re.I) +_LEGACY_TOKEN_ASSIGNMENT = re.compile(r"\b" + "tok" + r"en\s*=", re.I) +_LEGACY_PASSWORD_WORD = re.compile(r"\b" + "pass" + r"word\b", re.I) +_LEGACY_SECRET_WORD = re.compile(r"\b" + "sec" + r"ret\b", re.I) +_LEGACY_RULE: tuple[re.Pattern[str], ...] = ( + re.compile(r"/" + r"Users/"), + re.compile(r"/" + r"ext_data/"), + re.compile("la" + "rk" + "office", re.I), + re.compile("docs" + r"\." + "internal", re.I), + re.compile(r"\bt-20\d{12}-[a-z0-9]+\b"), + _LEGACY_BEARER_WORD, + _AUTHORIZATION_CREDENTIAL_SHAPE, + _BASIC_CREDENTIAL_VALUE, + _LEGACY_TOKEN_ASSIGNMENT, + _LEGACY_PASSWORD_WORD, + _LEGACY_SECRET_WORD, +) + + +def _legacy_rejects(value: str) -> bool: + return any(pattern.search(value) for pattern in _LEGACY_RULE) + + +def _corpus_samples() -> list[tuple[str, str, str]]: corpus = json.loads(CORPUS_PATH.read_text(encoding="utf-8")) - assert corpus["schema_version"] == "public_safe_text_corpus_v0" + assert corpus["schema_version"] == "public_safe_text_corpus_v1" tokens = corpus["tokens"] def render(template: str) -> str: @@ -207,28 +317,135 @@ def replace(match: re.Match[str]) -> str: return _PLACEHOLDER_RE.sub(replace, template) - checked = 0 - for group in ("public_safe", "private_looking"): - for sample in corpus[group]: - value = render(sample["template"]) - checked += 1 - legacy = find_private_text_match(value) - classified = classify_private_text(value, categories=ALL_CATEGORIES) - if legacy is None: - continue - assert classified is not None, sample["id"] - assert classified.pattern is legacy, sample["id"] - assert checked > 0 + return [ + (group, sample["id"], render(sample["template"])) + for group in ("public_safe", "private_looking", "internal_state_prose") + for sample in corpus[group] + ] + + +def test_both_tiers_differ_from_the_old_rule_only_where_this_change_says() -> None: + # The differential the maintainer asked for (Refs #5136, direction 4), stated + # as two named lists instead of prose. Anything the old rule rejected stays + # rejected by the publication tier; the only values the internal-state tier + # newly releases are the five prose samples, and the only value the + # publication tier newly rejects is the colon form of a token assignment. + newly_released: list[str] = [] + newly_rejected: list[str] = [] + for group, sample_id, value in _corpus_samples(): + strict = classify_private_text(value, categories=ALL_CATEGORIES) + internal = classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) + was_rejected = _legacy_rejects(value) + if was_rejected: + # The publication tier keeps every verdict the old rule gave. + assert strict is not None, sample_id + if not was_rejected and internal is not None: + newly_rejected.append(sample_id) + if was_rejected and internal is None: + newly_released.append(sample_id) + assert newly_released == [ + "bearer_word_in_prose", + "password_word_in_prose", + "secret_word_in_prose", + "rotation_note_names_two_schemes", + "bearer_value_below_named_floor", + ] + assert newly_rejected == [ + "token_assignment_colon", + "raw_github_token_unlabeled", + "private_key_block_unlabeled", + ] + # Every released sample is a credential word and nothing else, so the + # loosening cannot carry a value. + for _, sample_id, value in _corpus_samples(): + if sample_id in newly_released: + match = classify_private_text(value, categories=ALL_CATEGORIES) + assert match is not None, sample_id + assert match.category == CATEGORY_CREDENTIAL_WORD, sample_id + + +# The released class is enumerated, not sampled: every prefix x credential word x +# separator x value the two tiers can disagree about. The invariant is a +# biconditional, so a form that slips between the arms fails here. +_WORDS = ("Bear" + "er", "pass" + "word", "sec" + "ret") +_PREFIXES = ("", "the ", "Read the ", "retry used the ") +_SEPARATORS = ("", " ", ",", ":", " =", ": ", "\t=", " of ", ". ") +_VALUES = ( + "", + "x", + "abc123", + "hunter2", + "a" * (BEARER_VALUE_MIN_LENGTH - 1), + "a" * BEARER_VALUE_MIN_LENGTH, + "a" * 20, + "QWxhZGRpbjpvcGVuIHNlc2FtZQ==", + "token expired", +) + + +def _word_form_corpus() -> list[str]: + return [ + f"{prefix}{word}{separator}{value}" + for word in _WORDS + for prefix in _PREFIXES + for separator in _SEPARATORS + for value in _VALUES + ] + + +def test_tier_delta_is_exactly_the_word_category_over_the_whole_class() -> None: + # Only values the old rule actually rejected are candidates for release; a + # form neither rule ever saw ("Bearerx") is not a tier delta. + released = [ + value + for value in _word_form_corpus() + if _legacy_rejects(value) + and classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) is None + ] + for value in released: + strict = classify_private_text(value, categories=ALL_CATEGORIES) + assert strict is not None, value + assert strict.category == CATEGORY_CREDENTIAL_WORD, value + # A released value must carry no assignment and no scheme-with-value, so + # the delta cannot be an unmeasured hole in the value arms. + assert LABELED_CREDENTIAL_ASSIGNMENT_PATTERN.search(value) is None, value + assert BEARER_VALUE_SHAPE_PATTERN.search(value) is None, value + assert OWNER_SECRET_LIKE.search(value) is None, value + # Nothing the old rule accepted is newly rejected inside either tier. + for value in _word_form_corpus(): + if _legacy_rejects(value): + assert _publication_rejects(value), value + assert len(released) > 0 + + +def test_adjacency_is_the_documented_limit_of_the_value_arms() -> None: + # A value named *beside* the word, with no assignment operator and no + # whitespace-adjacent scheme form, is prose to these arms. This is the known + # limit of the split and it is stated, not left implicit: the publication tier + # still rejects it, and a raw credential token is caught wherever it appears. + mention = "Bear" + "er, " + "a" * 20 + assert _publication_rejects(mention) + assert not _internal_rejects(mention) + adjacent = "Bear" + "er " + "a" * 20 + assert _internal_rejects(adjacent) + raw_token = "ghp_" + "a" * 36 + assert _internal_rejects(raw_token) and _publication_rejects(raw_token) def test_private_text_patterns_are_the_categorized_patterns_in_order() -> None: # The compat tuple every existing importer reads is derived from the - # categorized list, same patterns, same order, so find_private_text_match is - # byte-identical to before the relocation. + # categorized list, same patterns, same order, so the publication tier still + # recognizes the three credential words it always did. assert PRIVATE_TEXT_PATTERNS == tuple( entry.pattern for entry in _CATEGORIZED_PRIVATE_TEXT_PATTERNS ) - assert len(PRIVATE_TEXT_PATTERNS) == 11 + assert len(PRIVATE_TEXT_PATTERNS) == 12 + word_arms = tuple( + entry.reason + for entry in _CATEGORIZED_PRIVATE_TEXT_PATTERNS + if entry.category == CATEGORY_CREDENTIAL_WORD + ) + assert word_arms == ("bearer auth scheme word", "password word", "secret word") def test_public_safety_reexports_the_same_owner_objects() -> None: diff --git a/tests/control_plane/test_public_safe_text_owner_parity.py b/tests/control_plane/test_public_safe_text_owner_parity.py index d2b8add407..1ed58df655 100644 --- a/tests/control_plane/test_public_safe_text_owner_parity.py +++ b/tests/control_plane/test_public_safe_text_owner_parity.py @@ -8,7 +8,10 @@ while the Python patterns accepted a quoted-JSON credential header. These tests drive the shared fixture through each owner's real entrypoint, so -any owner that drifts fails here instead of in a reviewer's manual probe. +any owner that drifts fails here instead of in a reviewer's manual probe. The +fixture has three buckets because the four owners are one tier: they validate +LoopX's own state, so they accept a bare credential word (Refs #5136 direction +2) while the stricter publication tier keeps rejecting it. """ from __future__ import annotations @@ -24,6 +27,13 @@ from loopx.boundary_authority import build_checkpointed_boundary_authority_entry from loopx.control_plane.goals.vision_checkpoint import build_vision_checkpoint from loopx.feedback import validate_public_safe_text as validate_feedback_text +from loopx.public_safe_text import ( + ALL_CATEGORIES, + CATEGORY_CREDENTIAL_WORD, + TEXT_OWNER_CATEGORIES, + classify_private_text, + find_private_text_match, +) CORPUS_PATH = ( Path(__file__).resolve().parents[1] / "fixtures" / "public_safe_text_corpus.json" @@ -33,7 +43,7 @@ def _load_corpus() -> dict[str, Any]: corpus = json.loads(CORPUS_PATH.read_text(encoding="utf-8")) - assert corpus["schema_version"] == "public_safe_text_corpus_v0" + assert corpus["schema_version"] == "public_safe_text_corpus_v1" return corpus @@ -60,6 +70,7 @@ def _samples(group: str) -> list[tuple[str, str]]: PUBLIC_SAFE_SAMPLES = _samples("public_safe") PRIVATE_LOOKING_SAMPLES = _samples("private_looking") +INTERNAL_STATE_PROSE_SAMPLES = _samples("internal_state_prose") def _ids(samples: list[tuple[str, str]]) -> list[str]: @@ -128,3 +139,50 @@ def test_corpus_covers_the_reviewed_credential_shapes() -> None: } assert required <= set(_ids(PRIVATE_LOOKING_SAMPLES)) assert "governance_prose_needs_owner_authorization" in _ids(PUBLIC_SAFE_SAMPLES) + # Direction 2's boundary is part of the contract, so the corpus must keep a + # sample on each side of the value floor and one assignment per demoted word. + private_ids = set(_ids(PRIVATE_LOOKING_SAMPLES)) + assert { + "bearer_value_at_named_floor", + "password_assignment_short_value", + "secret_assignment_colon", + "token_assignment_colon", + } <= private_ids + assert "bearer_word_in_prose" in _ids(INTERNAL_STATE_PROSE_SAMPLES) + + +@pytest.mark.parametrize("owner", [check for _, check in OWNERS], ids=OWNER_IDS) +@pytest.mark.parametrize( + "sample", INTERNAL_STATE_PROSE_SAMPLES, ids=_ids(INTERNAL_STATE_PROSE_SAMPLES) +) +def test_internal_state_prose_is_accepted_by_every_text_owner( + owner: Any, + sample: tuple[str, str], +) -> None: + owner(sample[1]) + + +def test_internal_state_prose_stays_rejected_by_the_publication_tier() -> None: + # The tier difference is the whole of direction 2, so it has to be visible + # from the stricter surface as well: everything the four owners now let + # through is still recognized by the full category set, and by the legacy + # helper the repository-publication scan still reaches. + for sample_id, value in INTERNAL_STATE_PROSE_SAMPLES: + assert find_private_text_match(value) is not None, sample_id + assert classify_private_text(value, categories=ALL_CATEGORIES) is not None, ( + sample_id + ) + assert classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) is None, ( + sample_id + ) + + +def test_no_value_bearing_form_left_the_internal_state_policy() -> None: + # A category set is a blunt instrument: dropping `credential_word` must not + # drop an assignment or a scheme-with-value. This walks every private-looking + # sample and proves the narrower policy still rejects all of them, so the + # only values the split can release are the prose samples above. + for sample_id, value in PRIVATE_LOOKING_SAMPLES: + match = classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) + assert match is not None, sample_id + assert match.category != CATEGORY_CREDENTIAL_WORD, sample_id diff --git a/tests/control_plane_ts/public_safe_text_corpus.test.ts b/tests/control_plane_ts/public_safe_text_corpus.test.ts index a582975e85..098666f0cb 100644 --- a/tests/control_plane_ts/public_safe_text_corpus.test.ts +++ b/tests/control_plane_ts/public_safe_text_corpus.test.ts @@ -4,6 +4,10 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { + CREDENTIAL_WORD_PATTERNS, + INTERNAL_STATE_PRIVATE_TEXT_PATTERNS, + INTERNAL_STATE_SHAPE_PATTERNS, + PRIVATE_TEXT_PATTERNS, VISION_REFRESH_REQUEST_SCHEMA, buildVisionCheckpoint, } from "../../loopx/control_plane/goals/vision_checkpoint.ts"; @@ -19,6 +23,7 @@ const CORPUS_PATH = fileURLToPath( interface CorpusSample { id: string; template: string; + note?: string; } interface Corpus { @@ -26,10 +31,11 @@ interface Corpus { tokens: Record; public_safe: CorpusSample[]; private_looking: CorpusSample[]; + internal_state_prose: CorpusSample[]; } const corpus = JSON.parse(readFileSync(CORPUS_PATH, "utf8")) as Corpus; -assert.equal(corpus.schema_version, "public_safe_text_corpus_v0"); +assert.equal(corpus.schema_version, "public_safe_text_corpus_v1"); function render(template: string): string { return template.replace(/\{([A-Z][A-Z_]*)\}/g, (_match, name: string) => { @@ -76,3 +82,65 @@ test("vision checkpoint rejects every private-looking corpus sample", () => { ); } }); + +// Refs #5136, direction 2: this owner validates LoopX's own state, so a bare +// credential word is a fact about a credential rather than one. The Python tier +// test drives the same bucket through the three Python owners. +test("vision checkpoint accepts the internal-state prose corpus", () => { + for (const sample of corpus.internal_state_prose) { + assert.doesNotThrow(() => checkpoint(render(sample.template)), sample.id); + } +}); + +test("the internal-state tier drops the words and adds the ported shapes", () => { + // Without this, dropping a word arm and dropping a value arm would look the + // same from the corpus alone. The composition is pinned, not the count. + const wordSources = CREDENTIAL_WORD_PATTERNS.map((pattern) => pattern.source); + assert.deepEqual( + wordSources, + [/\bBearer\b/i, /\bpassword\b/i, /\bsecret\b/i].map((pattern) => pattern.source), + ); + assert.equal(INTERNAL_STATE_SHAPE_PATTERNS.length, 2); + const expected = [ + ...PRIVATE_TEXT_PATTERNS.filter( + (pattern) => !CREDENTIAL_WORD_PATTERNS.includes(pattern), + ), + ...INTERNAL_STATE_SHAPE_PATTERNS, + ].map((pattern) => pattern.source); + assert.deepEqual( + INTERNAL_STATE_PRIVATE_TEXT_PATTERNS.map((pattern) => pattern.source), + expected, + ); +}); + +test("the narrower tier still rejects every value and assignment shape", () => { + // Each value below is one of the demoted words carrying something. Assembling + // them keeps the fixture's discipline of carrying no literal credential text. + const bearer = "Bear" + "er"; + const password = "pass" + "word"; + const secret = "sec" + "ret"; + const token = "tok" + "en"; + const rejected = [ + `${bearer} ${"a".repeat(8)}`, + `${bearer} ${"a".repeat(40)}`, + `${password}=hunter2`, + `${secret}: env`, + `${token}: abc123`, + "/Us" + "ers/operator/state.json", + ]; + for (const value of rejected) { + assert.throws( + () => checkpoint(value), + /private-looking value/, + `one char below/above floor: ${value.slice(0, 12)}`, + ); + } + const accepted = [ + `the ${bearer} token expired`, + `the ${password} is stored in the vault`, + `read the ${secret} from the environment`, + ]; + for (const value of accepted) { + assert.doesNotThrow(() => checkpoint(value), value.slice(0, 24)); + } +}); diff --git a/tests/fixtures/public_safe_text_corpus.json b/tests/fixtures/public_safe_text_corpus.json index 8709981bdf..6e578042fc 100644 --- a/tests/fixtures/public_safe_text_corpus.json +++ b/tests/fixtures/public_safe_text_corpus.json @@ -1,7 +1,8 @@ { - "schema_version": "public_safe_text_corpus_v0", + "schema_version": "public_safe_text_corpus_v1", "purpose": "One corpus for every real public-safe text validator owner: loopx/public_safe_text.py (used by feedback, authority, and boundary_authority) and the TypeScript Vision checkpoint. Both runtimes must agree on every sample.", "sample_encoding": "Each sample is a template. Substitute every {NAME} placeholder with the joined parts of tokens[NAME], and {NAME_LOWER} with the lowercased join. Samples are assembled at test time so this fixture carries no literal credential-looking string.", + "bucket_semantics": "public_safe is accepted by every owner and by the publication policy. private_looking is rejected by every owner and by the publication policy. internal_state_prose is the two-tier set from Refs #5136 direction 2: the four owners validate LoopX's own state and accept it, while the stricter publication policy (ALL_CATEGORIES / find_private_text_match) still rejects it.", "tokens": { "AUTHZ": ["Author", "ization"], "BASIC": ["Bas", "ic"], @@ -9,7 +10,10 @@ "TOKEN": ["tok", "en"], "PASSWORD": ["pass", "word"], "SECRET": ["sec", "ret"], - "HOME_PATH": ["/Us", "ers/"] + "HOME_PATH": ["/Us", "ers/"], + "GH_TOKEN_PREFIX": ["gh", "p_"], + "KEY_HEAD": ["---", "---"], + "PRIVATE_KEY": ["PRIVATE KE", "Y"] }, "public_safe": [ { @@ -35,6 +39,11 @@ { "id": "ordinary_word_token_without_assignment", "template": "the action kind is a public-safe {TOKEN_LOWER}" + }, + { + "id": "ordinary_public_url", + "template": "metrics published at https://example.com/run-7/metrics.json", + "note": "The internal-state tier does not decide URLs: the rule these four owners enforced before never rejected one either, and per-face URL policy is the caller-migration work still open in #5136." } ], "private_looking": [ @@ -72,23 +81,70 @@ }, { "id": "bare_bearer_scheme", - "template": "{BEARER} abc123def456" + "template": "{BEARER} abc123def456", + "note": "Rejected by the bearer-value arm, not by the word arm: 12 chars >= BEARER_VALUE_MIN_LENGTH." + }, + { + "id": "bearer_value_at_named_floor", + "template": "{BEARER} abc123de", + "note": "Exactly BEARER_VALUE_MIN_LENGTH (8) characters of value." }, { "id": "token_assignment", "template": "{TOKEN_LOWER}=abc123def456" }, { - "id": "password_word", - "template": "the {PASSWORD_LOWER} is stored in the vault" + "id": "password_assignment_short_value", + "template": "{PASSWORD_LOWER}=hunter2", + "note": "The assignment form carries no value-length floor, so a 6-char value is still rejected after the word arm moved to credential_word." }, { - "id": "secret_word", - "template": "read the {SECRET_LOWER} from the environment" + "id": "secret_assignment_colon", + "template": "{SECRET_LOWER}: env", + "note": "Newly rejected: the old rule only had an equals form for this label." + }, + { + "id": "token_assignment_colon", + "template": "{TOKEN_LOWER}: abc123", + "note": "Newly rejected: the old text-owner rule matched token= but not the colon form." }, { "id": "local_home_path", "template": "{HOME_PATH}operator/work/loopx/state.json" + }, + { + "id": "raw_github_token_unlabeled", + "template": "carry {GH_TOKEN_PREFIX}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa forward", + "note": "Newly rejected by the internal-state tier: the word-only rule had no arm for a credential value that arrives without a label." + }, + { + "id": "private_key_block_unlabeled", + "template": "{KEY_HEAD}BEGIN RSA {PRIVATE_KEY}-----", + "note": "Same class as the raw token: recognized only by the value-shape arm." + } + ], + "internal_state_prose": [ + { + "id": "bearer_word_in_prose", + "template": "the {BEARER} token expired", + "note": "The maintainer's own example in #5136: a sentence about an expired scheme is not a leak." + }, + { + "id": "password_word_in_prose", + "template": "the {PASSWORD_LOWER} is stored in the vault" + }, + { + "id": "secret_word_in_prose", + "template": "read the {SECRET_LOWER} from the environment" + }, + { + "id": "rotation_note_names_two_schemes", + "template": "{AUTHZ_LOWER} rotation is pending; reissue the {BEARER} value and the vault {SECRET_LOWER}." + }, + { + "id": "bearer_value_below_named_floor", + "template": "the {BEARER} abc123d was rejected", + "note": "One character below BEARER_VALUE_MIN_LENGTH, so it is a word mention. Both sides of the floor are pinned; the floor is a named constant, not a regex accident." } ] } From 8efceef0802f2c7c481504c1b6f7f18f8e91337e Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:09:54 +0800 Subject: [PATCH 03/12] fix(public-safety): cover the bearer label and name the tier each helper holds An assignment of the scheme word was the one form still released by adjacency: "bearer: short" matched neither the value floor nor the label arm. The label arm now names bearer with the other three words, in both runtimes. The two module-level comments that still described the pre-split list are corrected: find_private_text_match is the publication tier rather than a helper preserved for the four owners, and the derived tuple is no longer the byte-identical list #5245 landed. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- .../control_plane/goals/vision_checkpoint.ts | 2 +- loopx/public_safe_text.py | 26 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index f5b3db4c07..4b772b1380 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -122,7 +122,7 @@ const BEARER_VALUE_SHAPE = new RegExp( String.raw`\bBearer\s+[A-Za-z0-9._~+/=-]{${BEARER_VALUE_MIN_LENGTH},}`, "i", ); -const LABELED_CREDENTIAL_ASSIGNMENT = /\b(?:token|password|secret)\s*[:=]/i; +const LABELED_CREDENTIAL_ASSIGNMENT = /\b(?:Bearer|token|password|secret)\s*[:=]/i; // Ported from the Python owner's two in-policy shape detectors, so one corpus // yields one verdict in both runtimes (Refs #5136, direction 4). The third // Python detector -- a raw remote location -- is deliberately not ported: it is diff --git a/loopx/public_safe_text.py b/loopx/public_safe_text.py index 4693e87a8e..144d1c9c56 100644 --- a/loopx/public_safe_text.py +++ b/loopx/public_safe_text.py @@ -102,13 +102,14 @@ re.I, ) -# An assignment form of the three demoted words. It carries no value-length floor -# on purpose: ``password=``/``secret=``/``token=`` is a credential statement -# whatever follows it, and the previous behavior already rejected every one of -# them (and every bare mention), so this arm alone cannot tighten a surface that -# has kept ``credential_word`` out of its policy. +# An assignment form of the demoted words. It carries no value-length floor on +# purpose: ``password=``/``secret=``/``token=``/``bearer:`` states a credential +# whatever follows it. For ``password`` and ``secret`` this arm is no tightening +# -- the old word-only rule already rejected every mention, including these -- +# while ``token:`` and ``bearer:`` are the two spellings it did not know, and +# direction 2 asks that an assignment never depend on a length accident. LABELED_CREDENTIAL_ASSIGNMENT_PATTERN = re.compile( - r"\b(?:" + "tok" + r"en|pass" + r"word|sec" + r"ret)\s*[:=]", + r"\b(?:" + "Bear" + r"er|tok" + r"en|pass" + r"word|sec" + r"ret)\s*[:=]", re.I, ) @@ -257,8 +258,11 @@ class _CategorizedPattern: ), ) -# Kept as the plain pattern tuple so `find_private_text_match` and every -# existing importer see byte-identical behavior (same patterns, same order). +# The publication-tier tuple, derived from the categorized list so the two can +# never drift. It is not the pre-#5136 list: the two value arms were added and +# the three word arms moved last, so a first match over an assignment form names +# the shape rather than the word. `PRIVATE_TEXT_PATTERNS` stays byte-for-byte +# what a surface rejects when it asks for every category. PRIVATE_TEXT_PATTERNS: tuple[re.Pattern[str], ...] = tuple( entry.pattern for entry in _CATEGORIZED_PRIVATE_TEXT_PATTERNS ) @@ -313,8 +317,10 @@ class PrivateTextMatch: def find_private_text_match(value: str | None) -> re.Pattern[str] | None: """Return the first matching private-text pattern, or None when clean. - Preserved verbatim for the four text owners and the shared corpus parity - test; `classify_private_text` is the category-aware successor. + The publication tier: this recognizes every category, including a bare + credential word. The four internal-state owners ask the category-aware + `classify_private_text` for their narrower policy instead, so this helper is + what a repository-publication surface and the corpus parity test hold. """ if not value: From 81ff784dd9b0382756680240c1b00f49caf3b331 Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:33:48 +0800 Subject: [PATCH 04/12] fix(public-safety): keep credential-shaped examples out of the scanned source The repository boundary scan reads loopx/** and the test files as source, and the explanatory comments plus one parametrize row wrote a credential label next to a value in plain text. That is what its `public_boundary_violation: credential` report is for, and it did not stop at the comments: the two hits inside public_safe_text.py became contract errors, so `status` reported unhealthy contracts and `quota spend-slot` refused automatic compute. Sixteen settlement and turn-replay cases failed for a comment. Rewritten in prose, and the assignment rows in the test now assemble the label the way the file already assembled every other sensitive literal. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- docs/public-private-boundary.md | 6 ++--- loopx/public_safe_text.py | 25 ++++++++++--------- .../test_public_safe_text_classifier.py | 13 +++++++--- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/docs/public-private-boundary.md b/docs/public-private-boundary.md index 92478c4d31..571a1d31b0 100644 --- a/docs/public-private-boundary.md +++ b/docs/public-private-boundary.md @@ -137,9 +137,9 @@ destination needs: rejecting it. "the Bearer token expired" describes a credential; it does not carry one. -The value arms stay in every policy, so the narrower tier releases prose only: an -assignment (`password=`, `secret:`, `token:`) or a scheme followed by a value -(`Bearer <8+ characters>`) is rejected by both tiers. A raw credential token with +The value arms stay in every policy, so the narrower tier releases prose only: a +credential label followed by an assignment operator, or a bearer scheme carrying +a value of at least eight characters, is rejected by both tiers. A raw credential token with no label at all -- a GitHub token, a private key block -- is rejected by both tiers too, which the word-only rule never caught. diff --git a/loopx/public_safe_text.py b/loopx/public_safe_text.py index 144d1c9c56..bbbc3289e5 100644 --- a/loopx/public_safe_text.py +++ b/loopx/public_safe_text.py @@ -65,12 +65,12 @@ # Refs #5136, direction 2: a *mention* of a credential word is not a credential. # ``credential_word`` exists so a surface can recognize these words without -# rejecting them; the value and assignment forms stay in ``credential`` so -# narrowing one policy can never let ``password=hunter2`` out with the prose. -# The floor below is explicit and corpus-pinned on both sides: the shared -# shape detectors used to reject an ``Authorization: Bearer <12 chars>`` header -# only because their value minimum happened to be met, and a bare-word arm was -# the accidental backstop for the short values. +# rejecting them; the value and assignment forms stay in ``credential``, so +# narrowing one policy can never release a short password value along with the +# prose. The floor below is explicit and corpus-pinned on both sides: the shared +# shape detectors reject a bearer scheme only once its value reaches their own +# minimum, and before this split a bare-word arm was the accidental backstop for +# the values below it. BEARER_VALUE_MIN_LENGTH = 8 @@ -102,12 +102,13 @@ re.I, ) -# An assignment form of the demoted words. It carries no value-length floor on -# purpose: ``password=``/``secret=``/``token=``/``bearer:`` states a credential -# whatever follows it. For ``password`` and ``secret`` this arm is no tightening -# -- the old word-only rule already rejected every mention, including these -- -# while ``token:`` and ``bearer:`` are the two spellings it did not know, and -# direction 2 asks that an assignment never depend on a length accident. +# An assignment form of the four words: either separator, any value. It carries +# no value-length floor on purpose, because naming a credential label next to an +# operator already states an assignment. For the password and secret words this +# arm is no tightening -- the old word-only rule rejected every mention of them, +# assignments included -- while the colon spellings of token and bearer are the +# two forms it did not know. Direction 2 asks that an assignment never depend on +# a length accident. LABELED_CREDENTIAL_ASSIGNMENT_PATTERN = re.compile( r"\b(?:" + "Bear" + r"er|tok" + r"en|pass" + r"word|sec" + r"ret)\s*[:=]", re.I, diff --git a/tests/control_plane/test_public_safe_text_classifier.py b/tests/control_plane/test_public_safe_text_classifier.py index 2b43919734..e90782b6b1 100644 --- a/tests/control_plane/test_public_safe_text_classifier.py +++ b/tests/control_plane/test_public_safe_text_classifier.py @@ -71,6 +71,13 @@ _LOCAL_PATH = "/".join(["", "home", "dev", "x.json"]) _ORG_MARKER_PATH = "/".join(["", "ext_data", "run", "x"]) +# Assembled for the same reason as the three above: the repository boundary scan +# reads this file as source, and a credential label written next to a value is +# exactly what it reports. +_PASSWORD_WORD = "pass" + "word" +_SECRET_WORD = "sec" + "ret" +_TOKEN_WORD = "tok" + "en" + def _publication_rejects(value: str) -> bool: """Recognized by the full category set, the repository-publication tier.""" @@ -120,9 +127,9 @@ def test_text_owner_policy_drops_the_words_and_leaves_urls_undecided() -> None: ("the password is stored in the vault", CATEGORY_CREDENTIAL_WORD), ("read the secret from the environment", CATEGORY_CREDENTIAL_WORD), ("Bearer abc123def456", CATEGORY_CREDENTIAL), - ("password=hunter2", CATEGORY_CREDENTIAL), - ("secret: env", CATEGORY_CREDENTIAL), - ("token: abc123", CATEGORY_CREDENTIAL), + (f"{_PASSWORD_WORD}=hunter2", CATEGORY_CREDENTIAL), + (f"{_SECRET_WORD}: env", CATEGORY_CREDENTIAL), + (f"{_TOKEN_WORD}: abc123", CATEGORY_CREDENTIAL), ], ) def test_every_demoted_word_keeps_a_value_or_assignment_arm_in_policy( From 6e938bdc15deaefdeed576de7bd615a818bb4463 Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:24:01 +0800 Subject: [PATCH 05/12] fix(public-safety): state the bearer floor as a foldable literal CI's TypeScript digest guard requires every `new RegExp` whose pattern it cannot fold to be declared as a second owner of a value shape. Interpolating BEARER_VALUE_MIN_LENGTH into the pattern is exactly such a construction, so the quantifier is written literally and the tie to the constant is asserted instead: the source must carry `{,}`, and both sides of the floor are checked behaviorally. One failure, found by running the whole control-plane suite rather than only the files this change edits. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- loopx/control_plane/goals/vision_checkpoint.ts | 12 +++++++----- .../control_plane_ts/public_safe_text_corpus.test.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index 4b772b1380..cbf5b0285c 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -114,14 +114,16 @@ const GOAL_VISION_STATE_ALIASES: Readonly> = { // recognized but no longer rejected by this owner, because a checkpoint reason // that says the bearer token expired describes a credential rather than carrying // one. The value and assignment arms below are what keep a real credential out. -const BEARER_VALUE_MIN_LENGTH = 8; +export const BEARER_VALUE_MIN_LENGTH = 8; const AUTHORIZATION_CREDENTIAL_SHAPE = /\bAuthorization["']?\s*[:=]/i; const BASIC_CREDENTIAL_VALUE = /[Bb]asic\s+(?=[A-Za-z0-9+/=]*[a-z])(?=[A-Za-z0-9+/=]*[A-Z])[A-Za-z0-9+/=]{16,}/; -const BEARER_VALUE_SHAPE = new RegExp( - String.raw`\bBearer\s+[A-Za-z0-9._~+/=-]{${BEARER_VALUE_MIN_LENGTH},}`, - "i", -); +// A literal, not `new RegExp` with an interpolated floor: the digest guard +// (`tests/control_plane_ts/content_digest_single_owner.test.ts`) requires every +// construction whose pattern it cannot fold to be declared as a second owner of a +// value shape, and an interpolated quantifier is exactly that. The tie to the named +// constant is asserted in the corpus test instead of built into the pattern here. +export const BEARER_VALUE_SHAPE = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/i; const LABELED_CREDENTIAL_ASSIGNMENT = /\b(?:Bearer|token|password|secret)\s*[:=]/i; // Ported from the Python owner's two in-policy shape detectors, so one corpus // yields one verdict in both runtimes (Refs #5136, direction 4). The third diff --git a/tests/control_plane_ts/public_safe_text_corpus.test.ts b/tests/control_plane_ts/public_safe_text_corpus.test.ts index 098666f0cb..48f04b4df7 100644 --- a/tests/control_plane_ts/public_safe_text_corpus.test.ts +++ b/tests/control_plane_ts/public_safe_text_corpus.test.ts @@ -4,6 +4,8 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { + BEARER_VALUE_MIN_LENGTH, + BEARER_VALUE_SHAPE, CREDENTIAL_WORD_PATTERNS, INTERNAL_STATE_PRIVATE_TEXT_PATTERNS, INTERNAL_STATE_SHAPE_PATTERNS, @@ -113,6 +115,15 @@ test("the internal-state tier drops the words and adds the ported shapes", () => ); }); +test("the bearer floor is the named constant the pattern carries", () => { + // BEARER_VALUE_SHAPE is a literal so the digest guard can fold it, which means + // the constant has to be checked against the source rather than used to build it. + const floor = new RegExp(`\\{${BEARER_VALUE_MIN_LENGTH},\\}`); + assert.ok(floor.test(BEARER_VALUE_SHAPE.source), BEARER_VALUE_SHAPE.source); + assert.ok(!BEARER_VALUE_SHAPE.test(`Bearer ${"a".repeat(BEARER_VALUE_MIN_LENGTH - 1)}`)); + assert.ok(BEARER_VALUE_SHAPE.test(`Bearer ${"a".repeat(BEARER_VALUE_MIN_LENGTH)}`)); +}); + test("the narrower tier still rejects every value and assignment shape", () => { // Each value below is one of the demoted words carrying something. Assembling // them keeps the fixture's discipline of carrying no literal credential text. From 1d5ccc57ed068315c33c20d6a903ad194fea4585 Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:51:37 +0800 Subject: [PATCH 06/12] fix(public-safety): decide credential values by shape, not by one length The internal-state tier released text that carries a value while still rejecting ordinary prose, because the only discriminator between a credential word and a credential value was a bearer run of eight characters. A password value reached through the copula "is", a secret followed by an opaque run, a value after a comma or dash, a quoted passphrase, and a token label beside a space-separated value all passed the four real owners, while a scheme name plus an ordinary English word of eight characters or more was rejected. State the contract as four independent signals and implement each one: an assignment operator carries whatever follows, with no length condition; a connector (whitespace, comma, semicolon, dash, or a copula) followed by a token containing a digit or one of the base64 characters is a value; the same connector followed by a quoted run is a value; the same connector followed by an unbroken letter-only run at the named ceiling is a value. Nothing reads a word's length to decide whether a credential word is present, so the prose direction and the value direction stop sharing one number. The connector arms omit ':' and '=' because the assignment arm already carries those spellings: one spelling names one signal rather than depending on list order. A label-free value such as `token abc123def` is now rejected in the publication tier too, which is a hole that predates the tier split. Expectations stop being derived from the implementation. Every corpus row now declares the contract signal that explains its verdict, the fixture lists the signals, and both runtimes check that every signal has a row and every row names a known one. The tier-delta sweep is replaced by a biconditional over the whole form class (prefix x label x connector x value, 4,032 shapes) judged against a predicate written from the contract prose, with each signal pinned to its own arm and the four real owners driven over the enlarged corpus. The one residual the contract cannot recognize -- a letter-only run below the ceiling beside the label -- is kept as a named corpus row so the limit is a decision with a test. Refs #5136, direction 2 and direction 4. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- .../control_plane/goals/vision_checkpoint.ts | 23 +- loopx/public_safe_text.py | 97 +++++-- .../test_public_safe_text_classifier.py | 252 ++++++++++++------ .../test_public_safe_text_owner_parity.py | 91 ++++++- .../public_safe_text_corpus.test.ts | 70 +++-- tests/fixtures/public_safe_text_corpus.json | 232 +++++++++++++--- 6 files changed, 604 insertions(+), 161 deletions(-) diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index cbf5b0285c..6cf180b5ef 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -114,16 +114,26 @@ const GOAL_VISION_STATE_ALIASES: Readonly> = { // recognized but no longer rejected by this owner, because a checkpoint reason // that says the bearer token expired describes a credential rather than carrying // one. The value and assignment arms below are what keep a real credential out. -export const BEARER_VALUE_MIN_LENGTH = 8; +// The credential-value contract, mirrored from loopx/public_safe_text.py: four +// independent signals, of which this file owns the two connector arms. A label +// plus `:` or `=` carries whatever follows (LABELED_CREDENTIAL_ASSIGNMENT below); +// a label reached through a comma, dash, whitespace or copula carries a value only +// when the next token looks assembled -- it holds a digit or a base64-only +// character, or it is a quoted run, or it is an unbroken letter run at or above +// OPAQUE_VALUE_MIN_LENGTH. An ordinary English word beside the label is prose. +export const OPAQUE_VALUE_MIN_LENGTH = 16; const AUTHORIZATION_CREDENTIAL_SHAPE = /\bAuthorization["']?\s*[:=]/i; const BASIC_CREDENTIAL_VALUE = /[Bb]asic\s+(?=[A-Za-z0-9+/=]*[a-z])(?=[A-Za-z0-9+/=]*[A-Z])[A-Za-z0-9+/=]{16,}/; -// A literal, not `new RegExp` with an interpolated floor: the digest guard +// Literals, not `new RegExp` with interpolated parts: the digest guard // (`tests/control_plane_ts/content_digest_single_owner.test.ts`) requires every // construction whose pattern it cannot fold to be declared as a second owner of a -// value shape, and an interpolated quantifier is exactly that. The tie to the named -// constant is asserted in the corpus test instead of built into the pattern here. -export const BEARER_VALUE_SHAPE = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/i; +// value shape. The tie between the quantifier and OPAQUE_VALUE_MIN_LENGTH is +// asserted in the corpus test instead of built into the pattern here. +export const CONNECTED_CREDENTIAL_VALUE_SHAPE = + /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*(?:(?=[A-Za-z0-9._~+\/=-]{2,})(?=[A-Za-z0-9._~+\/=-]*[0-9+\/=])[A-Za-z0-9._~+\/=-]+|[A-Za-z]{16,})/i; +export const QUOTED_CREDENTIAL_VALUE_SHAPE = + /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*["'][^"'\n]{2,}["']/i; const LABELED_CREDENTIAL_ASSIGNMENT = /\b(?:Bearer|token|password|secret)\s*[:=]/i; // Ported from the Python owner's two in-policy shape detectors, so one corpus // yields one verdict in both runtimes (Refs #5136, direction 4). The third @@ -139,7 +149,8 @@ export const PRIVATE_TEXT_PATTERNS: RegExp[] = [ /lark[o]ffice/i, // Equivalent matcher avoids matching its own policy source. /docs\.internal/i, /\bt-20\d{12}-[a-z0-9]+\b/, - BEARER_VALUE_SHAPE, + CONNECTED_CREDENTIAL_VALUE_SHAPE, + QUOTED_CREDENTIAL_VALUE_SHAPE, AUTHORIZATION_CREDENTIAL_SHAPE, BASIC_CREDENTIAL_VALUE, LABELED_CREDENTIAL_ASSIGNMENT, diff --git a/loopx/public_safe_text.py b/loopx/public_safe_text.py index bbbc3289e5..78a5f3f71a 100644 --- a/loopx/public_safe_text.py +++ b/loopx/public_safe_text.py @@ -67,11 +67,81 @@ # ``credential_word`` exists so a surface can recognize these words without # rejecting them; the value and assignment forms stay in ``credential``, so # narrowing one policy can never release a short password value along with the -# prose. The floor below is explicit and corpus-pinned on both sides: the shared -# shape detectors reject a bearer scheme only once its value reaches their own -# minimum, and before this split a bare-word arm was the accidental backstop for -# the values below it. -BEARER_VALUE_MIN_LENGTH = 8 +# prose. +# +# The credential-value contract has exactly four independent signals. Each one +# is a shape fact about the text next to the label, which is what lets the two +# directions be decided by different rules instead of by one length: +# +# assignment_punctuation -- ``label:`` or ``label=`` carries whatever follows; +# the label already asserts an assignment, so no +# value floor applies (``LABELED_CREDENTIAL_...``). +# shaped_value_token -- a connector (whitespace, comma, semicolon, dash) or a +# copula ("is", "set to") followed by a token +# containing a digit or one of the base64-only +# characters ``+ / =``. +# quoted_value -- the same connector followed by a quoted run. +# opaque_value_run -- the same connector followed by an unbroken +# letter-only run of ``OPAQUE_VALUE_MIN_LENGTH`` or +# more, which is how an assembled token with no digit +# is still caught. +# +# ``OPAQUE_VALUE_MIN_LENGTH`` is the only length in this contract, and it is a +# *word-length* ceiling, not a token floor: it exists to keep an ordinary English +# word beside the label out of the value class. Fifteen is above the operational +# prose seen in this repository ("authentication", "administrator", +# "responsibilit" plus a suffix) and the value arms no longer depend on it for +# short assembled tokens -- those are caught by shape at any length. +# +# The residual is stated rather than hidden: a value written with no digit, no +# ``+/=``, no quotes, and fifteen letters or fewer is prose to this owner. That is +# why the internal-state tier documents itself as not a credential-storage +# exemption, and why the publication tier keeps the bare words. +OPAQUE_VALUE_MIN_LENGTH = 16 + +CREDENTIAL_LABEL_PATTERN_SOURCE = ( + "(?:" + "Bear" + r"er|tok" + r"en|pass" + r"word|sec" + r"ret)" +) +# One connector definition, shared by the two connector arms so the Python and +# TypeScript owners cannot drift on which separators and copulas count. `:` and +# `=` are deliberately absent: the assignment arm already carries anything after +# them, so leaving them here would give one spelling two owners and make the named +# reason depend on list order. +CREDENTIAL_VALUE_CONNECTOR_PATTERN_SOURCE = ( + r"(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*" +) +# A run that carries a digit or a base64-only character, at two characters long. +_SHAPE_VALUE_TOKEN_SOURCE = ( + r"(?=[A-Za-z0-9._~+/=-]{2,})(?=[A-Za-z0-9._~+/=-]*[0-9+/=])[A-Za-z0-9._~+/=-]+" +) +_OPAQUE_VALUE_RUN_SOURCE = r"[A-Za-z]{%d,}" % OPAQUE_VALUE_MIN_LENGTH + +# A credential label reached through a connector, with a token next to it that +# looks assembled. This is the arm that replaced the bearer-only length floor: it +# covers the copula and punctuation spellings a bare `\s+` never reached, and it +# no longer rejects an ordinary English word just because it is long-ish. +CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN = re.compile( + r"\b" + + CREDENTIAL_LABEL_PATTERN_SOURCE + + CREDENTIAL_VALUE_CONNECTOR_PATTERN_SOURCE + + r"(?:" + + _SHAPE_VALUE_TOKEN_SOURCE + + "|" + + _OPAQUE_VALUE_RUN_SOURCE + + r")", + re.IGNORECASE, +) + +# The same connector followed by a quoted run. Quoting is its own signal: the +# value class does not depend on the quoted text looking like a token, so a +# letter-only passphrase written as a quoted value is still rejected. +QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN = re.compile( + r"\b" + + CREDENTIAL_LABEL_PATTERN_SOURCE + + CREDENTIAL_VALUE_CONNECTOR_PATTERN_SOURCE + + r"[\"'][^\"'\n]{2,}[\"']", + re.IGNORECASE, +) # Credential shape, not the plain English word. LoopX governance prose says @@ -94,14 +164,6 @@ r"[A-Za-z0-9+/=]{16,}", ) -# ``Bearer `` with a value long enough to be a token rather than the next -# English word. Kept separate from the word arm so a surface can stop rejecting -# the scheme name while still rejecting the scheme plus a value. -BEARER_VALUE_SHAPE_PATTERN = re.compile( - r"\b" + "Bear" + r"er\s+[A-Za-z0-9._~+/=-]{%d,}" % BEARER_VALUE_MIN_LENGTH, - re.I, -) - # An assignment form of the four words: either separator, any value. It carries # no value-length floor on purpose, because naming a credential label next to an # operator already states an assignment. For the password and secret words this @@ -222,9 +284,14 @@ class _CategorizedPattern: "internal ticket identifier", ), _CategorizedPattern( - BEARER_VALUE_SHAPE_PATTERN, + CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN, + CATEGORY_CREDENTIAL, + "credential label carrying a shaped value", + ), + _CategorizedPattern( + QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN, CATEGORY_CREDENTIAL, - "bearer scheme carrying a value", + "credential label carrying a quoted value", ), _CategorizedPattern( _AUTHORIZATION_CREDENTIAL_SHAPE, diff --git a/tests/control_plane/test_public_safe_text_classifier.py b/tests/control_plane/test_public_safe_text_classifier.py index e90782b6b1..20a76cfb52 100644 --- a/tests/control_plane/test_public_safe_text_classifier.py +++ b/tests/control_plane/test_public_safe_text_classifier.py @@ -37,7 +37,6 @@ from loopx.public_safe_text import ( ALL_CATEGORIES, ARTIFACT_LIFECYCLE_CATEGORIES, - BEARER_VALUE_MIN_LENGTH, CATEGORY_CREDENTIAL, CATEGORY_CREDENTIAL_WORD, CATEGORY_LOCAL_PATH, @@ -50,7 +49,9 @@ TEXT_OWNER_CATEGORIES, _AUTHORIZATION_CREDENTIAL_SHAPE, _BASIC_CREDENTIAL_VALUE, - BEARER_VALUE_SHAPE_PATTERN, + CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN, + OPAQUE_VALUE_MIN_LENGTH, + QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN, LABELED_CREDENTIAL_ASSIGNMENT_PATTERN, _CATEGORIZED_PRIVATE_TEXT_PATTERNS, classify_private_text, @@ -126,10 +127,16 @@ def test_text_owner_policy_drops_the_words_and_leaves_urls_undecided() -> None: ("the Bearer token expired", CATEGORY_CREDENTIAL_WORD), ("the password is stored in the vault", CATEGORY_CREDENTIAL_WORD), ("read the secret from the environment", CATEGORY_CREDENTIAL_WORD), - ("Bearer abc123def456", CATEGORY_CREDENTIAL), + ("Bearer authentication is required here", CATEGORY_CREDENTIAL_WORD), + ("Bear" + "er abc123def456", CATEGORY_CREDENTIAL), (f"{_PASSWORD_WORD}=hunter2", CATEGORY_CREDENTIAL), + (f"{_PASSWORD_WORD} is hunter2", CATEGORY_CREDENTIAL), (f"{_SECRET_WORD}: env", CATEGORY_CREDENTIAL), + (f"{_SECRET_WORD} - Qz8m2Xp7", CATEGORY_CREDENTIAL), (f"{_TOKEN_WORD}: abc123", CATEGORY_CREDENTIAL), + (f"{_TOKEN_WORD} abc123def", CATEGORY_CREDENTIAL), + ("Bear" + "er, aB3d9QkLm", CATEGORY_CREDENTIAL), + (f'the {_PASSWORD_WORD} is "correcthorsebatterystaple"', CATEGORY_CREDENTIAL), ], ) def test_every_demoted_word_keeps_a_value_or_assignment_arm_in_policy( @@ -145,22 +152,27 @@ def test_every_demoted_word_keeps_a_value_or_assignment_arm_in_policy( assert _internal_rejects(value) is not released_to_prose, value -def test_bearer_value_floor_is_pinned_on_both_sides() -> None: - # The floor is a named constant, so the boundary is a decision with a test - # rather than a regex artifact. One character below it is a word mention; at - # it and above it the scheme carries a value every tier rejects. - below = "Bearer " + "a" * (BEARER_VALUE_MIN_LENGTH - 1) - at = "Bearer " + "a" * BEARER_VALUE_MIN_LENGTH - above = "Bearer " + "a" * (BEARER_VALUE_MIN_LENGTH + 12) - below_match = classify_private_text(below, categories=ALL_CATEGORIES) - assert below_match is not None - assert below_match.category == CATEGORY_CREDENTIAL_WORD - assert classify_private_text(below, categories=TEXT_OWNER_CATEGORIES) is None - for value in (at, above): - strict = classify_private_text(value, categories=ALL_CATEGORIES) - assert strict is not None - assert strict.category == CATEGORY_CREDENTIAL - assert _internal_rejects(value) +def test_value_is_decided_by_a_signal_not_by_a_token_floor() -> None: + # The retired rule read one length: eight characters made a bearer value and + # seven made prose. That rejected ordinary English words beside the scheme name + # while releasing a six-character password value, so the boundary was a + # spelling accident. Each contract signal is now decided on its own shape, and + # a letter-only run changes class only at the named ceiling. + label = "Bear" + "er" + for length in (2, 5, 8, 14, OPAQUE_VALUE_MIN_LENGTH - 1): + prose = f"{label} " + "a" * length + match = classify_private_text(prose, categories=ALL_CATEGORIES) + assert match is not None, prose + assert match.category == CATEGORY_CREDENTIAL_WORD, prose + assert not _internal_rejects(prose), prose + for length in (OPAQUE_VALUE_MIN_LENGTH, OPAQUE_VALUE_MIN_LENGTH + 24): + assert _internal_rejects(f"{label} " + "a" * length), length + # A digit or a base64-only character makes the same position a value at the + # shortest length the arms read at all, which is what retired the floor. + for value in ("ab1", "abc123de", "ab+cd", "Qz8m2Xp7"): + assert _internal_rejects(f"{label} {value}"), value + # A quoted run is a value whatever it is made of. + assert _internal_rejects(f'{label} is "abc"') def test_migrating_the_owners_onto_the_classifier_closes_a_shape_hole() -> None: @@ -310,7 +322,7 @@ def _legacy_rejects(value: str) -> bool: def _corpus_samples() -> list[tuple[str, str, str]]: corpus = json.loads(CORPUS_PATH.read_text(encoding="utf-8")) - assert corpus["schema_version"] == "public_safe_text_corpus_v1" + assert corpus["schema_version"] == "public_safe_text_corpus_v2" tokens = corpus["tokens"] def render(template: str) -> str: @@ -335,8 +347,8 @@ def test_both_tiers_differ_from_the_old_rule_only_where_this_change_says() -> No # The differential the maintainer asked for (Refs #5136, direction 4), stated # as two named lists instead of prose. Anything the old rule rejected stays # rejected by the publication tier; the only values the internal-state tier - # newly releases are the five prose samples, and the only value the - # publication tier newly rejects is the colon form of a token assignment. + # newly releases are the prose samples, and the newly rejected forms are the + # two colon spellings plus the value shapes that arrive with no label at all. newly_released: list[str] = [] newly_rejected: list[str] = [] for group, sample_id, value in _corpus_samples(): @@ -355,9 +367,12 @@ def test_both_tiers_differ_from_the_old_rule_only_where_this_change_says() -> No "password_word_in_prose", "secret_word_in_prose", "rotation_note_names_two_schemes", - "bearer_value_below_named_floor", + "bearer_before_long_ordinary_word", + "password_copula_ordinary_word", + "disclosed_residual_short_letter_value", ] assert newly_rejected == [ + "token_space_digit_value", "token_assignment_colon", "raw_github_token_unlabeled", "private_key_block_unlabeled", @@ -371,74 +386,155 @@ def test_both_tiers_differ_from_the_old_rule_only_where_this_change_says() -> No assert match.category == CATEGORY_CREDENTIAL_WORD, sample_id -# The released class is enumerated, not sampled: every prefix x credential word x -# separator x value the two tiers can disagree about. The invariant is a -# biconditional, so a form that slips between the arms fails here. -_WORDS = ("Bear" + "er", "pass" + "word", "sec" + "ret") +# The form class is enumerated, not sampled: every prefix x credential label x +# connector x value the two tiers can disagree about. Each separator and each +# value carries the contract's *own* reading of it, so the expected verdict is a +# declared fact about the spelling rather than a pattern the implementation +# happens to use -- which is what makes this an oracle and not a mirror. +_LABELS = ("Bear" + "er", "pass" + "word", "sec" + "ret", "tok" + "en") +_WORD_ONLY_LABELS = frozenset({"Bear" + "er", "pass" + "word", "sec" + "ret"}) _PREFIXES = ("", "the ", "Read the ", "retry used the ") -_SEPARATORS = ("", " ", ",", ":", " =", ": ", "\t=", " of ", ". ") +# kind: "assignment" carries anything after the label; "value_connector" carries a +# value only when the next token is value-shaped; "none" and "foreign" connect a +# label to nothing at all, so the next token cannot be read as a value. +_SEPARATORS = ( + ("", "none"), + (" ", "value_connector"), + (",", "value_connector"), + (", ", "value_connector"), + (";", "value_connector"), + (" - ", "value_connector"), + (" is ", "value_connector"), + (" are ", "value_connector"), + (" set ", "value_connector"), + (" to ", "value_connector"), + (" of ", "value_connector"), + (" with ", "value_connector"), + (". ", "foreign"), + (":", "assignment"), + ("=", "assignment"), + (" =", "assignment"), + (": ", "assignment"), + ("\t=", "assignment"), +) +# kind: the contract signal a value alone would express, if a connector reaches it. _VALUES = ( - "", - "x", - "abc123", - "hunter2", - "a" * (BEARER_VALUE_MIN_LENGTH - 1), - "a" * BEARER_VALUE_MIN_LENGTH, - "a" * 20, - "QWxhZGRpbjpvcGVuIHNlc2FtZQ==", - "token expired", + ("", "empty"), + ("x", "short_letter_run"), + ("abc", "short_letter_run"), + ("authentication", "long_ordinary_word"), + ("a" * (OPAQUE_VALUE_MIN_LENGTH - 1), "ceiling_minus_one"), + ("a" * OPAQUE_VALUE_MIN_LENGTH, "at_ceiling"), + ("a" * (OPAQUE_VALUE_MIN_LENGTH + 14), "above_ceiling"), + ("abc123", "digit_bearing"), + ("hunter2", "digit_bearing"), + ("ab+cd", "base64_punctuation"), + ("QWxhZGRpbjpvcGVuIHNlc2FtZQ==", "base64_punctuation"), + ('"correcthorse"', "quoted_run"), + ("'abc123'", "quoted_run"), + ("token expired", "word_then_word"), ) +_VALUE_SHAPES = frozenset( + {"digit_bearing", "base64_punctuation", "at_ceiling", "above_ceiling", "quoted_run"} +) + +def _forms() -> list[tuple[str, str, str, str, str, str]]: + """Every (text, label, separator, value, connector kind, value kind) form.""" -def _word_form_corpus() -> list[str]: return [ - f"{prefix}{word}{separator}{value}" - for word in _WORDS + (f"{prefix}{label}{separator}{value}", label, separator, value, kind, value_kind) + for label in _LABELS for prefix in _PREFIXES - for separator in _SEPARATORS - for value in _VALUES + for separator, kind in _SEPARATORS + for value, value_kind in _VALUES ] -def test_tier_delta_is_exactly_the_word_category_over_the_whole_class() -> None: - # Only values the old rule actually rejected are candidates for release; a - # form neither rule ever saw ("Bearerx") is not a tier delta. - released = [ - value - for value in _word_form_corpus() - if _legacy_rejects(value) - and classify_private_text(value, categories=TEXT_OWNER_CATEGORIES) is None - ] - for value in released: - strict = classify_private_text(value, categories=ALL_CATEGORIES) - assert strict is not None, value - assert strict.category == CATEGORY_CREDENTIAL_WORD, value - # A released value must carry no assignment and no scheme-with-value, so - # the delta cannot be an unmeasured hole in the value arms. - assert LABELED_CREDENTIAL_ASSIGNMENT_PATTERN.search(value) is None, value - assert BEARER_VALUE_SHAPE_PATTERN.search(value) is None, value - assert OWNER_SECRET_LIKE.search(value) is None, value - # Nothing the old rule accepted is newly rejected inside either tier. - for value in _word_form_corpus(): - if _legacy_rejects(value): - assert _publication_rejects(value), value - assert len(released) > 0 - - -def test_adjacency_is_the_documented_limit_of_the_value_arms() -> None: - # A value named *beside* the word, with no assignment operator and no - # whitespace-adjacent scheme form, is prose to these arms. This is the known - # limit of the split and it is stated, not left implicit: the publication tier - # still rejects it, and a raw credential token is caught wherever it appears. - mention = "Bear" + "er, " + "a" * 20 - assert _publication_rejects(mention) - assert not _internal_rejects(mention) - adjacent = "Bear" + "er " + "a" * 20 - assert _internal_rejects(adjacent) +def _expected_carries_value(separator_kind: str, value_kind: str) -> bool: + """The contract applied to the declared factors, independent of any pattern.""" + + if separator_kind == "assignment": + return True + if separator_kind == "value_connector": + return value_kind in _VALUE_SHAPES + return False + + +def _expected_reads_the_label_as_a_word(separator: str, value: str) -> bool: + """Whether the label is a free-standing word in this form. + + The word arms name the scheme or the vault word, so they can only fire when + nothing word-shaped is glued to the label -- `Bearerx` is one run, not a word + plus a value. The whole form class is glued only where the separator is empty. + """ + + following = separator or value + return not (following[:1].isalnum() or following[:1] == "_") + + +def test_contract_biconditional_over_the_whole_form_class() -> None: + # Both directions over all 4,032 forms: nothing the contract calls a value is + # released by the internal-state tier, and nothing it calls prose is rejected + # there. The publication tier is pinned in the same sweep so the only + # difference between the two is the credential-word class. + forms = _forms() + assert len(forms) == 4_032, len(forms) + word_labels = {word.lower() for word in _WORD_ONLY_LABELS} + disagreements: list[str] = [] + for text, label, separator, value, kind, value_kind in forms: + expected = _expected_carries_value(kind, value_kind) + if _internal_rejects(text) is not expected: + disagreements.append(f"internal {text!r} expected={expected}") + expected_publication = expected or ( + label.lower() in word_labels + and _expected_reads_the_label_as_a_word(separator, value) + ) + if _publication_rejects(text) is not expected_publication: + disagreements.append(f"publication {text!r} expected={expected_publication}") + assert disagreements == [], disagreements[:12] + # Both classes are actually populated, so a vacuous sweep cannot pass. + values = [text for text, _l, _s, _v, kind, vk in forms if _expected_carries_value(kind, vk)] + prose = [text for text, _l, _s, _v, _k, _vk in forms if not _expected_carries_value(_k, _vk)] + assert values and prose + + +def test_documented_residual_is_a_short_letter_run_behind_a_prose_connector() -> None: + # The one class the contract cannot recognize: a letter-only run below the + # ceiling, beside the label, with no quotes and no assignment punctuation. + # Stated as a decision with a test, together with the three signals that each + # flip it back into the value class on their own. + label = "Bear" + "er" + residual = f"{label} " + "a" * (OPAQUE_VALUE_MIN_LENGTH - 1) + assert not _internal_rejects(residual), residual + assert _publication_rejects(residual), residual + for flipped in ( + f'{label} "{ "a" * (OPAQUE_VALUE_MIN_LENGTH - 1) }"', + f"{label} is " + "a" * (OPAQUE_VALUE_MIN_LENGTH - 1) + "1", + f"{label}: " + "a" * (OPAQUE_VALUE_MIN_LENGTH - 1), + f"{label} " + "a" * OPAQUE_VALUE_MIN_LENGTH, + ): + assert _internal_rejects(flipped), flipped + # A credential value that needs no label at all is still caught wherever it + # appears, so the residual cannot travel with an unlabeled token. raw_token = "ghp_" + "a" * 36 assert _internal_rejects(raw_token) and _publication_rejects(raw_token) +def test_each_contract_signal_is_wired_to_its_own_arm() -> None: + # Which pattern implements which signal, so a later edit cannot drop an arm and + # still pass the behavioral sweep above. The verdicts themselves are decided by + # the corpus and the form matrix, not here. + bearer = "Bear" + "er" + assert LABELED_CREDENTIAL_ASSIGNMENT_PATTERN.search(f"{bearer}:") + assert CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} abc123") + assert CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} is abc123") + assert CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer}, abc123") + assert QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f'{bearer} is "abc123"') + assert not CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} authentication") + assert not QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} authentication") + + def test_private_text_patterns_are_the_categorized_patterns_in_order() -> None: # The compat tuple every existing importer reads is derived from the # categorized list, same patterns, same order, so the publication tier still @@ -446,7 +542,7 @@ def test_private_text_patterns_are_the_categorized_patterns_in_order() -> None: assert PRIVATE_TEXT_PATTERNS == tuple( entry.pattern for entry in _CATEGORIZED_PRIVATE_TEXT_PATTERNS ) - assert len(PRIVATE_TEXT_PATTERNS) == 12 + assert len(PRIVATE_TEXT_PATTERNS) == 13 word_arms = tuple( entry.reason for entry in _CATEGORIZED_PRIVATE_TEXT_PATTERNS diff --git a/tests/control_plane/test_public_safe_text_owner_parity.py b/tests/control_plane/test_public_safe_text_owner_parity.py index 1ed58df655..11bdedc63f 100644 --- a/tests/control_plane/test_public_safe_text_owner_parity.py +++ b/tests/control_plane/test_public_safe_text_owner_parity.py @@ -43,7 +43,7 @@ def _load_corpus() -> dict[str, Any]: corpus = json.loads(CORPUS_PATH.read_text(encoding="utf-8")) - assert corpus["schema_version"] == "public_safe_text_corpus_v1" + assert corpus["schema_version"] == "public_safe_text_corpus_v2" return corpus @@ -73,6 +73,39 @@ def _samples(group: str) -> list[tuple[str, str]]: INTERNAL_STATE_PROSE_SAMPLES = _samples("internal_state_prose") +def _shaped_samples(group: str) -> list[tuple[str, str, str]]: + """The group's samples with the contract signal each row declares.""" + + corpus = _load_corpus() + tokens = corpus["tokens"] + return [ + (str(sample["id"]), _render(str(sample["template"]), tokens), str(sample["shape"])) + for sample in corpus[group] + ] + + +# Which named reason the contract expects for a declared signal. Written from the +# contract prose in the fixture, so a row cannot pass by matching whichever +# implementation pattern happens to be first: the reason has to be the one that +# signal owns. +SIGNAL_REASONS: dict[str, frozenset[str]] = { + "assignment_punctuation": frozenset( + {"credential-word assignment shape", "authorization header/assignment shape"} + ), + "shaped_value_token": frozenset({"credential label carrying a shaped value"}), + "quoted_value": frozenset({"credential label carrying a quoted value"}), + "opaque_value_run": frozenset({"credential label carrying a shaped value"}), + "unlabeled_secret_shape": frozenset({"credential-like value shape", "basic-auth credential value"}), + "non_credential_local_path": frozenset({"absolute home-directory path", "local filesystem path"}), + "credential_word_only": frozenset( + {"bearer auth scheme word", "password word", "secret word"} + ), + "remote_location_undecided": frozenset({"raw remote location URL"}), + "non_credential_prose": frozenset(), +} +CORPUS_GROUPS = ("public_safe", "private_looking", "internal_state_prose") + + def _ids(samples: list[tuple[str, str]]) -> list[str]: return [sample_id for sample_id, _ in samples] @@ -130,7 +163,7 @@ def test_private_looking_corpus_is_rejected_by_every_owner( def test_corpus_covers_the_reviewed_credential_shapes() -> None: - """Guard the corpus itself: the three shapes that motivated this contract.""" + """Guard the corpus itself: the shapes that motivated this contract.""" required = { "raw_header_basic", @@ -139,16 +172,64 @@ def test_corpus_covers_the_reviewed_credential_shapes() -> None: } assert required <= set(_ids(PRIVATE_LOOKING_SAMPLES)) assert "governance_prose_needs_owner_authorization" in _ids(PUBLIC_SAFE_SAMPLES) - # Direction 2's boundary is part of the contract, so the corpus must keep a - # sample on each side of the value floor and one assignment per demoted word. + # Direction 2's boundary is part of the contract, so the corpus keeps a sample + # for each signal, for the ordinary word the retired length floor used to + # reject, and for the residual the contract cannot recognize. private_ids = set(_ids(PRIVATE_LOOKING_SAMPLES)) assert { "bearer_value_at_named_floor", + "bearer_digit_value_below_old_floor", + "token_space_digit_value", + "copula_password_digit_value", + "space_secret_opaque_value", + "comma_bearer_opaque_value", + "dash_secret_opaque_value", + "quoted_password_passphrase", + "bearer_opaque_letter_run_at_ceiling", + "bearer_assignment_colon_bare_word", "password_assignment_short_value", "secret_assignment_colon", "token_assignment_colon", } <= private_ids - assert "bearer_word_in_prose" in _ids(INTERNAL_STATE_PROSE_SAMPLES) + prose_ids = set(_ids(INTERNAL_STATE_PROSE_SAMPLES)) + assert { + "bearer_word_in_prose", + "bearer_before_long_ordinary_word", + "password_copula_ordinary_word", + "disclosed_residual_short_letter_value", + } <= prose_ids + + +def test_every_corpus_row_is_declared_by_one_contract_signal() -> None: + # The fixture is the contract, so coverage goes both ways: every row names a + # declared signal, and every declared signal has at least one row. An arm added + # without a sample, or a sample whose verdict its own signal does not explain, + # fails here instead of in a reviewer's manual probe. + signals = set(_load_corpus()["contract_signals"]) + declared: set[str] = set() + for group in CORPUS_GROUPS: + for sample_id, text, shape in _shaped_samples(group): + assert shape in signals, sample_id + declared.add(shape) + strict = classify_private_text(text, categories=ALL_CATEGORIES) + internal = classify_private_text(text, categories=TEXT_OWNER_CATEGORIES) + if group == "public_safe": + assert internal is None, sample_id + if shape == "remote_location_undecided": + assert strict is not None, sample_id + assert strict.reason in SIGNAL_REASONS[shape], (sample_id, strict.reason) + else: + assert strict is None, sample_id + elif group == "private_looking": + assert strict is not None and internal is not None, sample_id + assert internal.reason == strict.reason, sample_id + assert internal.reason in SIGNAL_REASONS[shape], (sample_id, internal.reason) + else: + assert internal is None, sample_id + assert strict is not None, sample_id + assert strict.category == CATEGORY_CREDENTIAL_WORD, sample_id + assert strict.reason in SIGNAL_REASONS[shape], (sample_id, strict.reason) + assert declared == signals, signals ^ declared @pytest.mark.parametrize("owner", [check for _, check in OWNERS], ids=OWNER_IDS) diff --git a/tests/control_plane_ts/public_safe_text_corpus.test.ts b/tests/control_plane_ts/public_safe_text_corpus.test.ts index 48f04b4df7..6ca1e38bac 100644 --- a/tests/control_plane_ts/public_safe_text_corpus.test.ts +++ b/tests/control_plane_ts/public_safe_text_corpus.test.ts @@ -4,12 +4,13 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { - BEARER_VALUE_MIN_LENGTH, - BEARER_VALUE_SHAPE, + CONNECTED_CREDENTIAL_VALUE_SHAPE, CREDENTIAL_WORD_PATTERNS, INTERNAL_STATE_PRIVATE_TEXT_PATTERNS, INTERNAL_STATE_SHAPE_PATTERNS, + OPAQUE_VALUE_MIN_LENGTH, PRIVATE_TEXT_PATTERNS, + QUOTED_CREDENTIAL_VALUE_SHAPE, VISION_REFRESH_REQUEST_SCHEMA, buildVisionCheckpoint, } from "../../loopx/control_plane/goals/vision_checkpoint.ts"; @@ -25,11 +26,13 @@ const CORPUS_PATH = fileURLToPath( interface CorpusSample { id: string; template: string; + shape?: string; note?: string; } interface Corpus { schema_version: string; + contract_signals: Record; tokens: Record; public_safe: CorpusSample[]; private_looking: CorpusSample[]; @@ -37,7 +40,7 @@ interface Corpus { } const corpus = JSON.parse(readFileSync(CORPUS_PATH, "utf8")) as Corpus; -assert.equal(corpus.schema_version, "public_safe_text_corpus_v1"); +assert.equal(corpus.schema_version, "public_safe_text_corpus_v2"); function render(template: string): string { return template.replace(/\{([A-Z][A-Z_]*)\}/g, (_match, name: string) => { @@ -115,41 +118,70 @@ test("the internal-state tier drops the words and adds the ported shapes", () => ); }); -test("the bearer floor is the named constant the pattern carries", () => { - // BEARER_VALUE_SHAPE is a literal so the digest guard can fold it, which means - // the constant has to be checked against the source rather than used to build it. - const floor = new RegExp(`\\{${BEARER_VALUE_MIN_LENGTH},\\}`); - assert.ok(floor.test(BEARER_VALUE_SHAPE.source), BEARER_VALUE_SHAPE.source); - assert.ok(!BEARER_VALUE_SHAPE.test(`Bearer ${"a".repeat(BEARER_VALUE_MIN_LENGTH - 1)}`)); - assert.ok(BEARER_VALUE_SHAPE.test(`Bearer ${"a".repeat(BEARER_VALUE_MIN_LENGTH)}`)); +test("every corpus row declares a contract signal and every signal has a row", () => { + // The fixture is the contract both runtimes are pinned to, so its structure is + // checked here as well as in Python: a signal nobody samples, or a row whose + // verdict its own signal does not name, fails in both suites. + const signals = new Set(Object.keys(corpus.contract_signals)); + const declared = new Set(); + for (const group of ["public_safe", "private_looking", "internal_state_prose"] as const) { + for (const sample of corpus[group]) { + assert.ok(typeof sample.shape === "string", `${sample.id} declares no signal`); + assert.ok(signals.has(sample.shape), `${sample.id} names unknown signal ${sample.shape}`); + declared.add(sample.shape); + } + } + assert.deepEqual([...declared].sort(), [...signals].sort()); +}); + +test("the letter-run ceiling is the named constant the pattern carries", () => { + // The connector arms are literals so the digest guard can fold them, which means + // the ceiling has to be checked against the source rather than used to build it. + const ceiling = new RegExp(`\\{${OPAQUE_VALUE_MIN_LENGTH},\\}`); + assert.ok(ceiling.test(CONNECTED_CREDENTIAL_VALUE_SHAPE.source)); + const below = `Bearer ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH - 1)}`; + const at = `Bearer ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH)}`; + assert.ok(!CONNECTED_CREDENTIAL_VALUE_SHAPE.test(below), below); + assert.ok(CONNECTED_CREDENTIAL_VALUE_SHAPE.test(at), at); + // Shape signals need no length at all, which is what retired the bearer floor. + assert.ok(CONNECTED_CREDENTIAL_VALUE_SHAPE.test("Bearer abc123")); + assert.ok(QUOTED_CREDENTIAL_VALUE_SHAPE.test(`Bearer is "${"a".repeat(8)}"`)); }); test("the narrower tier still rejects every value and assignment shape", () => { - // Each value below is one of the demoted words carrying something. Assembling - // them keeps the fixture's discipline of carrying no literal credential text. + // Each value below is one of the demoted labels carrying something, decided by + // which contract signal it expresses. Assembling them keeps the fixture's + // discipline of carrying no literal credential text. const bearer = "Bear" + "er"; const password = "pass" + "word"; const secret = "sec" + "ret"; const token = "tok" + "en"; const rejected = [ - `${bearer} ${"a".repeat(8)}`, - `${bearer} ${"a".repeat(40)}`, + `${bearer} abc123def456`, + `${bearer} abc123de`, + `${bearer}, aB3d9QkLm`, + `${bearer} is abc123`, + `${bearer} ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH)}`, + `${bearer} ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH + 24)}`, `${password}=hunter2`, + `${password} is hunter2`, + `${password} is "${"a".repeat(8)}"`, `${secret}: env`, + `${secret} - Qz8m2Xp7`, `${token}: abc123`, + `${token} abc123def`, "/Us" + "ers/operator/state.json", ]; for (const value of rejected) { - assert.throws( - () => checkpoint(value), - /private-looking value/, - `one char below/above floor: ${value.slice(0, 12)}`, - ); + assert.throws(() => checkpoint(value), /private-looking value/, value.slice(0, 24)); } const accepted = [ `the ${bearer} token expired`, `the ${password} is stored in the vault`, `read the ${secret} from the environment`, + `${bearer} authentication is required here`, + `the ${password} is configured per environment`, + `${secret} is ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH - 1)}`, ]; for (const value of accepted) { assert.doesNotThrow(() => checkpoint(value), value.slice(0, 24)); diff --git a/tests/fixtures/public_safe_text_corpus.json b/tests/fixtures/public_safe_text_corpus.json index 6e578042fc..e362ae2cd1 100644 --- a/tests/fixtures/public_safe_text_corpus.json +++ b/tests/fixtures/public_safe_text_corpus.json @@ -1,150 +1,306 @@ { - "schema_version": "public_safe_text_corpus_v1", + "schema_version": "public_safe_text_corpus_v2", "purpose": "One corpus for every real public-safe text validator owner: loopx/public_safe_text.py (used by feedback, authority, and boundary_authority) and the TypeScript Vision checkpoint. Both runtimes must agree on every sample.", "sample_encoding": "Each sample is a template. Substitute every {NAME} placeholder with the joined parts of tokens[NAME], and {NAME_LOWER} with the lowercased join. Samples are assembled at test time so this fixture carries no literal credential-looking string.", "bucket_semantics": "public_safe is accepted by every owner and by the publication policy. private_looking is rejected by every owner and by the publication policy. internal_state_prose is the two-tier set from Refs #5136 direction 2: the four owners validate LoopX's own state and accept it, while the stricter publication policy (ALL_CATEGORIES / find_private_text_match) still rejects it.", + "contract_signals": { + "assignment_punctuation": "A credential label followed by ':' or '=' carries whatever follows it. No value shape and no length applies, because the label already asserts an assignment.", + "shaped_value_token": "A connector (whitespace, comma, semicolon, dash, or a copula such as 'is' or 'set to') followed by a token that contains a digit or one of the base64-only characters + / =.", + "quoted_value": "The same connector followed by a quoted run. Quoting is its own signal, so a letter-only passphrase written as a quoted value is still a value.", + "opaque_value_run": "The same connector followed by an unbroken letter-only run of OPAQUE_VALUE_MIN_LENGTH (16) characters or longer.", + "credential_word_only": "The label stands beside ordinary words: no assignment punctuation, and the next token is a letter-only run below the ceiling. This is prose to the internal-state tier and is still a credential word to the publication tier.", + "unlabeled_secret_shape": "A credential value that arrives with no label at all -- a raw token, a private key block -- caught by the ported shape detector.", + "non_credential_local_path": "A local filesystem path, which every tier in scope rejects.", + "non_credential_prose": "Text with no credential label and no other recognized category.", + "remote_location_undecided": "A raw remote location. The internal-state policy these four owners enforce never rejected a URL and still does not; the publication tier recognizes it, and deciding per face whether internal state may carry one is the caller migration still open in #5136." + }, "tokens": { - "AUTHZ": ["Author", "ization"], - "BASIC": ["Bas", "ic"], - "BEARER": ["Bear", "er"], - "TOKEN": ["tok", "en"], - "PASSWORD": ["pass", "word"], - "SECRET": ["sec", "ret"], - "HOME_PATH": ["/Us", "ers/"], - "GH_TOKEN_PREFIX": ["gh", "p_"], - "KEY_HEAD": ["---", "---"], - "PRIVATE_KEY": ["PRIVATE KE", "Y"] + "AUTHZ": [ + "Author", + "ization" + ], + "BASIC": [ + "Bas", + "ic" + ], + "BEARER": [ + "Bear", + "er" + ], + "TOKEN": [ + "tok", + "en" + ], + "PASSWORD": [ + "pass", + "word" + ], + "SECRET": [ + "sec", + "ret" + ], + "HOME_PATH": [ + "/Us", + "ers/" + ], + "GH_TOKEN_PREFIX": [ + "gh", + "p_" + ], + "KEY_HEAD": [ + "---", + "---" + ], + "PRIVATE_KEY": [ + "PRIVATE KE", + "Y" + ], + "LONG_LETTER_RUN": [ + "correct", + "horse", + "batter", + "ystaple" + ], + "SHORT_LETTER_RUN": [ + "short", + "letter", + "valu" + ], + "ORDINARY_LONG_WORD": [ + "authentic", + "ation" + ], + "DIGIT_VALUE": [ + "abc", + "123def" + ], + "OPAQUE_VALUE": [ + "aB3", + "d9QkLm" + ] }, "public_safe": [ { "id": "governance_prose_needs_owner_authorization", - "template": "needs owner {AUTHZ_LOWER} before delivery." + "template": "needs owner {AUTHZ_LOWER} before delivery.", + "shape": "non_credential_prose" }, { "id": "governance_prose_owner_authorized_pr", - "template": "the owner-authorized pull request is open" + "template": "the owner-authorized pull request is open", + "shape": "non_credential_prose" }, { "id": "governance_prose_sentence_initial", - "template": "{AUTHZ} remains with the human owner" + "template": "{AUTHZ} remains with the human owner", + "shape": "non_credential_prose" }, { "id": "governance_prose_release_gate", - "template": "Release publication waits for explicit owner {AUTHZ_LOWER}." + "template": "Release publication waits for explicit owner {AUTHZ_LOWER}.", + "shape": "non_credential_prose" }, { "id": "ordinary_word_basic", - "template": "the {BASIC_LOWER} control-plane contract stays unchanged" + "template": "the {BASIC_LOWER} control-plane contract stays unchanged", + "shape": "non_credential_prose" }, { "id": "ordinary_word_token_without_assignment", - "template": "the action kind is a public-safe {TOKEN_LOWER}" + "template": "the action kind is a public-safe {TOKEN_LOWER}", + "shape": "non_credential_prose", + "note": "No credential word arm covers the token label, so this is public-safe in both tiers; the word arms are the scheme name and the two vault words." }, { "id": "ordinary_public_url", "template": "metrics published at https://example.com/run-7/metrics.json", + "shape": "remote_location_undecided", "note": "The internal-state tier does not decide URLs: the rule these four owners enforced before never rejected one either, and per-face URL policy is the caller-migration work still open in #5136." } ], "private_looking": [ { "id": "raw_header_basic", - "template": "{AUTHZ}: {BASIC} QWxhZGRpbjpvcGVuIHNlc2FtZQ==" + "template": "{AUTHZ}: {BASIC} QWxhZGRpbjpvcGVuIHNlc2FtZQ==", + "shape": "assignment_punctuation" }, { "id": "raw_header_bearer", - "template": "{AUTHZ}: {BEARER} abc123def456" + "template": "{AUTHZ}: {BEARER} abc123def456", + "shape": "shaped_value_token" }, { "id": "assignment_bearer", - "template": "{AUTHZ_LOWER}={BEARER} abc123def456" + "template": "{AUTHZ_LOWER}={BEARER} abc123def456", + "shape": "shaped_value_token" }, { "id": "assignment_spaced_basic", - "template": "{AUTHZ_LOWER} = {BASIC} QWxhZGRpbjpvcGVu" + "template": "{AUTHZ_LOWER} = {BASIC} QWxhZGRpbjpvcGVu", + "shape": "assignment_punctuation" }, { "id": "quoted_json_key_basic", - "template": "{\"{AUTHZ}\": \"{BASIC} QWxhZGRpbjpvcGVu\"}" + "template": "{\"{AUTHZ}\": \"{BASIC} QWxhZGRpbjpvcGVu\"}", + "shape": "assignment_punctuation" }, { "id": "quoted_json_key_lowercase_bearer", - "template": "{\"{AUTHZ_LOWER}\": \"{BEARER} abc123def456\"}" + "template": "{\"{AUTHZ_LOWER}\": \"{BEARER} abc123def456\"}", + "shape": "shaped_value_token" }, { "id": "single_quoted_key_basic", - "template": "{'{AUTHZ}': '{BASIC} QWxhZGRpbjpvcGVu'}" + "template": "{'{AUTHZ}': '{BASIC} QWxhZGRpbjpvcGVu'}", + "shape": "assignment_punctuation" }, { "id": "bare_basic_credential_value", - "template": "{BASIC} QWxhZGRpbjpvcGVuIHNlc2FtZQ==" + "template": "{BASIC} QWxhZGRpbjpvcGVuIHNlc2FtZQ==", + "shape": "unlabeled_secret_shape", + "note": "The scheme value needs no label: the ported shape arm carries it." }, { "id": "bare_bearer_scheme", "template": "{BEARER} abc123def456", - "note": "Rejected by the bearer-value arm, not by the word arm: 12 chars >= BEARER_VALUE_MIN_LENGTH." + "shape": "shaped_value_token", + "note": "Carries a digit, so the shape signal rejects it at any length; the bearer arm this replaced needed eight characters and nothing else." }, { "id": "bearer_value_at_named_floor", "template": "{BEARER} abc123de", - "note": "Exactly BEARER_VALUE_MIN_LENGTH (8) characters of value." + "shape": "shaped_value_token", + "note": "Eight characters, the shortest value the retired length floor used to catch by size alone. It is now caught by shape." + }, + { + "id": "bearer_digit_value_below_old_floor", + "template": "{BEARER} abc123d", + "shape": "shaped_value_token", + "note": "Moved out of the prose bucket: it was a word mention only because it was one character below the retired floor, while it carries a digit." }, { "id": "token_assignment", - "template": "{TOKEN_LOWER}=abc123def456" + "template": "{TOKEN_LOWER}=abc123def456", + "shape": "assignment_punctuation" + }, + { + "id": "token_space_digit_value", + "template": "{TOKEN_LOWER} {DIGIT_VALUE}", + "shape": "shaped_value_token", + "note": "Newly rejected in BOTH tiers: neither the old word-only rule nor the previous value arms saw a token label with a space-separated value." + }, + { + "id": "copula_password_digit_value", + "template": "{PASSWORD_LOWER} is {DIGIT_VALUE}", + "shape": "shaped_value_token", + "note": "The copula spelling the whitespace-only arm missed." + }, + { + "id": "space_secret_opaque_value", + "template": "{SECRET_LOWER} {OPAQUE_VALUE}", + "shape": "shaped_value_token" + }, + { + "id": "comma_bearer_opaque_value", + "template": "{BEARER}, {OPAQUE_VALUE}", + "shape": "shaped_value_token", + "note": "A comma before the value is still an assignment in prose spelling; the previous adjacency rule called this one prose." + }, + { + "id": "dash_secret_opaque_value", + "template": "{SECRET_LOWER} - {OPAQUE_VALUE}", + "shape": "shaped_value_token" + }, + { + "id": "quoted_password_passphrase", + "template": "the {PASSWORD_LOWER} is \"{LONG_LETTER_RUN}\"", + "shape": "quoted_value", + "note": "Letter-only and un-quantifiable by shape, but quoted: the quote is the signal." + }, + { + "id": "bearer_opaque_letter_run_at_ceiling", + "template": "{BEARER} {LONG_LETTER_RUN}", + "shape": "opaque_value_run" + }, + { + "id": "bearer_assignment_colon_bare_word", + "template": "{BEARER}: env", + "shape": "assignment_punctuation" }, { "id": "password_assignment_short_value", "template": "{PASSWORD_LOWER}=hunter2", + "shape": "assignment_punctuation", "note": "The assignment form carries no value-length floor, so a 6-char value is still rejected after the word arm moved to credential_word." }, { "id": "secret_assignment_colon", "template": "{SECRET_LOWER}: env", - "note": "Newly rejected: the old rule only had an equals form for this label." + "shape": "assignment_punctuation", + "note": "Newly rejected by the assignment arm the old rule spelled only with an equals sign." }, { "id": "token_assignment_colon", "template": "{TOKEN_LOWER}: abc123", - "note": "Newly rejected: the old text-owner rule matched token= but not the colon form." + "shape": "assignment_punctuation", + "note": "Newly rejected: the old text-owner rule knew the equals spelling of this label but not the colon one." }, { "id": "local_home_path", - "template": "{HOME_PATH}operator/work/loopx/state.json" + "template": "{HOME_PATH}operator/work/loopx/state.json", + "shape": "non_credential_local_path" }, { "id": "raw_github_token_unlabeled", "template": "carry {GH_TOKEN_PREFIX}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa forward", + "shape": "unlabeled_secret_shape", "note": "Newly rejected by the internal-state tier: the word-only rule had no arm for a credential value that arrives without a label." }, { "id": "private_key_block_unlabeled", "template": "{KEY_HEAD}BEGIN RSA {PRIVATE_KEY}-----", - "note": "Same class as the raw token: recognized only by the value-shape arm." + "shape": "unlabeled_secret_shape", + "note": "Same class as a label-free value: recognized only by the ported value-shape arm." } ], "internal_state_prose": [ { "id": "bearer_word_in_prose", "template": "the {BEARER} token expired", + "shape": "credential_word_only", "note": "The maintainer's own example in #5136: a sentence about an expired scheme is not a leak." }, { "id": "password_word_in_prose", - "template": "the {PASSWORD_LOWER} is stored in the vault" + "template": "the {PASSWORD_LOWER} is stored in the vault", + "shape": "credential_word_only" }, { "id": "secret_word_in_prose", - "template": "read the {SECRET_LOWER} from the environment" + "template": "read the {SECRET_LOWER} from the environment", + "shape": "credential_word_only" }, { "id": "rotation_note_names_two_schemes", - "template": "{AUTHZ_LOWER} rotation is pending; reissue the {BEARER} value and the vault {SECRET_LOWER}." + "template": "{AUTHZ_LOWER} rotation is pending; reissue the {BEARER} value and the vault {SECRET_LOWER}.", + "shape": "credential_word_only" + }, + { + "id": "bearer_before_long_ordinary_word", + "template": "{BEARER} {ORDINARY_LONG_WORD} is required here", + "shape": "credential_word_only", + "note": "The false positive the review reported: an ordinary English word of eleven letters beside the scheme name is prose, not a token." + }, + { + "id": "password_copula_ordinary_word", + "template": "the {PASSWORD_LOWER} is configured per environment", + "shape": "credential_word_only" }, { - "id": "bearer_value_below_named_floor", - "template": "the {BEARER} abc123d was rejected", - "note": "One character below BEARER_VALUE_MIN_LENGTH, so it is a word mention. Both sides of the floor are pinned; the floor is a named constant, not a regex accident." + "id": "disclosed_residual_short_letter_value", + "template": "{SECRET_LOWER} is {SHORT_LETTER_RUN}", + "shape": "credential_word_only", + "note": "The contract's stated limit, pinned rather than argued: a letter-only run of fifteen characters or fewer beside the label, with no quotes and no assignment punctuation, is prose to this owner. The publication tier still rejects the mention, and the tier documents itself as not a credential-storage exemption. Fifteen letters is one below the ceiling." } ] } From 0b7aa45b9e5bbc81b1a0f2dfaa56191adc22febe Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:51:39 +0800 Subject: [PATCH 07/12] docs(public-private-boundary): state the four value signals and the residual The published contract said a bearer scheme carrying a value of at least eight characters is what separates prose from a credential. It now names the four signals, says that no signal reads a word's length to decide whether a credential word is present, and states the residual instead of leaving it implicit. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- docs/public-private-boundary.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/public-private-boundary.md b/docs/public-private-boundary.md index 571a1d31b0..0a466de8cc 100644 --- a/docs/public-private-boundary.md +++ b/docs/public-private-boundary.md @@ -137,11 +137,24 @@ destination needs: rejecting it. "the Bearer token expired" describes a credential; it does not carry one. -The value arms stay in every policy, so the narrower tier releases prose only: a -credential label followed by an assignment operator, or a bearer scheme carrying -a value of at least eight characters, is rejected by both tiers. A raw credential token with -no label at all -- a GitHub token, a private key block -- is rejected by both -tiers too, which the word-only rule never caught. +The value arms stay in every policy, so the narrower tier releases prose only. A +label carries a value when one of four independent signals is present: an +assignment operator (`:` or `=`), which carries whatever follows with no length +condition; a connector (whitespace, comma, semicolon, dash, or a copula such as +"is" or "set to") followed by a token containing a digit or one of `+ / =`; the +same connector followed by a quoted run; or the same connector followed by an +unbroken letter-only run of `OPAQUE_VALUE_MIN_LENGTH` (16) characters or more. A +raw credential token with no label at all -- a GitHub token, a private key block +-- is rejected by both tiers too, which the word-only rule never caught. + +No signal reads the length of a word to decide whether a credential *word* is +present, so a scheme name beside an ordinary English word stays prose while the +same word with one digit appended is a value. One residual is stated rather than +argued: a letter-only run of fifteen characters or fewer, written beside the label +with no quotes and no assignment operator, is prose to this owner. The publication +tier still rejects the mention, and the internal-state tier is not a +credential-storage exemption; the corpus carries that row as +`disclosed_residual_short_letter_value` so the limit stays a decision with a test. A URL is in neither tier's internal-state policy yet. The rule these four owners enforced before never rejected an ordinary link, so this split does not start to; From e5f26734716ad0c082971e903af5347e1910fece Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:51:40 +0800 Subject: [PATCH 08/12] chore(semantic): refresh project registry I/O site lines The migrated import and call-site lines in authority.py and feedback.py moved five census coordinates. Regenerated in place with the repository generator; only the line field changed for those five sites, with no site, kind, api or classification edits, which is what the two architecture tests compare. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- loopx/semantics/project_registry_io_manifest_v1.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 78b65b58d2..52cf619dc1 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -79,7 +79,7 @@ }, { "site": "loopx/authority.py::.import_doc_registry_authority::codec_read:load_project_registry#1", - "line": 575, + "line": 578, "column": 20, "kind": "codec_read", "api": "load_project_registry", @@ -87,7 +87,7 @@ }, { "site": "loopx/authority.py::.import_doc_registry_authority::codec_write:mutate_project_registry#1", - "line": 582, + "line": 585, "column": 35, "kind": "codec_write", "api": "mutate_project_registry", @@ -95,7 +95,7 @@ }, { "site": "loopx/authority.py::.register_authority_source::codec_read:load_project_registry#1", - "line": 461, + "line": 464, "column": 20, "kind": "codec_read", "api": "load_project_registry", @@ -103,7 +103,7 @@ }, { "site": "loopx/authority.py::.register_authority_source::codec_write:mutate_project_registry#1", - "line": 468, + "line": 471, "column": 35, "kind": "codec_write", "api": "mutate_project_registry", @@ -1759,7 +1759,7 @@ }, { "site": "loopx/feedback.py::.append_human_reward::codec_read:load_registry#1", - "line": 414, + "line": 418, "column": 16, "kind": "codec_read", "api": "load_registry", From ed333a57b717fb10175c389a3b46ea90859b2cb6 Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:56:57 +0800 Subject: [PATCH 09/12] test(public-safety): lock the shared connector, labels and ceiling across runtimes The corpus already fails when one runtime's verdict drifts, but it reports a sample id rather than the spelling that moved. The value connector, the credential label list and the letter-run ceiling are now asserted to be the same text in both owners, so a one-sided edit names the piece that drifted. This is the cheapest form of "one contract, two runtimes" that does not require generating a regular expression from the fixture. Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- .../test_public_safe_text_classifier.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/control_plane/test_public_safe_text_classifier.py b/tests/control_plane/test_public_safe_text_classifier.py index 20a76cfb52..87051d88bd 100644 --- a/tests/control_plane/test_public_safe_text_classifier.py +++ b/tests/control_plane/test_public_safe_text_classifier.py @@ -79,6 +79,11 @@ _SECRET_WORD = "sec" + "ret" _TOKEN_WORD = "tok" + "en" +# The two spellings both owners must share, assembled for the same reason as the +# words above so this file carries no literal credential label. +SHARED_CONNECTOR = r"(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*" +SHARED_LABELS = "(?:" + "Bear" + "er|tok" + "en|pass" + "word|sec" + "ret)" + def _publication_rejects(value: str) -> bool: """Recognized by the full category set, the repository-publication tier.""" @@ -521,6 +526,23 @@ def test_documented_residual_is_a_short_letter_run_behind_a_prose_connector() -> assert _internal_rejects(raw_token) and _publication_rejects(raw_token) +def test_the_two_runtimes_read_the_same_contract_spelling() -> None: + # The connector, the label list and the letter-run ceiling are the three pieces + # of spelling the two owners must share. Drift in one of them changes behavior + # on one side of the corpus only, which the parity sweep would catch late and + # noisily; this names the drifted piece instead of the failing sample. + typescript = (REPOSITORY_ROOT / "loopx/control_plane/goals/vision_checkpoint.ts").read_text( + encoding="utf-8" + ) + assert SHARED_CONNECTOR in typescript, "TypeScript connector drifted" + assert SHARED_LABELS in typescript, "TypeScript label list drifted" + assert f"[A-Za-z]{{{OPAQUE_VALUE_MIN_LENGTH},}}" in typescript, "TypeScript ceiling drifted" + assert SHARED_CONNECTOR in CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.pattern + assert SHARED_CONNECTOR in QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN.pattern + assert SHARED_LABELS in CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.pattern + assert SHARED_LABELS in QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN.pattern + + def test_each_contract_signal_is_wired_to_its_own_arm() -> None: # Which pattern implements which signal, so a later edit cannot drop an arm and # still pass the behavioral sweep above. The verdicts themselves are decided by From f738ecf272bf873e629e7afcf2555bce8ee0357e Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:54:54 +0800 Subject: [PATCH 10/12] fix(public-safety): reject single-character credential values Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- loopx/control_plane/goals/vision_checkpoint.ts | 4 ++-- loopx/public_safe_text.py | 8 +++++--- .../test_public_safe_text_classifier.py | 10 ++++++---- .../test_public_safe_text_owner_parity.py | 2 ++ .../public_safe_text_corpus.test.ts | 5 +++++ tests/fixtures/public_safe_text_corpus.json | 18 ++++++++++++++++++ 6 files changed, 38 insertions(+), 9 deletions(-) diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index 6cf180b5ef..e963a16650 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -131,9 +131,9 @@ const BASIC_CREDENTIAL_VALUE = // value shape. The tie between the quantifier and OPAQUE_VALUE_MIN_LENGTH is // asserted in the corpus test instead of built into the pattern here. export const CONNECTED_CREDENTIAL_VALUE_SHAPE = - /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*(?:(?=[A-Za-z0-9._~+\/=-]{2,})(?=[A-Za-z0-9._~+\/=-]*[0-9+\/=])[A-Za-z0-9._~+\/=-]+|[A-Za-z]{16,})/i; + /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*(?:(?=[A-Za-z0-9._~+\/=-]*[0-9+\/=])[A-Za-z0-9._~+\/=-]+|[A-Za-z]{16,})/i; export const QUOTED_CREDENTIAL_VALUE_SHAPE = - /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*["'][^"'\n]{2,}["']/i; + /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*["'][^"'\n]+["']/i; const LABELED_CREDENTIAL_ASSIGNMENT = /\b(?:Bearer|token|password|secret)\s*[:=]/i; // Ported from the Python owner's two in-policy shape detectors, so one corpus // yields one verdict in both runtimes (Refs #5136, direction 4). The third diff --git a/loopx/public_safe_text.py b/loopx/public_safe_text.py index 78a5f3f71a..1e35d689af 100644 --- a/loopx/public_safe_text.py +++ b/loopx/public_safe_text.py @@ -110,9 +110,11 @@ CREDENTIAL_VALUE_CONNECTOR_PATTERN_SOURCE = ( r"(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*" ) -# A run that carries a digit or a base64-only character, at two characters long. +# A run that carries a digit or a base64-only character. The signal has no +# length floor: a one-character value still carries a credential value, and the +# assignment arm already makes the same length-independent decision. _SHAPE_VALUE_TOKEN_SOURCE = ( - r"(?=[A-Za-z0-9._~+/=-]{2,})(?=[A-Za-z0-9._~+/=-]*[0-9+/=])[A-Za-z0-9._~+/=-]+" + r"(?=[A-Za-z0-9._~+/=-]*[0-9+/=])[A-Za-z0-9._~+/=-]+" ) _OPAQUE_VALUE_RUN_SOURCE = r"[A-Za-z]{%d,}" % OPAQUE_VALUE_MIN_LENGTH @@ -139,7 +141,7 @@ r"\b" + CREDENTIAL_LABEL_PATTERN_SOURCE + CREDENTIAL_VALUE_CONNECTOR_PATTERN_SOURCE - + r"[\"'][^\"'\n]{2,}[\"']", + + r"[\"'][^\"'\n]+[\"']", re.IGNORECASE, ) diff --git a/tests/control_plane/test_public_safe_text_classifier.py b/tests/control_plane/test_public_safe_text_classifier.py index 87051d88bd..04f6e3d849 100644 --- a/tests/control_plane/test_public_safe_text_classifier.py +++ b/tests/control_plane/test_public_safe_text_classifier.py @@ -172,11 +172,12 @@ def test_value_is_decided_by_a_signal_not_by_a_token_floor() -> None: assert not _internal_rejects(prose), prose for length in (OPAQUE_VALUE_MIN_LENGTH, OPAQUE_VALUE_MIN_LENGTH + 24): assert _internal_rejects(f"{label} " + "a" * length), length - # A digit or a base64-only character makes the same position a value at the - # shortest length the arms read at all, which is what retired the floor. - for value in ("ab1", "abc123de", "ab+cd", "Qz8m2Xp7"): + # A digit or a base64-only character makes the same position a value without + # a length floor, which is what retired the floor. + for value in ("1", "+", "ab1", "abc123de", "ab+cd", "Qz8m2Xp7"): assert _internal_rejects(f"{label} {value}"), value - # A quoted run is a value whatever it is made of. + # A non-empty quoted run is a value whatever it is made of. + assert _internal_rejects(f'{label} is "a"') assert _internal_rejects(f'{label} is "abc"') @@ -378,6 +379,7 @@ def test_both_tiers_differ_from_the_old_rule_only_where_this_change_says() -> No ] assert newly_rejected == [ "token_space_digit_value", + "copula_token_single_digit_value", "token_assignment_colon", "raw_github_token_unlabeled", "private_key_block_unlabeled", diff --git a/tests/control_plane/test_public_safe_text_owner_parity.py b/tests/control_plane/test_public_safe_text_owner_parity.py index 11bdedc63f..484e3764a6 100644 --- a/tests/control_plane/test_public_safe_text_owner_parity.py +++ b/tests/control_plane/test_public_safe_text_owner_parity.py @@ -181,10 +181,12 @@ def test_corpus_covers_the_reviewed_credential_shapes() -> None: "bearer_digit_value_below_old_floor", "token_space_digit_value", "copula_password_digit_value", + "copula_token_single_digit_value", "space_secret_opaque_value", "comma_bearer_opaque_value", "dash_secret_opaque_value", "quoted_password_passphrase", + "quoted_password_single_letter_value", "bearer_opaque_letter_run_at_ceiling", "bearer_assignment_colon_bare_word", "password_assignment_short_value", diff --git a/tests/control_plane_ts/public_safe_text_corpus.test.ts b/tests/control_plane_ts/public_safe_text_corpus.test.ts index 6ca1e38bac..4df9f233f0 100644 --- a/tests/control_plane_ts/public_safe_text_corpus.test.ts +++ b/tests/control_plane_ts/public_safe_text_corpus.test.ts @@ -144,7 +144,10 @@ test("the letter-run ceiling is the named constant the pattern carries", () => { assert.ok(!CONNECTED_CREDENTIAL_VALUE_SHAPE.test(below), below); assert.ok(CONNECTED_CREDENTIAL_VALUE_SHAPE.test(at), at); // Shape signals need no length at all, which is what retired the bearer floor. + assert.ok(CONNECTED_CREDENTIAL_VALUE_SHAPE.test("Bearer 1")); + assert.ok(CONNECTED_CREDENTIAL_VALUE_SHAPE.test("Bearer +")); assert.ok(CONNECTED_CREDENTIAL_VALUE_SHAPE.test("Bearer abc123")); + assert.ok(QUOTED_CREDENTIAL_VALUE_SHAPE.test('Bearer is "a"')); assert.ok(QUOTED_CREDENTIAL_VALUE_SHAPE.test(`Bearer is "${"a".repeat(8)}"`)); }); @@ -158,6 +161,7 @@ test("the narrower tier still rejects every value and assignment shape", () => { const token = "tok" + "en"; const rejected = [ `${bearer} abc123def456`, + `${bearer} 1`, `${bearer} abc123de`, `${bearer}, aB3d9QkLm`, `${bearer} is abc123`, @@ -165,6 +169,7 @@ test("the narrower tier still rejects every value and assignment shape", () => { `${bearer} ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH + 24)}`, `${password}=hunter2`, `${password} is hunter2`, + `${password} is "a"`, `${password} is "${"a".repeat(8)}"`, `${secret}: env`, `${secret} - Qz8m2Xp7`, diff --git a/tests/fixtures/public_safe_text_corpus.json b/tests/fixtures/public_safe_text_corpus.json index e362ae2cd1..2578a9885c 100644 --- a/tests/fixtures/public_safe_text_corpus.json +++ b/tests/fixtures/public_safe_text_corpus.json @@ -74,6 +74,12 @@ "abc", "123def" ], + "ONE_DIGIT": [ + "1" + ], + "ONE_LETTER": [ + "a" + ], "OPAQUE_VALUE": [ "aB3", "d9QkLm" @@ -195,6 +201,12 @@ "shape": "shaped_value_token", "note": "The copula spelling the whitespace-only arm missed." }, + { + "id": "copula_token_single_digit_value", + "template": "{TOKEN_LOWER} is {ONE_DIGIT}", + "shape": "shaped_value_token", + "note": "The token-shape signal has no length floor: a one-character digit is still a credential value." + }, { "id": "space_secret_opaque_value", "template": "{SECRET_LOWER} {OPAQUE_VALUE}", @@ -217,6 +229,12 @@ "shape": "quoted_value", "note": "Letter-only and un-quantifiable by shape, but quoted: the quote is the signal." }, + { + "id": "quoted_password_single_letter_value", + "template": "the {PASSWORD_LOWER} is \"{ONE_LETTER}\"", + "shape": "quoted_value", + "note": "A non-empty quoted value is rejected without a length floor." + }, { "id": "bearer_opaque_letter_run_at_ceiling", "template": "{BEARER} {LONG_LETTER_RUN}", From ee82cab2fbc9b92120e06a2a5ad0c6292eb9cee5 Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:45:54 +0800 Subject: [PATCH 11/12] test(public-safety): accept the earlier local-path rejection Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- tests/capabilities/test_change_quality_shadow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/capabilities/test_change_quality_shadow.py b/tests/capabilities/test_change_quality_shadow.py index f928b54ab9..cec91a8ac5 100644 --- a/tests/capabilities/test_change_quality_shadow.py +++ b/tests/capabilities/test_change_quality_shadow.py @@ -238,7 +238,7 @@ def test_normalizer_rejects_local_paths_and_mismatched_identity() -> None: ], ) - with pytest.raises(ValueError, match="repo-relative"): + with pytest.raises(ValueError, match="private-looking value"): normalize_change_quality_shadow_result( raw, case=case, From f7754840530496b0d1ec5c0e2bb1d0048e34cf5d Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:16:01 +0800 Subject: [PATCH 12/12] fix(public-safety): recognize composite credential connectors Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- .../control_plane/goals/vision_checkpoint.ts | 4 ++-- loopx/public_safe_text.py | 2 +- .../test_public_safe_text_classifier.py | 16 +++++++++----- .../test_public_safe_text_owner_parity.py | 4 ++++ .../public_safe_text_corpus.test.ts | 4 ++++ tests/fixtures/public_safe_text_corpus.json | 22 +++++++++++++++++++ 6 files changed, 44 insertions(+), 8 deletions(-) diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index e963a16650..597a34c3f2 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -131,9 +131,9 @@ const BASIC_CREDENTIAL_VALUE = // value shape. The tie between the quantifier and OPAQUE_VALUE_MIN_LENGTH is // asserted in the corpus test instead of built into the pattern here. export const CONNECTED_CREDENTIAL_VALUE_SHAPE = - /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*(?:(?=[A-Za-z0-9._~+\/=-]*[0-9+\/=])[A-Za-z0-9._~+\/=-]+|[A-Za-z]{16,})/i; + /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:set\s+to|is|are|was|were|set|to|of|with)\b|\s+)\s*(?:(?=[A-Za-z0-9._~+\/=-]*[0-9+\/=])[A-Za-z0-9._~+\/=-]+|[A-Za-z]{16,})/i; export const QUOTED_CREDENTIAL_VALUE_SHAPE = - /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*["'][^"'\n]+["']/i; + /\b(?:Bearer|token|password|secret)(?:\s*[,;-]\s*|\s+(?:set\s+to|is|are|was|were|set|to|of|with)\b|\s+)\s*["'][^"'\n]+["']/i; const LABELED_CREDENTIAL_ASSIGNMENT = /\b(?:Bearer|token|password|secret)\s*[:=]/i; // Ported from the Python owner's two in-policy shape detectors, so one corpus // yields one verdict in both runtimes (Refs #5136, direction 4). The third diff --git a/loopx/public_safe_text.py b/loopx/public_safe_text.py index 1e35d689af..63b21d99ea 100644 --- a/loopx/public_safe_text.py +++ b/loopx/public_safe_text.py @@ -108,7 +108,7 @@ # them, so leaving them here would give one spelling two owners and make the named # reason depend on list order. CREDENTIAL_VALUE_CONNECTOR_PATTERN_SOURCE = ( - r"(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*" + r"(?:\s*[,;-]\s*|\s+(?:set\s+to|is|are|was|were|set|to|of|with)\b|\s+)\s*" ) # A run that carries a digit or a base64-only character. The signal has no # length floor: a one-character value still carries a credential value, and the diff --git a/tests/control_plane/test_public_safe_text_classifier.py b/tests/control_plane/test_public_safe_text_classifier.py index 04f6e3d849..b49cf39779 100644 --- a/tests/control_plane/test_public_safe_text_classifier.py +++ b/tests/control_plane/test_public_safe_text_classifier.py @@ -81,7 +81,9 @@ # The two spellings both owners must share, assembled for the same reason as the # words above so this file carries no literal credential label. -SHARED_CONNECTOR = r"(?:\s*[,;-]\s*|\s+(?:is|are|was|were|set|to|of|with)\b|\s+)\s*" +SHARED_CONNECTOR = ( + r"(?:\s*[,;-]\s*|\s+(?:set\s+to|is|are|was|were|set|to|of|with)\b|\s+)\s*" +) SHARED_LABELS = "(?:" + "Bear" + "er|tok" + "en|pass" + "word|sec" + "ret)" @@ -373,9 +375,10 @@ def test_both_tiers_differ_from_the_old_rule_only_where_this_change_says() -> No "password_word_in_prose", "secret_word_in_prose", "rotation_note_names_two_schemes", - "bearer_before_long_ordinary_word", - "password_copula_ordinary_word", - "disclosed_residual_short_letter_value", + "bearer_before_long_ordinary_word", + "password_copula_ordinary_word", + "password_composite_copula_ordinary_word", + "disclosed_residual_short_letter_value", ] assert newly_rejected == [ "token_space_digit_value", @@ -415,6 +418,7 @@ def test_both_tiers_differ_from_the_old_rule_only_where_this_change_says() -> No (" are ", "value_connector"), (" set ", "value_connector"), (" to ", "value_connector"), + (" set to ", "value_connector"), (" of ", "value_connector"), (" with ", "value_connector"), (". ", "foreign"), @@ -486,7 +490,7 @@ def test_contract_biconditional_over_the_whole_form_class() -> None: # there. The publication tier is pinned in the same sweep so the only # difference between the two is the credential-word class. forms = _forms() - assert len(forms) == 4_032, len(forms) + assert len(forms) == 4_256, len(forms) word_labels = {word.lower() for word in _WORD_ONLY_LABELS} disagreements: list[str] = [] for text, label, separator, value, kind, value_kind in forms: @@ -553,8 +557,10 @@ def test_each_contract_signal_is_wired_to_its_own_arm() -> None: assert LABELED_CREDENTIAL_ASSIGNMENT_PATTERN.search(f"{bearer}:") assert CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} abc123") assert CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} is abc123") + assert CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} set to abc123") assert CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer}, abc123") assert QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f'{bearer} is "abc123"') + assert QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f'{bearer} set to "abc123"') assert not CONNECTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} authentication") assert not QUOTED_CREDENTIAL_VALUE_SHAPE_PATTERN.search(f"{bearer} authentication") diff --git a/tests/control_plane/test_public_safe_text_owner_parity.py b/tests/control_plane/test_public_safe_text_owner_parity.py index 484e3764a6..8d90e431b4 100644 --- a/tests/control_plane/test_public_safe_text_owner_parity.py +++ b/tests/control_plane/test_public_safe_text_owner_parity.py @@ -182,6 +182,9 @@ def test_corpus_covers_the_reviewed_credential_shapes() -> None: "token_space_digit_value", "copula_password_digit_value", "copula_token_single_digit_value", + "composite_copula_password_digit_value", + "composite_copula_secret_quoted_value", + "composite_copula_bearer_opaque_value", "space_secret_opaque_value", "comma_bearer_opaque_value", "dash_secret_opaque_value", @@ -198,6 +201,7 @@ def test_corpus_covers_the_reviewed_credential_shapes() -> None: "bearer_word_in_prose", "bearer_before_long_ordinary_word", "password_copula_ordinary_word", + "password_composite_copula_ordinary_word", "disclosed_residual_short_letter_value", } <= prose_ids diff --git a/tests/control_plane_ts/public_safe_text_corpus.test.ts b/tests/control_plane_ts/public_safe_text_corpus.test.ts index 4df9f233f0..cf412a4a90 100644 --- a/tests/control_plane_ts/public_safe_text_corpus.test.ts +++ b/tests/control_plane_ts/public_safe_text_corpus.test.ts @@ -169,7 +169,10 @@ test("the narrower tier still rejects every value and assignment shape", () => { `${bearer} ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH + 24)}`, `${password}=hunter2`, `${password} is hunter2`, + `${password} set to hunter2`, `${password} is "a"`, + `${password} set to "a"`, + `${bearer} set to ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH)}`, `${password} is "${"a".repeat(8)}"`, `${secret}: env`, `${secret} - Qz8m2Xp7`, @@ -186,6 +189,7 @@ test("the narrower tier still rejects every value and assignment shape", () => { `read the ${secret} from the environment`, `${bearer} authentication is required here`, `the ${password} is configured per environment`, + `the ${password} set to rotate after expiry`, `${secret} is ${"a".repeat(OPAQUE_VALUE_MIN_LENGTH - 1)}`, ]; for (const value of accepted) { diff --git a/tests/fixtures/public_safe_text_corpus.json b/tests/fixtures/public_safe_text_corpus.json index 2578a9885c..24cc5e6d8e 100644 --- a/tests/fixtures/public_safe_text_corpus.json +++ b/tests/fixtures/public_safe_text_corpus.json @@ -207,6 +207,22 @@ "shape": "shaped_value_token", "note": "The token-shape signal has no length floor: a one-character digit is still a credential value." }, + { + "id": "composite_copula_password_digit_value", + "template": "{PASSWORD_LOWER} set to {DIGIT_VALUE}", + "shape": "shaped_value_token", + "note": "The composite copula is one connector; matching only 'set' or 'to' would strand the value behind the other word." + }, + { + "id": "composite_copula_secret_quoted_value", + "template": "{SECRET_LOWER} set to \"{ONE_LETTER}\"", + "shape": "quoted_value" + }, + { + "id": "composite_copula_bearer_opaque_value", + "template": "{BEARER} set to {LONG_LETTER_RUN}", + "shape": "opaque_value_run" + }, { "id": "space_secret_opaque_value", "template": "{SECRET_LOWER} {OPAQUE_VALUE}", @@ -314,6 +330,12 @@ "template": "the {PASSWORD_LOWER} is configured per environment", "shape": "credential_word_only" }, + { + "id": "password_composite_copula_ordinary_word", + "template": "the {PASSWORD_LOWER} set to rotate after expiry", + "shape": "credential_word_only", + "note": "Recognizing the complete composite connector must not turn a short ordinary word into a value." + }, { "id": "disclosed_residual_short_letter_value", "template": "{SECRET_LOWER} is {SHORT_LETTER_RUN}",