@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 CHANGED
@@ -1,128 +1,90 @@
1
1
  # DECOY
2
2
 
3
- **Pixelate the lie, not the secret.** DECOY is a client-side redaction tool. When you box a password, an IBAN, or a phone number in a screenshot or a PDF, it throws the original pixels away and fills the box with the _pixelated rendering of a different phrase_, a funny or rude one cut to fit, so anyone who "enhances" the export recovers the insult instead of the secret.
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
- Live at **https://decoy.ggalmazor.com**. Nothing you open ever leaves the tab.
5
+ ## Install
6
6
 
7
- ## Why plain pixelation is unsafe
8
-
9
- A mosaic block is just the average colour of the pixels under it. Pixelation is lossy, but it is not random: the same text in the same font, pixelated the same way, gives the same blocks every time. A depixelizer uses exactly that. It renders guesses (every word in a dictionary, every eight-character string, every phrase it can think of) in the font it sees on the page, pixelates each one with the block size it measures, and keeps whichever matches the blocks best. Short, structured secrets such as passwords, PINs, and account numbers are the easiest targets, because the search space is small and the font is known.
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
- ## Command line and agents
12
+ Node 22 or later.
32
13
 
33
- The same core runs in Node for scripts and agents. The package is [`@ggalmazor/decoy`](https://www.npmjs.com/package/@ggalmazor/decoy); it installs a `decoy` command and an MCP server, and like the site it never sends a byte anywhere.
14
+ ## Quick start
34
15
 
35
16
  ```sh
36
- npm install -g @ggalmazor/decoy # or prefix each call with: npx -y @ggalmazor/decoy
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` covers every match of every `-m` pattern (a JavaScript regular expression, matched per line so a number split at spaces still matches) and every `-b` box with a decoy, then writes `<name>-decoyed.pdf` or `.png` next to the input. `--mood`, `--clean`, `--cell`, `--seed`, `--text`, and `--watermark` are the panel's controls; `--json` on any command returns machine-readable output. `verify` proves what an agent can prove about a file it did not watch being made: a PDF has no text layer and no metadata, no pattern is still readable, and, given `--original`, only the matched boxes changed.
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
- ```sh
51
- claude mcp add decoy -- npx -y @ggalmazor/decoy mcp
52
- ```
24
+ ## Commands
53
25
 
54
- ```json
55
- { "mcpServers": { "decoy": { "command": "npx", "args": ["-y", "@ggalmazor/decoy", "mcp"] } } }
56
- ```
26
+ ### `decoy find <file> [-m <regex>]...`
57
27
 
58
- The CLI is built with `mise run build:cli` into `dist-cli/`, from the same `src/core` the site uses; `src/cli/` holds the Node side (pdf.js with a Node canvas, the text search, and the MCP server).
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
- ## QA: attacking the output
30
+ ### `decoy redact <file> [options]`
61
31
 
62
- `tools/qa/depix-qa.mjs` is a maintainer's shell tool that runs [Depix](https://codeberg.org/spipm/Depixelization_poc), the standard pixelation-reversal tool, against DECOY's exports, next to plain pixelation of the same region for comparison. `mise run qa -- demo` draws a page with a secret in DECOY's own font, pixelates the secret the usual way, decoys the same box through the real app, and runs Depix on both cutouts with a search image rendered by the same browser engine. Depix reconstructs the secret from plain pixelation and finds nothing in the DECOY export. `mise run qa:attack -- <file>` takes an exported PNG or PDF, finds every mosaic on every page by itself, and runs Depix on each. `tools/qa/README.md` has the pieces (`crop`, `pixelate`, `search-image`, `depix`) behind it and the tool's limits.
32
+ Covers every match of every `-m` pattern and every `-b` box with a decoy, then writes a flattened copy.
63
33
 
64
- ## Running it
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
- Tools are managed with [mise](https://mise.jdx.dev) (`mise.toml` pins Node 22 and defines the tasks). Without mise, use Node 22 and `npm run <script>` directly.
48
+ ### `decoy verify <file> [-m <regex>]... [-b <box>]... [--original <file>]`
67
49
 
68
- ```sh
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
- Checks:
52
+ ### `decoy update`
77
53
 
78
- ```sh
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
- `node scripts/make-fixtures.mjs` regenerates the e2e fixtures. `node scripts/screenshots.mjs` takes desktop and 390 px screenshots of the landing page and editor from a running dev server into `e2e/output/shots/`, for comparing with `design/screens/`.
56
+ ### `decoy mcp`
87
57
 
88
- ## How it is built
58
+ Serves the same operations as MCP tools over stdio: `find_text`, `redact`, `verify`, and `check_update`.
89
59
 
90
- - **Vite 8 + TypeScript (strict) + Preact with signals.** Preact was chosen over vanilla TS and Lit because the settings panel is a dense set of derived values (fit ratio, capacity, alternatives, status label, meter) that re-render on every keystroke, drag, and reroll; signals express that as a handful of `computed`s with no manual DOM bookkeeping, at about 4 kB. The canvas itself is imperative (pointer events and a compositing effect); Preact only owns the chrome around it.
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
- The design kit in `design/` is the source of truth for look and copy; `AGENTS.md` explains the working rules.
62
+ Wire the MCP server into Claude Code for every project:
97
63
 
98
- ## Deploying
64
+ ```sh
65
+ claude mcp add --scope user decoy -- npx -y @ggalmazor/decoy mcp
66
+ ```
99
67
 
100
- The site is a plain static folder (`dist/`), so it can be served from anywhere. The repository deploys to GitHub Pages at `decoy.ggalmazor.com` following the release practice described in `AGENTS.md`:
68
+ Any other MCP client takes the same command:
101
69
 
102
- - A branch adds a `changelog.d/<branch>.md` fragment and never touches the version or `CHANGELOG.md`.
103
- - `mise run merge <branch>` merges into `main` under a lock, runs the full suite in a merge-queue worktree, and pushes `main`.
104
- - `mise run release` folds the fragments into `CHANGELOG.md`, writes the version to `package.json`, commits `Release X.Y.Z`, and pushes the commit with its `vX.Y.Z` tag.
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
- ### One-time setup
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
- 1. **DNS:** add a `CNAME` record for `decoy` pointing at `ggalmazor.github.io`. `public/CNAME` already contains `decoy.ggalmazor.com`, so every deploy keeps the custom domain.
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
- ```sh
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
- `deploy/release.sh` checks this rule before it tags and refuses to release while it is missing.
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
- 4. **First release:** push `main`, then run `mise run release` (the version starts at 0.0.0, so the first release is 0.1.0; pass `--major` for 1.0.0).
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
- ## Credits
84
+ ## Development
123
85
 
124
- Fonts are self-hosted from `design/fonts/` and licensed under the SIL Open Font License 1.1: [Anton](https://fonts.google.com/specimen/Anton) by Vernon Adams, [IBM Plex Mono](https://github.com/IBM/plex) by IBM, and [Courier Prime](https://quoteunquoteapps.com/courierprime/) by Alan Dague-Greene for Quote-Unquote Apps. Their licences are in `design/fonts/LICENSE-*.txt`.
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
- PDF rendering by [pdf.js](https://mozilla.github.io/pdf.js/) (Apache 2.0), PDF writing by [pdf-lib](https://pdf-lib.js.org/) (MIT).
88
+ ## Licence
127
89
 
128
- DECOY is released under the [MIT License](LICENSE).
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.
@@ -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.0";
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.0",
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",