Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fuckmark/robustness_data/** text eol=lf
specs/fuckmark-robustness-bench-v1.protocol.md text eol=lf
specs/fuckmark-robustness-bench-v1.vectors.json text eol=lf
specs/fuckmark-robustness-bench-v1.freeze.json text eol=lf
specs/cycle8/fuckmark-cycle8-gate-v2-confirmation-scorecard-v1.json text eol=lf
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- WASM build of `fuckmark-hidden-scan-v1` (`crates/fuckmark-scan`, committed `fuckmark_scan.wasm`). The scan page and the Chromium popup load it when `fetch` works so classification stays on-device; `scan.js` remains the `file://` fallback. Lone UTF-16 surrogates route through the JS engine (UTF-8 cannot carry them). Empty category arrays stay empty selections. The extension CSP allows `wasm-unsafe-eval` so Chromium can instantiate the module. Page reveal and paste-safe keep the synchronous JS port. Rebuild with `crates/fuckmark-scan/build-wasm.sh`.
- Language bindings for the same Rust scan ABI: C header (`crates/fuckmark-scan/include/fuckmark_scan.h`), optional Python ctypes loader (`fuckmark.native_scan`), and a Node package under `bindings/node` (WASM + JS fallback). Host library build: `crates/fuckmark-scan/build-native.sh`. Lone UTF-16 surrogates cannot cross the UTF-8 ABI, so Python `scan_text` / `clean_text` delegate those strings to the Python reference scanner, and the WASM wrapper normalizes the JS fallback result to the same `index` / `truncated` / `highest_severity` / `source_length` schema. The C example is linked with `-rpath,$ORIGIN` and written next to `libfuckmark_scan.so`. Overview: `bindings/README.md`.
- OS clipboard watcher (`fuckmark-clipboard-watch-v1`). `fuckmark clipboard scan` / `clean` / `watch` poll the system clipboard (pbpaste, wl-paste, xclip, xsel, PowerShell Get-Clipboard) for the same security category set as `fuckmark lint`. `--clean` rewrites the clipboard in place after a digest re-read so a newer copy is not overwritten. Snapshots hash with UTF-8 `surrogatepass` so lone surrogates do not crash the watcher. Linux read uses `wl-paste` without `--no-newline`. Emoji ZWJ sequences (both neighbors emoji) and info-level variation selectors are kept, matching browser paste-safe. `--json`, `-q`, `--select`, `--once`, `--exit-on-find`, `--interval`, `--max-seconds`. Exit `0` clean, `1` findings, `2` usage, `3` clipboard unavailable. Reference: `docs/clipboard.md`. Python: `evaluate_clipboard_text`, `watch_clipboard`, `clean_clipboard_text` (injectable reader/writer for tests).
- Public sanitizer-restore robustness bench (`fuckmark-robustness-bench-v1`). `fuckmark robustness` replays 10 short fixtures times 18 Unicode sanitizer attacks (180 cells) against the live five-layer mix and frozen vectors. It does not rerun GPT-2 or SynthID; the Gate v2 confirmation scorecard stays the sealed detector track (identity 188/192, mix 0/192 after required sanitizers). Mixed letter/emoji fixtures do not restore; `digits` has no eligible site so mix is a no-op and every attack restores. `--json`, `-q`, `--fixture`, `--attack`. Exit `0` match, `1` mismatch, `2` usage. The CLI loads packaged copies under `fuckmark/robustness_data/` (byte-identical to `specs/` after LF newline folding) and compares protocol, vectors, and scorecard-file SHA-256 to the freeze record before reporting success. File hashes fold CRLF to LF so Windows checkouts match. Spec: `specs/fuckmark-robustness-bench-v1.{protocol.md,vectors.json,freeze.json}`. Reference: `docs/robustness.md`.
- `fuckmark normalize` (`fuckmark-normalize-v1`): NFC, optional identifier lookalike fold (UTS #39-inspired subset, not a full confusable map), then strip of the security category set, plus a JSON receipt (`input_sha256` / `output_sha256` / `steps` / `report_hash`). `--clean` remains the blunt strip with no receipt. HTTP: `POST /api/normalize`. Python: `normalize_text`, `skeleton_fold`. Reference: `docs/normalize.md`.
- VS Code / Cursor: `fuckmark.cleanOnSave` (default off) strips hidden Unicode on save.
- Frozen confirmation files, hashes, mix mechanisms, and the published wheel are unchanged. Package version stays 0.4.1.
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ fuckmark clipboard watch --clean

Needs a clipboard tool (`pbpaste`, `wl-paste` / `xclip` / `xsel`, or PowerShell `Get-Clipboard`). Full reference: [`docs/clipboard.md`](docs/clipboard.md).

## Replay the sanitizer-restore bench

`fuckmark robustness` is the public, hashed restore grid for the live mix. It does not rerun GPT-2 or SynthID. Detector numbers stay on the sealed Gate v2 scorecard. Mixed letter/emoji fixtures in the v1 catalog do not restore after the listed Unicode sanitizers; `digits` has no eligible site, so mix is a no-op.

```text
fuckmark robustness
fuckmark robustness --json
```

Full reference: [`docs/robustness.md`](docs/robustness.md). Protocol and freeze: [`specs/fuckmark-robustness-bench-v1.protocol.md`](specs/fuckmark-robustness-bench-v1.protocol.md).

## Guard model input (LLM prompt-injection smuggling)

Hidden Unicode — especially Unicode **tag** characters — can smuggle a second instruction into a prompt that a human reviewer will not see. `protect()` strips that payload before the text reaches a model, and can decode the smuggled ASCII so you can log it. It is not a semantic jailbreak detector.
Expand Down Expand Up @@ -260,7 +271,7 @@ Eligible Latin, Greek, Cyrillic, Han, Kana, Hangul syllable, and emoji clusters

## Research

Frozen evidence, hashes, and protocols: [`docs/research.md`](docs/research.md). Product contract: [`docs/product-contract.md`](docs/product-contract.md). CLI: [`docs/cli.md`](docs/cli.md). Limits matrix: [`docs/limits.md`](docs/limits.md). Demo: [`docs/demo.html`](docs/demo.html).
Frozen evidence, hashes, and protocols: [`docs/research.md`](docs/research.md). Public sanitizer-restore bench: [`docs/robustness.md`](docs/robustness.md). Product contract: [`docs/product-contract.md`](docs/product-contract.md). CLI: [`docs/cli.md`](docs/cli.md). Limits matrix: [`docs/limits.md`](docs/limits.md). Demo: [`docs/demo.html`](docs/demo.html).

## Language bindings (Rust scan ABI)

Expand Down
13 changes: 13 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ fuckmark guard --json < messages.json
fuckmark normalize --receipt < notes.txt
fuckmark clipboard scan
fuckmark clipboard watch --clean
fuckmark robustness --json
fuckmark web
fuckmark --text "I don’t agree." --status
```
Expand Down Expand Up @@ -157,6 +158,18 @@ fuckmark clipboard watch --once --exit-on-find

`scan` reads once. `clean` rewrites the clipboard in place when findings exist. `watch` polls (default 0.5s) and warns when the clipboard contents change to a hidden payload. `--select security|all|CATS` chooses categories. Exit status for this subcommand: `0` clean, `1` findings (including after a successful clean), `2` usage, `3` clipboard tool missing or failed.

### `fuckmark robustness`

Replay the public sanitizer-restore bench (`fuckmark-robustness-bench-v1`). 10 short fixtures times 18 Unicode sanitizer attacks. Does not rerun GPT-2 or SynthID; the Gate v2 confirmation scorecard is hashed as a sealed detector track. Reference: [`robustness.md`](robustness.md).

```text
fuckmark robustness
fuckmark robustness --json
fuckmark robustness --fixture digits --attack identity
```

Exit status for this subcommand: `0` cells match frozen vectors, `1` mix or sanitizer drift, `2` usage. A file named `robustness` is not read; use `fuckmark --file robustness`.

### `fuckmark web`

Open the local browser tool (same UI as `docs/mark.html`). Aimed at beginners who prefer a page over pipes and flags. The server also exposes a Python API: `GET /api/health`, `POST /api/remove-marks`, `POST /api/scan`, `POST /api/guard` (sanitize text or chat messages before a model call), and `POST /api/normalize`. Hidden-Unicode reveal is `/scan.html` (WASM engine with `scan.js` fallback; zero-install copy at `docs/scan.html`).
Expand Down
2 changes: 1 addition & 1 deletion docs/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ No-install walkthrough of these limits: [`demo.html`](demo.html).
| Mn then Me then frozen cf_strip then UnicodeSanitizer | **yes** (closed-set remainder) | n/a |
| required-bundle then UnicodeSanitizer | **no** (exploratory 0/192) | n/a (bundle already strips marks) |

Live mix (`u034f-ufe00-cc-me-cf-ia-letter-alt-v1`) leaves Me/Cc/Cf residuals after Mn-strip, default-ignorable strip, UnicodeSanitizer orderings, Mn then Me then UnicodeSanitizer, and the required sanitizer bundle. UnicodeSanitizer turns U+FFF9-U+FFFB into spaces, so frozen Cf-strip after that path does not restore. Frozen Gate v2 confirmation is the historical mark-only arm and is not rewritten. Exploratory rescores: `evidence/cycle8-dual-layer-stress-exploratory-2026-08-28/`, `evidence/cycle8-combo-stress-exploratory-n192-2026-08-28/` (historical triple-layer detector scores), and `evidence/cycle8-quad-layer-restore-exploratory-2026-08-29/` (four-layer restore census).
Live mix (`u034f-ufe00-cc-me-cf-ia-letter-alt-v1`) leaves Me/Cc/Cf residuals after Mn-strip, default-ignorable strip, UnicodeSanitizer orderings, Mn then Me then UnicodeSanitizer, and the required sanitizer bundle. UnicodeSanitizer turns U+FFF9-U+FFFB into spaces, so frozen Cf-strip after that path does not restore. Frozen Gate v2 confirmation is the historical mark-only arm and is not rewritten. The public hashed restore grid is `fuckmark robustness` ([`robustness.md`](robustness.md)); it does not rerun detectors. Exploratory rescores: `evidence/cycle8-dual-layer-stress-exploratory-2026-08-28/`, `evidence/cycle8-combo-stress-exploratory-n192-2026-08-28/` (historical triple-layer detector scores), and `evidence/cycle8-quad-layer-restore-exploratory-2026-08-29/` (four-layer restore census).

## L02 — Input domain

Expand Down
3 changes: 2 additions & 1 deletion docs/research.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Research archive

This directory's user manuals are [`install.md`](install.md), [`cli.md`](cli.md), [`limits.md`](limits.md), [`website.md`](website.md), and the no-install [`demo.html`](demo.html). The demo uses fixed samples and frozen scores; it is not a live detector.
This directory's user manuals are [`install.md`](install.md), [`cli.md`](cli.md), [`limits.md`](limits.md), [`website.md`](website.md), the public sanitizer-restore bench [`robustness.md`](robustness.md), and the no-install [`demo.html`](demo.html). The demo uses fixed samples and frozen scores; it is not a live detector.

Frozen scientific records are not the user manual. They stay in the repository so the product claims can be audited.

Expand All @@ -15,6 +15,7 @@ Frozen scientific records are not the user manual. They stay in the repository s

## Evidence index

- Public sanitizer-restore bench (`fuckmark-robustness-bench-v1`, model-free, does not rerun detectors): [`robustness.md`](robustness.md), [`specs/fuckmark-robustness-bench-v1.protocol.md`](../specs/fuckmark-robustness-bench-v1.protocol.md)
- Gate v2 confirmation: [`evidence/cycle8-gate-v2-confirmation-2026-08-27/README.md`](../evidence/cycle8-gate-v2-confirmation-2026-08-27/README.md)
- DeepMind 30-key transfer: [`evidence/cycle8-mix-deepmind-transfer-2026-08-27/README.md`](../evidence/cycle8-mix-deepmind-transfer-2026-08-27/README.md)
- DistilGPT2 n=16: [`evidence/cycle8-mix-distilgpt2-1090000-n16-2026-08-27/README.md`](../evidence/cycle8-mix-distilgpt2-1090000-n16-2026-08-27/README.md)
Expand Down
97 changes: 97 additions & 0 deletions docs/robustness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Public sanitizer-restore bench

`fuckmark robustness` replays `fuckmark-robustness-bench-v1`: a hashed, model-free
grid of short fixtures times Unicode sanitizer attacks. It answers whether the
live five-layer mix still restores the original source after those attacks.

It does **not** rerun GPT-2, SynthID, or any neural detector. Detector numbers
stay on the sealed Gate v2 confirmation scorecard. This is a restore and
residual-category bench, not a platform-evasion rate.

Protocol: [`specs/fuckmark-robustness-bench-v1.protocol.md`](../specs/fuckmark-robustness-bench-v1.protocol.md).
Vectors (sources as codepoint arrays only):
[`specs/fuckmark-robustness-bench-v1.vectors.json`](../specs/fuckmark-robustness-bench-v1.vectors.json).
Freeze hashes: [`specs/fuckmark-robustness-bench-v1.freeze.json`](../specs/fuckmark-robustness-bench-v1.freeze.json).
Installed wheels load copies from `fuckmark/robustness_data/` that must match
`specs/` after UTF-8 LF newline folding. Before exit 0 the CLI recomputes those
hashes, including the scorecard *file* SHA-256, so editing the scorecard while
leaving its embedded `scorecard_hash` field alone still fails. CRLF checkouts
(Windows `core.autocrlf`) are folded to LF before hashing.

## Command line

```text
fuckmark robustness
fuckmark robustness --json
fuckmark robustness -q
fuckmark robustness --fixture digits --attack identity
fuckmark robustness --json --fixture ascii_prose --attack mn_me_us_cf
```

Default is every public fixture and every frozen attack (180 cells). `--fixture`
and `--attack` are repeatable. `--json` writes the full report to stdout. Human
status goes to stderr. `-q` is silent on success.

A file named `robustness` in the current directory is not read; use
`fuckmark --file robustness` for that.

## What a cell records

For source `S` and attack `A`: mix `M`, then `T = A(M)`.

| Field | Meaning |
| --- | --- |
| `restores_source` | `T == S` |
| `mix_projection_equals_source` | visible projection of `M` equals `S` |
| `projection_equals_source` | visible projection of `T` equals `S` |
| `carrier_detected` | closed-set FuckMark insertion scan of `T` |
| `residual_categories` | `fuckmark-hidden-scan-v1` categories still in `T` |
| `mix_sha256` / `output_sha256` | SHA-256 of UTF-8 `M` and `T` |

Visible projection is `project_visible_v1` (approved carriers removed; Me, Cc,
and Cf residuals remain). UnicodeSanitizer turns interlinear annotation
controls into spaces, so Cf-strip after that path cannot rebuild the original
spacing.

## Frozen outcome

Live measure of the v1 grid:

- 10 fixtures, 18 attacks, 180 cells
- mix visible projection holds on every cell (180/180)
- `digits` has no eligible letter or emoji site, so mix is a no-op and every
attack restores (18/18)
- mixed letter/emoji fixtures do not restore under the catalog
- `ascii_prose` / `mn_me_us_cf`: restore false, carrier false, projection false
(spaces from UnicodeSanitizer)

Sealed detector track (hashed, not rerun): Gate v2 confirmation identity
**188/192** detected; mix **0/192** after required sanitizers; visible
**192/192**. Do not rewrite that scorecard.

## Exit status

| Status | Meaning |
| ---: | ---: |
| 0 | Selected cells match the frozen vectors, freeze hashes match the loaded artifacts, and the sealed scorecard hash still matches. |
| 1 | Mix, sanitizer, or freeze-hash drift, or a packaged artifact is missing. |
| 2 | Usage (unknown `--fixture` / `--attack`, or argparse). |

## Python

```text
from fuckmark.robustness import measure_cell, run_robustness_bench

cell = measure_cell("ascii_prose", "mn_strip")
report = run_robustness_bench()
```

`tests/test_robustness.py` binds the freeze hashes and replays every cell.

## Honest limits

This v1 freeze is local and deterministic. It is not a leaderboard of third-party
detectors. A later track may add opt-in adapters; it is not this freeze. Short
public fixtures are not the GPT-2 Gate v2 corpus. Sanitizer restore is not the
same as detector miss, and detector miss on GPT-2 / SynthID is not a general
AI-detector rate.
7 changes: 7 additions & 0 deletions fuckmark/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from .guard import run_guard_argv
from .lint import run_lint_argv
from .product.normalize import run_normalize_argv
from .robustness import run_robustness_argv
from .web import run_web_argv
from .product.domain import (
PRODUCT_MAX_INPUT_CHARS,
Expand Down Expand Up @@ -233,6 +234,7 @@ def _parser() -> argparse.ArgumentParser:
" fuckmark guard --json < messages.json\n"
" fuckmark normalize --receipt < notes.txt\n"
" fuckmark clipboard watch --clean\n"
" fuckmark robustness --json\n"
" fuckmark web\n"
"\n"
"Latin, Greek, Cyrillic, Han, Kana, Hangul syllable, and emoji sites are processed\n"
Expand All @@ -256,6 +258,7 @@ def _parser() -> argparse.ArgumentParser:
"Use fuckmark guard to strip hidden Unicode from text or JSON before a model call.\n"
"Use fuckmark normalize to NFC-fold, optionally skeleton-fold lookalikes, and strip hidden Unicode.\n"
"Use fuckmark clipboard to scan, clean, or watch the OS clipboard for hidden Unicode.\n"
"Use fuckmark robustness to replay the public sanitizer-restore bench (no detector rerun).\n"
"Use fuckmark web to open the local browser tool (beginner-friendly).\n"
"That server also runs the Python detect/strip API.\n"
"Mn-strip, default-ignorable strip, UnicodeSanitizer combinations, and Cf-strip after UnicodeSanitizer leave Me/Cc/Cf residuals and spaces."
Expand Down Expand Up @@ -972,6 +975,10 @@ def _run(
_ensure_utf8(output)
_ensure_utf8(errors)
return run_clipboard_argv(raw_argv[1:], output, errors)
if raw_argv and raw_argv[0] == "robustness":
_ensure_utf8(output)
_ensure_utf8(errors)
return run_robustness_argv(raw_argv[1:], output, errors)
if raw_argv and raw_argv[0] == "lint":
_ensure_utf8(output)
_ensure_utf8(errors)
Expand Down
7 changes: 7 additions & 0 deletions fuckmark/hashing.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ def sha256_file(path: str | Path, chunk_size: int = 1024 * 1024) -> str:
return digest.hexdigest()


def sha256_lf_file(path: str | Path) -> str:
if not isinstance(path, (str, Path)):
raise TypeError("path must be a string or Path")
data = Path(path).read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n")
return sha256_bytes(data)


def sha256_json(value: Any) -> str:
return sha256_bytes(canonical_json_bytes(value))

Expand Down
Loading
Loading