Skip to content

Untrusted PR metadata accepts zero-width and other Cf characters the bidi check was meant to stop #64

Description

@seonghobae

Gap

scripts/prepare_agent_pr_message.py is the boundary where model-authored pull-request metadata becomes text a human reviewer reads. Its own docstring states the purpose: "Safely parse untrusted OpenCode pull-request metadata for a trusted publisher", and _reject_unsafe_text promises to "Reject control and bidirectional characters that can spoof metadata".

It rejects twelve named characters. Measured just now, every one of them is Unicode category Cf, and seven other Cf characters pass straight through:

character name result
U+200B ZERO WIDTH SPACE accepted
U+200C ZERO WIDTH NON-JOINER accepted
U+200D ZERO WIDTH JOINER accepted
U+2060 WORD JOINER accepted
U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM) accepted
U+00AD SOFT HYPHEN accepted
U+180E MONGOLIAN VOWEL SEPARATOR accepted

Verified by calling _reject_unsafe_text("fix: harmless<CHAR>hidden") for each.

The existing Cc check catches C0 and C1 controls and correctly exempts \n and \t. The Cf handling is the part that is an enumeration where a category test belongs.

Why it matters at this particular boundary

A title carrying a zero-width space or a soft hyphen renders as ordinary text. The reviewer who approves that pull request reads the rendered title, so invisible characters can split a word, hide a fragment, or make two different titles look identical. That is the same failure the bidirectional check exists to prevent, and the module already pays the cost of scanning every character.

A BOM surviving into the first line is a second, smaller case: it is invisible and it is part of the title the publisher writes out.

The fix is a simplification, not an addition

unicodedata.category(character) == "Cf" subsumes the entire twelve-character _BIDIRECTIONAL_CONTROLS set and closes all seven gaps. The frozenset and the membership test can both go, leaving one category test beside the existing Cc one.

Interaction with #39, which decides whether this matters

refactor/orchestrator-free-runtime deletes this file, and it deletes .github/workflows/hourly-nim-product-development.yml with it. I checked before writing this: the only surviving mention on that branch is a historical planning document, and the workflow that called the script is gone entirely. So that branch removes the whole model-authored publication path rather than removing its guard, which is coherent and is not a security regression.

On main today the path is live. The workflow installs the script to $RUNNER_TEMP with mode 0500 and runs it on PR_MESSAGE.md before the publisher consumes the output.

So the decision is simple. If #39 lands, this closes itself. If it does not land, or lands later, the gap is open on main in the meantime and the fix is a two-line simplification.

I have not edited the file, because #39 occupies it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions