Invisible Unicode - Markdown as an Attack Surface

Gate smuggling primitives in CI, not in preview

Share

Markdown can look fine in the rendered preview while the logical file still carries Trojan instructions. In agentic delivery, skills, YAML, specs, and comments are machine context - not just human prose. Supply-chain risk is not only npm; it is unvetted text your agents load before they act.

Here's what this piece covers: the leadership problem of WYSIWYG security, what commit e833777 (2026-07-07) enforced in CI, the threat classes we gate at a high level, the named pattern, and three habits for any repo agents treat as context.

Part of the Field Notes: agentic delivery series - deterministic gates sit beside human merge approval.


1. The leadership problem

Reviewers trust the rendered preview. Attackers exploit the gap between display and logical order - Trojan Source-style confusion, plus invisible codepoints that models still tokenize.

The anti-pattern is trust the rendered preview - WYSIWYG security for agent-readable repos.

Wrong: "Be careful with copy-paste."
Right: "e833777: what we detect, what we block, what CI enforces - a lint-shaped gate, not a lecture."

You cannot ask every reviewer to eyeball zero-width characters on every skill file. You can fail the build.


2. What the git history shows

On 2026-07-07, commit e833777 - Add invisible-unicode CI workflow and align methodology docs - made the gate non-optional:

Artifact Role
.github/workflows/invisible-unicode-lint.yml Scan + unit tests on push/PR
tools/invisible_unicode_lint/ Deterministic scanner (Python + TS)
Canonical threat note docs/code-quality/invisible-text-prompt-injection-and-linting.md
Standard docs/standards/agent-context-text-hygiene.md

The scanner predates the workflow; the commit is the leadership move - CI enforcement, not optional local hygiene.

flowchart LR PR[PR text / skills / specs] --> S[Deterministic Unicode scan] S -->|hit| F[Fail CI with file:line] S -->|clean| M[Human review of intent] M --> Merge[Merge]

Core scan loop - codepoints, not models:

export function scanText(
  text: string,
  rules: Iterable<Rule>,
): Array<[number, number, number, Rule]> {
  const violations: Array<[number, number, number, Rule]> = [];
  let line = 1, col = 1, cpOffset = 0;
  for (const ch of text) {
    const code = ch.codePointAt(0)!;
    for (const rule of matchRules(code, cpOffset, rules)) {
      violations.push([line, col, code, rule]);
    }
    if (ch === "\n") { line++; col = 1; }
    else if (ch !== "\r") { col++; }
    cpOffset++;
  }
  return violations;
}

3. What I built

Threat mechanisms we treat as lint-shaped (high level, no exploit cookbook):

Mechanism Why it matters
Unicode tag letters (U+E0000 block) Invisible English to models; no legitimate role in most docs
Zero-width / format chars Hidden payloads, token splitting
Bidirectional controls Display order ≠ logical order (Trojan Source class)

What we ship: deterministic rules from JSON, strict UTF-8 reads, pre-commit + CI, same rules for skills, AGENTS.md, specs, and comments. Complements - does not replace - least-privilege tool access and human review of high-risk changes.

What broke: teams forget non-code files are in the blast radius. False positives stay rare when the rule set stays scoped to smuggling primitives rather than "any unusual Unicode."


4. Named pattern: deterministic Unicode lint

Deterministic Unicode lint - supply-chain hygiene for agent-readable repos; gate in CI, do not trust the preview.
Control Who it protects Decision it supports
CI scan Every merge "May this text become agent context?"
Pre-commit Authors Fix before push
Canonical doc + standard Security + EM onboarding Shared vocabulary without FUD
Human playbook (MCP/hooks) Operators Broader instruction supply chain

What broke: treating this as a complete security program. It is a cheap filter for high-yield primitives - then least privilege and human merge still matter.


5. What I'd do as a lead

Three habits - steal one this month:

  1. Enable the gate on any repo agents load as context. Skills and specs first; source comments next.
  2. Include invisible-text hygiene in security onboarding for agentic teams. Show one failing CI log; skip the Unicode lecture.
  3. Pair the lint with the human playbook for MCP, hooks, and instruction supply chain. Lint is necessary; it is not sufficient alone.

Further reading