diff --git a/docs/02_builtin.md b/docs/02_builtin.md
index 516149d..85db0c6 100644
--- a/docs/02_builtin.md
+++ b/docs/02_builtin.md
@@ -132,6 +132,7 @@ The following list provides an overview of each build-in plugin, further informa
| `homoglyph` | Encoding, Obfuscation | Substitutes Latin characters with visually-identical Unicode confusables (Cyrillic/Greek). Output looks unchanged to a human but uses different code points, evading ASCII keyword/regex filters. | `ratio` (fraction of mappable characters to substitute, 0.0-1.0, default: 1.0)
`seed` (optional integer seed for reproducible partial substitution) |
| `morse` | Encoding | Encodes text into Morse code. | N/A |
| `octal` | Encoding | Encodes text as space-separated ASCII/Unicode octal codepoint values. | `hint` (show the literal plaintext string `octal` in the encoded output, default true) |
+| `zero_width` | Encoding, Obfuscation | Intersperses invisible zero-width Unicode characters between visible characters. Text looks unchanged to a human but contains no contiguous ASCII substring, evading keyword/regex filters. | `char` (which zero-width character to insert: `zwsp`/`zwnj`/`zwj`/`wj`/`zwnbsp`, default: `zwsp`)
`ratio` (fraction of gaps to fill, 0.0-1.0, default: 1.0)
`seed` (optional integer seed for reproducible partial output) |
| `best_of_n` | Obfuscation, Attack-Based | Implements ["Best-of-N Jailbreaking" John Hughes et al., 2024](https://arxiv.org/html/2412.03556v1#A1) to apply character scrambling, random capitalization, and character noising. | `variants` (number of variations to generate, default: 50) |
| `flip` | Obfuscation | Applies a flip attack to obfuscate text:
- FWO: Flip Word Order
- FCW: Flip Chars in Word
- FCS: Flip Chars in Sentence | `mode` (the flip mode to apply, default: `FWO`) |
| `mask` | Obfuscation, LLM | Masks high-risk words in the text with random character sequences, while providing a suffix that maps the masks back to the original words. | `advanced` (if true, creates multiple masks for longer words)
`advanced-split` (the number of characters per mask chunk for the advanced option, default: 6) |
diff --git a/spikee/plugins/zero_width.py b/spikee/plugins/zero_width.py
new file mode 100644
index 0000000..5e8037a
--- /dev/null
+++ b/spikee/plugins/zero_width.py
@@ -0,0 +1,136 @@
+"""
+Zero-Width Plugin
+
+This plugin interleaves invisible zero-width Unicode characters between the
+visible characters of the text. To a human reader (and in most UIs) the text
+looks unchanged ("ignore previous instructions" still reads normally), but the
+underlying byte sequence no longer contains any contiguous ASCII substring, which
+defeats naive keyword/regex filters, substring denylists, and byte-level matching.
+
+This is distinct from the existing plugins:
+ - `ascii_smuggler` *replaces* the text with invisible Unicode tag characters
+ (U+E00xx), so the visible content disappears entirely.
+ - `homoglyph` *substitutes* Latin glyphs with look-alike confusables from other
+ scripts, changing every visible code point.
+Zero-width interspersing keeps the original visible glyphs intact and simply
+injects invisible separators between them.
+
+By default a separator is inserted in every gap (``ratio=1.0``), which is fully
+deterministic. A lower ``ratio`` fills only that fraction of gaps, producing a
+lighter obfuscation; pass ``seed`` for reproducible partial output. The invisible
+character used is configurable via ``char``.
+
+Usage:
+ spikee generate --plugins zero_width
+ spikee generate --plugins zero_width --plugin-options "zero_width:char=zwnj"
+ spikee generate --plugins zero_width --plugin-options "zero_width:ratio=0.5,seed=42"
+
+Reference:
+ https://en.wikipedia.org/wiki/Zero-width_space
+ https://embracethered.com/blog/posts/2024/hiding-and-finding-text-with-unicode-tags/
+
+Parameters:
+ text (str): The input text to be transformed.
+ char (str): Which zero-width character to insert. One of ``zwsp`` (U+200B,
+ default), ``zwnj`` (U+200C), ``zwj`` (U+200D), ``wj`` (U+2060), or
+ ``zwnbsp``/``bom`` (U+FEFF).
+ ratio (float): Fraction of inter-character gaps to fill, in [0.0, 1.0]
+ (default: 1.0). At 1.0 a separator is inserted in every gap; below 1.0 a
+ random subset of gaps is chosen.
+ seed (int, optional): Seed for the random subset when ``ratio`` < 1.0, for
+ reproducible output.
+ exclude_patterns (List[str], optional): Supplied by the framework. Substrings
+ matching these regex patterns are preserved as-is.
+
+Returns:
+ str: The transformed text with zero-width characters interspersed.
+"""
+
+import random
+from typing import Dict
+
+from spikee.templates.basic_plugin import BasicPlugin
+from spikee.utilities.enums import ModuleTag
+from spikee.utilities.hinting import ModuleDescriptionHint, ModuleOptionsHint
+from spikee.utilities.modules import parse_options
+
+
+class ZeroWidthPlugin(BasicPlugin):
+ DEFAULT_RATIO = 1.0
+ DEFAULT_CHAR = "zwsp"
+
+ # Supported invisible separators. Keys are the option values users pass.
+ ZERO_WIDTH_CHARS: Dict[str, str] = {
+ "zwsp": "", # ZERO WIDTH SPACE
+ "zwnj": "", # ZERO WIDTH NON-JOINER
+ "zwj": "", # ZERO WIDTH JOINER
+ "wj": "", # WORD JOINER
+ "zwnbsp": "", # ZERO WIDTH NO-BREAK SPACE (BOM)
+ "bom": "", # alias for zwnbsp
+ }
+
+ def get_description(self) -> ModuleDescriptionHint:
+ return (
+ [ModuleTag.ENCODING, ModuleTag.OBFUSCATION],
+ "Intersperses invisible zero-width characters between visible characters.",
+ )
+
+ def get_available_option_values(self) -> ModuleOptionsHint:
+ """Return supported attack options; Tuple[options (default is first), llm_required]"""
+ return [
+ "char=zwsp",
+ "char=zwsp/zwnj/zwj/wj/zwnbsp (which zero-width character to insert)",
+ "ratio=N (0.0-1.0, fraction of inter-character gaps to fill)",
+ "seed=N (optional integer seed for reproducible partial output)",
+ ], False
+
+ def plugin_transform(self, text: str, plugin_option: str = "") -> str:
+ """
+ Intersperses zero-width characters between the characters of ``text``.
+
+ Args:
+ text (str): The input text (or chunk thereof).
+ plugin_option (str, optional): Plugin options string, e.g.
+ "char=zwnj,ratio=0.5,seed=42".
+
+ Returns:
+ str: The transformed text.
+ """
+ zw_char, ratio, seed = self._parse_options(plugin_option)
+
+ if len(text) < 2 or ratio <= 0.0:
+ return text
+
+ if ratio >= 1.0:
+ return zw_char.join(text)
+
+ rng = random.Random(seed)
+ result = [text[0]]
+ for char in text[1:]:
+ if rng.random() < ratio:
+ result.append(zw_char)
+ result.append(char)
+ return "".join(result)
+
+ def _parse_options(self, plugin_option: str):
+ """Parse the ``char``, ``ratio`` and ``seed`` options, falling back to defaults."""
+ opts = parse_options(plugin_option)
+
+ char_key = str(opts.get("char", self.DEFAULT_CHAR)).lower()
+ zw_char = self.ZERO_WIDTH_CHARS.get(char_key, self.ZERO_WIDTH_CHARS[self.DEFAULT_CHAR])
+
+ ratio = self.DEFAULT_RATIO
+ if "ratio" in opts:
+ try:
+ ratio = max(0.0, min(1.0, float(opts["ratio"])))
+ except ValueError:
+ ratio = self.DEFAULT_RATIO
+
+ seed = None
+ if "seed" in opts:
+ try:
+ seed = int(opts["seed"])
+ except ValueError:
+ seed = None
+
+ return zw_char, ratio, seed
diff --git a/tests/functional/test_spikee_list.py b/tests/functional/test_spikee_list.py
index f89f4c3..e780ce6 100644
--- a/tests/functional/test_spikee_list.py
+++ b/tests/functional/test_spikee_list.py
@@ -47,7 +47,7 @@ def test_list_plugins(run_spikee, workspace_dir):
output_lines = spikee_list(run_spikee, workspace_dir, "plugins")
expected_local = {"test_repeat", "test_upper"}
- expected_builtin = {"1337", "base64", "hex"}
+ expected_builtin = {"1337", "base64", "hex", "zero_width"}
_assert_contains(output_lines, expected_local | expected_builtin)