@ggalmazor/decoy 1.1.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -91
- package/dist-cli/decoy.mjs +84 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,128 +1,90 @@
|
|
|
1
1
|
# DECOY
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Redaction that talks back.** DECOY covers a secret in a PDF or an image with the pixelated rendering of a _different_ phrase, cut to fit the box. Anyone who "enhances" the result recovers the decoy, not the secret. It runs as a command line and an MCP server for agents, and as a small web app at **https://decoy.ggalmazor.com** to show it off. Nothing ever leaves your machine.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Blurring is no better; it is a different averaging kernel. The only redaction that cannot be reversed is one where the output carries no information about the input. A black bar does that. DECOY does that too, and is funnier: the box is filled from a decoy phrase, so the puzzle a depixelizer solves is a puzzle about the decoy. `tools/qa/` holds a shell tool that runs an off-the-shelf depixelizer against DECOY's own exports; see [QA: attacking the output](#qa-attacking-the-output).
|
|
12
|
-
|
|
13
|
-
## What DECOY does
|
|
14
|
-
|
|
15
|
-
1. **Open** a PNG, JPG, or WebP, or a multi-page PDF, by dropping it, picking it, or pasting a screenshot.
|
|
16
|
-
2. **Box** the secret. DECOY measures the box in page pixels, samples the document's ink and paper colours around it (the only two things it ever reads from the page), and works out how many characters fit.
|
|
17
|
-
3. **Pick a mood** (Rude, Smug, Corporate, Wholesome), reroll until the line is right, pick an alternative, or type your own. "Keep it clean" removes every profane phrase from every mood. The mosaic previews live in place.
|
|
18
|
-
4. **Export flattened.** Pages are re-drawn as images: PNG (one per page), PDF (image-only pages at the original page size, title/author/subject/keywords/creator cleared, producer set to "DECOY"), or the clipboard. An optional tiny "decoyed" watermark goes in the corner. The export dialog lists every decoy with what a depixelizer would recover.
|
|
19
|
-
|
|
20
|
-
The editor has move, resize (re-fits the phrase unless you lock it), nudge with the arrow keys (Shift for 10 px), undo and redo, zoom, a pan tool (or hold Space), thumbnails with per-page counts, and a bottom-sheet layout on phones. Enter applies, Esc cancels, R rerolls, Delete removes.
|
|
21
|
-
|
|
22
|
-
## Privacy model
|
|
23
|
-
|
|
24
|
-
- **Read from the document:** its pixels, to display it and to export it, and two colours per box (paper and ink) sampled from a 6 px margin around the box. Nothing else.
|
|
25
|
-
- **Never read into a mosaic:** the pixels inside the box. The mosaic is a pure function of the decoy text, the box size, the block size, and those two colours (`src/core/mosaic.ts`), and it is written over the page with an opaque replace, never a blend (`src/core/raster.ts`). `src/core/security.test.ts` checks that two documents differing only inside a box export identically inside it.
|
|
26
|
-
- **Never sent anywhere:** anything. The site is static. There are no uploads, no analytics, no third-party requests, no CDN fonts; the fonts, pdf.js worker, CMaps, and standard fonts are served from this origin. The built `index.html` carries a Content-Security-Policy that allows `self` only, and a service worker caches the app shell so the page keeps working with the network off once it has loaded.
|
|
27
|
-
- **Removed on export:** EXIF and every other chunk of the source image (re-encoding from a canvas drops them), and for PDFs the text layer, annotations, forms, attachments, and metadata, because the PDF is rebuilt from scratch out of page images rather than edited.
|
|
28
|
-
|
|
29
|
-
Things DECOY does not protect you from: text elsewhere on the page that repeats the secret, filenames, and the memory of whoever already saw the original.
|
|
7
|
+
```sh
|
|
8
|
+
npm install -g @ggalmazor/decoy # gives you the `decoy` command
|
|
9
|
+
npx -y @ggalmazor/decoy --help # or run it without installing
|
|
10
|
+
```
|
|
30
11
|
|
|
31
|
-
|
|
12
|
+
Node 22 or later.
|
|
32
13
|
|
|
33
|
-
|
|
14
|
+
## Quick start
|
|
34
15
|
|
|
35
16
|
```sh
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
decoy find invoice.pdf # every line of text with its box, in page pixels at 150 dpi
|
|
39
|
-
decoy find invoice.pdf -m 'ES\d{2}( ?\d{4}){5}' # only the matches
|
|
40
|
-
decoy redact invoice.pdf -m 'ES\d{2}( ?\d{4}){5}' -m '[\w.+-]+@[\w-]+\.[\w.]+' --mood corporate
|
|
41
|
-
decoy redact screenshot.png -b 120,340,260,32 # images have no text layer: boxes are x,y,w,h[@page]
|
|
17
|
+
decoy find invoice.pdf # what is on the page, with coordinates
|
|
18
|
+
decoy redact invoice.pdf -m 'ES\d{2}( ?\d{4}){5}' # cover every IBAN
|
|
42
19
|
decoy verify invoice-decoyed.pdf -m 'ES\d{2}( ?\d{4}){5}' --original invoice.pdf
|
|
43
|
-
decoy mcp # find_text, redact, and verify as MCP tools over stdio
|
|
44
20
|
```
|
|
45
21
|
|
|
46
|
-
`redact`
|
|
47
|
-
|
|
48
|
-
For agents, `skills/decoy-redact/SKILL.md` is a ready-made skill (copy it into `.claude/skills/` or `~/.claude/skills/`), and the MCP server plugs into any client:
|
|
22
|
+
`redact` writes `invoice-decoyed.pdf` next to the input. `verify` proves the result: no text layer, no metadata, no pattern still readable, and not a single pixel changed outside the boxes.
|
|
49
23
|
|
|
50
|
-
|
|
51
|
-
claude mcp add decoy -- npx -y @ggalmazor/decoy mcp
|
|
52
|
-
```
|
|
24
|
+
## Commands
|
|
53
25
|
|
|
54
|
-
|
|
55
|
-
{ "mcpServers": { "decoy": { "command": "npx", "args": ["-y", "@ggalmazor/decoy", "mcp"] } } }
|
|
56
|
-
```
|
|
26
|
+
### `decoy find <file> [-m <regex>]...`
|
|
57
27
|
|
|
58
|
-
|
|
28
|
+
Lists every line of a PDF's text with its box in page pixels (150 dpi). With one or more `-m` patterns, lists only the matches. Images have no text layer and return nothing; redact them with `-b` boxes.
|
|
59
29
|
|
|
60
|
-
|
|
30
|
+
### `decoy redact <file> [options]`
|
|
61
31
|
|
|
62
|
-
|
|
32
|
+
Covers every match of every `-m` pattern and every `-b` box with a decoy, then writes a flattened copy.
|
|
63
33
|
|
|
64
|
-
|
|
34
|
+
| Option | Meaning |
|
|
35
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
36
|
+
| `-m, --match <regex>` | JavaScript regular expression, matched per line, so a number split at spaces still matches. Repeatable. PDFs only. |
|
|
37
|
+
| `-b, --box <x,y,w,h[@page]>` | A box in page pixels, 1-based page, page 1 when omitted. Repeatable. Works for images too. |
|
|
38
|
+
| `--mood <mood>` | `rude`, `smug` (default), `corporate`, or `wholesome`. |
|
|
39
|
+
| `--clean` | No profanity. |
|
|
40
|
+
| `--text <phrase>` | Use this phrase for every box instead of letting DECOY pick one. |
|
|
41
|
+
| `--seed <n>` | Reproducible phrase choice. |
|
|
42
|
+
| `--cell <px>` | Mosaic block size, 4 to 16 (default 8). |
|
|
43
|
+
| `--watermark` | A tiny "decoyed" in the corner. |
|
|
44
|
+
| `-f, --format <pdf\|png>` | Defaults to the input kind. PNG writes one file per page. |
|
|
45
|
+
| `-o, --out <path>` | Defaults to `<name>-decoyed.<format>` next to the input. |
|
|
46
|
+
| `--json` | Machine-readable output (works on every command). |
|
|
65
47
|
|
|
66
|
-
|
|
48
|
+
### `decoy verify <file> [-m <regex>]... [-b <box>]... [--original <file>]`
|
|
67
49
|
|
|
68
|
-
|
|
69
|
-
mise install
|
|
70
|
-
npm ci
|
|
71
|
-
mise run dev # http://localhost:5173
|
|
72
|
-
mise run build # static site in dist/
|
|
73
|
-
mise run preview # serve dist/ on http://127.0.0.1:4173
|
|
74
|
-
```
|
|
50
|
+
Checks a DECOY output. For a PDF: no text layer, producer is DECOY, author, subject, keywords, and creator are empty. For each pattern: nothing readable matches it. With `--original`: every pattern matched something in the original, pixels inside those boxes changed, and no pixel outside them did. Give it the same patterns and boxes the redaction used. Exit code 0 when everything holds, 1 otherwise.
|
|
75
51
|
|
|
76
|
-
|
|
52
|
+
### `decoy update`
|
|
77
53
|
|
|
78
|
-
|
|
79
|
-
mise run lint # ESLint (typescript-eslint, type-checked) and Prettier
|
|
80
|
-
mise run typecheck # tsc --noEmit
|
|
81
|
-
mise run test # Vitest: everything in src/core
|
|
82
|
-
mise run test:e2e # Playwright smoke tests against the production build (needs: npx playwright install chromium)
|
|
83
|
-
mise run check # all of the above, in CI order
|
|
84
|
-
```
|
|
54
|
+
Asks the npm registry whether a newer version exists and prints the install command. This is the only network request DECOY ever makes, and it runs only when you ask.
|
|
85
55
|
|
|
86
|
-
|
|
56
|
+
### `decoy mcp`
|
|
87
57
|
|
|
88
|
-
|
|
58
|
+
Serves the same operations as MCP tools over stdio: `find_text`, `redact`, `verify`, and `check_update`.
|
|
89
59
|
|
|
90
|
-
|
|
91
|
-
- `src/core/` is pure and unit-tested: `rng` (mulberry32, plus the reference LCG for mosaic jitter), `phrases` (four moods as data with an explicit `profane` flag, templates expanded to over 40 lines per mood), `fit` (font size `0.82·h`, candidates at 85–100 % of `w − 4`, ranked by closeness to 95 % with seeded noise so rerolls vary; two phrases joined for long boxes, chains for longer, short fallbacks for tiny ones), `mosaic` (`blockDarkness`, `mosaicCells`, `rasterizeCells` with integer block edges at `round(c·w/cols)`, a port of `design/mockups/mosaic.js` kept honest by an oracle test), `sample` (paper = mean above the 60th luma percentile, ink = mean below the 5th, fallback ink `#17140F`), `geometry`, `history`, and `grawlix`.
|
|
92
|
-
- `src/render/` rasterises the decoy phrase in Courier Prime 700 and caches each mosaic bitmap by its parameters. `src/doc/` loads images with `createImageBitmap` and PDFs with pdf.js at 150 dpi, and exports with canvas re-encoding and pdf-lib.
|
|
93
|
-
- Build-time Vite plugins (`scripts/vite-*.ts`) inject the CSP into the built HTML only (dev HMR needs inline scripts), copy pdf.js's `cmaps/`, `standard_fonts/`, `iccs/`, and `wasm/` under `/pdfjs/`, emit `version.json`, and generate `sw.js` with the exact list of built assets to precache.
|
|
94
|
-
- Profanity is never printed in plain on the website. Everything that displays a phrase goes through `grawlix()`, which keeps the first letter and swaps the vowels for comic-book symbols (`f@ck`). The mosaic always uses the real phrase.
|
|
60
|
+
## Agents
|
|
95
61
|
|
|
96
|
-
|
|
62
|
+
Wire the MCP server into Claude Code for every project:
|
|
97
63
|
|
|
98
|
-
|
|
64
|
+
```sh
|
|
65
|
+
claude mcp add --scope user decoy -- npx -y @ggalmazor/decoy mcp
|
|
66
|
+
```
|
|
99
67
|
|
|
100
|
-
|
|
68
|
+
Any other MCP client takes the same command:
|
|
101
69
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
- The tag triggers `.github/workflows/deploy.yml`, which checks the tag against `package.json`, builds, publishes to Pages with `actions/upload-pages-artifact` and `actions/deploy-pages`, and waits until `https://decoy.ggalmazor.com/version.json` reports the new version. `.github/workflows/ci.yml` runs lint, typecheck, unit, build, and e2e on every push and pull request.
|
|
70
|
+
```json
|
|
71
|
+
{ "mcpServers": { "decoy": { "command": "npx", "args": ["-y", "@ggalmazor/decoy", "mcp"] } } }
|
|
72
|
+
```
|
|
106
73
|
|
|
107
|
-
|
|
74
|
+
Then ask for what you want: "redact the IBAN and every email in `~/Downloads/invoice.pdf`, then verify it". The tool descriptions tell the agent to look first, redact by pattern, verify with the same patterns, and report what it covered. `skills/decoy-redact/SKILL.md` is the same routine as a skill for agents that do not speak MCP; copy it into `.claude/skills/` or `~/.claude/skills/`.
|
|
108
75
|
|
|
109
|
-
|
|
110
|
-
2. **GitHub Pages:** in the repository settings, under _Pages_, set _Source_ to **GitHub Actions**, enter `decoy.ggalmazor.com` as the custom domain, wait for the DNS check, and tick **Enforce HTTPS** once the certificate is issued.
|
|
111
|
-
3. **Let tags deploy:** the `github-pages` environment that Pages creates only admits deployments from `main`, and a release is a `v*` tag. Under _Settings > Environments > github-pages > Deployment branches and tags_, add a **Tag** rule for `v*`, or run, with an admin account:
|
|
76
|
+
## How it works
|
|
112
77
|
|
|
113
|
-
|
|
114
|
-
gh api -X POST repos/ggalmazor/decoy/environments/github-pages/deployment-branch-policies -f name='v*' -f type=tag
|
|
115
|
-
```
|
|
78
|
+
Pixelation is reversible. A mosaic block is the average colour of the pixels under it, so a depixelizer renders guesses in the font it sees, pixelates them the same way, and keeps the match. Passwords, PINs, and account numbers are the easiest targets.
|
|
116
79
|
|
|
117
|
-
|
|
80
|
+
DECOY never pixelates the secret. The box is filled from the decoy phrase alone: the mosaic is a function of the phrase, the box size, the block size, and two colours sampled around the box so it blends in. The original pixels are discarded before anything is drawn, and the file is rebuilt as images, so no text layer, annotation, or metadata survives. A test in the repository checks that two documents differing only inside a box export identically inside it, and a maintainer's tool runs Depix, a real depixelizer, against the output to make sure it finds nothing.
|
|
118
81
|
|
|
119
|
-
|
|
120
|
-
5. **npm:** `.github/workflows/publish.yml` publishes `@ggalmazor/decoy` on the same tag. For the first release add a repository secret `NPM_TOKEN` (a granular access token from npmjs.com with publish rights for the package or the `@ggalmazor` scope, allowed to bypass 2FA). Once the package exists you can switch to trusted publishing: on npmjs.com, under the package's settings, add GitHub Actions as a trusted publisher with repository `ggalmazor/decoy` and workflow `publish.yml`, then delete the secret. Either way every release puts the site and the package out at the same version, with provenance.
|
|
82
|
+
What DECOY cannot do for you: find text that is not in the PDF's text layer (scans and images need boxes), hide how long the secret was (the box size still says), or unsee what someone already saw.
|
|
121
83
|
|
|
122
|
-
##
|
|
84
|
+
## Development
|
|
123
85
|
|
|
124
|
-
|
|
86
|
+
`docs/development.md` covers running the site and the tests, how the code is organised, the Depix QA tool, and how releases ship to Pages and npm. `AGENTS.md` has the working rules for anyone, human or otherwise, changing the code.
|
|
125
87
|
|
|
126
|
-
|
|
88
|
+
## Licence
|
|
127
89
|
|
|
128
|
-
|
|
90
|
+
MIT. Fonts under the SIL Open Font License, PDF rendering by pdf.js, PDF writing by pdf-lib; see `docs/development.md` for credits.
|
package/dist-cli/decoy.mjs
CHANGED
|
@@ -1460,6 +1460,72 @@ function round(rect) {
|
|
|
1460
1460
|
};
|
|
1461
1461
|
}
|
|
1462
1462
|
//#endregion
|
|
1463
|
+
//#region src/cli/update.ts
|
|
1464
|
+
var PACKAGE_NAME = "@ggalmazor/decoy";
|
|
1465
|
+
var REGISTRY = "https://registry.npmjs.org";
|
|
1466
|
+
function parts(version) {
|
|
1467
|
+
const [core, prerelease = null] = version.replace(/^v/, "").split("-", 2);
|
|
1468
|
+
return {
|
|
1469
|
+
numbers: core.split(".").map((n) => Number(n) || 0),
|
|
1470
|
+
prerelease
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
function compareVersions(a, b) {
|
|
1474
|
+
const pa = parts(a);
|
|
1475
|
+
const pb = parts(b);
|
|
1476
|
+
for (let i = 0; i < 3; i++) {
|
|
1477
|
+
const d = (pa.numbers[i] ?? 0) - (pb.numbers[i] ?? 0);
|
|
1478
|
+
if (d !== 0) return Math.sign(d);
|
|
1479
|
+
}
|
|
1480
|
+
if (pa.prerelease && !pb.prerelease) return -1;
|
|
1481
|
+
if (!pa.prerelease && pb.prerelease) return 1;
|
|
1482
|
+
return 0;
|
|
1483
|
+
}
|
|
1484
|
+
async function checkForUpdate(installed, fetchImpl = fetch, timeoutMs = 8e3) {
|
|
1485
|
+
const base = {
|
|
1486
|
+
package: PACKAGE_NAME,
|
|
1487
|
+
installed,
|
|
1488
|
+
command: `npm install -g ${PACKAGE_NAME}@latest`,
|
|
1489
|
+
registry: REGISTRY
|
|
1490
|
+
};
|
|
1491
|
+
try {
|
|
1492
|
+
const res = await fetchImpl(`${REGISTRY}/${encodeURIComponent(PACKAGE_NAME)}/latest`, {
|
|
1493
|
+
headers: { accept: "application/json" },
|
|
1494
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1495
|
+
});
|
|
1496
|
+
if (!res.ok) return {
|
|
1497
|
+
...base,
|
|
1498
|
+
latest: null,
|
|
1499
|
+
updateAvailable: false,
|
|
1500
|
+
error: `registry answered ${res.status}`
|
|
1501
|
+
};
|
|
1502
|
+
const body = await res.json();
|
|
1503
|
+
if (typeof body.version !== "string") return {
|
|
1504
|
+
...base,
|
|
1505
|
+
latest: null,
|
|
1506
|
+
updateAvailable: false,
|
|
1507
|
+
error: "registry answer had no version"
|
|
1508
|
+
};
|
|
1509
|
+
return {
|
|
1510
|
+
...base,
|
|
1511
|
+
latest: body.version,
|
|
1512
|
+
updateAvailable: compareVersions(body.version, installed) > 0
|
|
1513
|
+
};
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
return {
|
|
1516
|
+
...base,
|
|
1517
|
+
latest: null,
|
|
1518
|
+
updateAvailable: false,
|
|
1519
|
+
error: error.message
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
function describeUpdate(report) {
|
|
1524
|
+
if (report.error) return `${report.package} ${report.installed} installed; could not reach ${report.registry}: ${report.error}`;
|
|
1525
|
+
if (report.updateAvailable) return `${report.package} ${report.installed} installed, ${report.latest} available. Update with: ${report.command}`;
|
|
1526
|
+
return `${report.package} ${report.installed} is the latest version.`;
|
|
1527
|
+
}
|
|
1528
|
+
//#endregion
|
|
1463
1529
|
//#region src/cli/verify.ts
|
|
1464
1530
|
function inside(rects, x, y) {
|
|
1465
1531
|
return rects.some((r) => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h);
|
|
@@ -1692,11 +1758,22 @@ async function serveMcp(version) {
|
|
|
1692
1758
|
return failure(error);
|
|
1693
1759
|
}
|
|
1694
1760
|
});
|
|
1761
|
+
server.registerTool("check_update", {
|
|
1762
|
+
title: "Check for a newer decoy",
|
|
1763
|
+
description: "Asks registry.npmjs.org for the latest published version of @ggalmazor/decoy and compares it with the running one. This is the only network request decoy ever makes, and it happens only when this tool is called; nothing about the user or their files is sent. Returns installed, latest, updateAvailable, and the install command.",
|
|
1764
|
+
inputSchema: {}
|
|
1765
|
+
}, async () => {
|
|
1766
|
+
try {
|
|
1767
|
+
return json(await checkForUpdate(version));
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
return failure(error);
|
|
1770
|
+
}
|
|
1771
|
+
});
|
|
1695
1772
|
await server.connect(new StdioServerTransport());
|
|
1696
1773
|
}
|
|
1697
1774
|
//#endregion
|
|
1698
1775
|
//#region src/cli/main.ts
|
|
1699
|
-
var VERSION = "1.1
|
|
1776
|
+
var VERSION = "1.3.1";
|
|
1700
1777
|
function collect(value, previous) {
|
|
1701
1778
|
return [...previous, value];
|
|
1702
1779
|
}
|
|
@@ -1778,6 +1855,12 @@ program.command("verify").description("check a redacted file: no text layer, no
|
|
|
1778
1855
|
fail(error);
|
|
1779
1856
|
}
|
|
1780
1857
|
});
|
|
1858
|
+
program.command("update").description("ask the npm registry whether a newer version exists (the only network request decoy ever makes)").action(async () => {
|
|
1859
|
+
const report = await checkForUpdate(VERSION);
|
|
1860
|
+
if (program.opts().json) console.log(JSON.stringify(report, null, 2));
|
|
1861
|
+
else console.log(describeUpdate(report));
|
|
1862
|
+
process.exit(report.error ? 1 : 0);
|
|
1863
|
+
});
|
|
1781
1864
|
program.command("mcp").description("serve find_text, redact, and verify as MCP tools over stdio").action(async () => {
|
|
1782
1865
|
try {
|
|
1783
1866
|
await serveMcp(VERSION);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggalmazor/decoy",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Redaction that paints a pixelated decoy phrase over the secret instead of pixelating the secret. Web app, CLI, and MCP server; everything runs on your machine.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"redaction",
|