diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml
new file mode 100644
index 0000000..b84a7cb
--- /dev/null
+++ b/.gitea/workflows/verify.yml
@@ -0,0 +1,39 @@
+name: Verify
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: verify-${{ gitea.repository }}-${{ gitea.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ env:
+ CI: "true"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ - name: Select declared npm version
+ run: npm install --global npm@11.17.0
+ - name: Install dependencies
+ run: npm ci
+ - name: Audit runtime dependencies
+ run: npm audit --omit=dev --audit-level=moderate
+ - name: Check, test, and build
+ run: npm run check
+ - name: Install browser engines
+ run: npx playwright install --with-deps chromium firefox webkit
+ - name: Browser tests
+ run: npm run test:browser
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e911593..4028ec7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 0.2.0 - 2026-09-02
+
+- Added reusable selective-removal profiles and pseudonymized per-file policy evidence across supported and inspect-only formats.
+- Distinguished verified full re-encoding, available re-encoding, inspect-only input, and non-executable selective-preservation requests; added Toolbox I/O/capability metadata.
+
## 0.1.0 - 2026-09-01
- Added byte-signature inventory, claimed-vs-detected media types, safe names,
diff --git a/README.md b/README.md
index a1c415f..bc28dbe 100644
--- a/README.md
+++ b/README.md
@@ -22,9 +22,24 @@ lookups, telemetry, or runtime network calls. A source file is never modified.
removed, preserved, generated, unsupported, or incompletely checked, and
includes source/output SHA-256 hashes and a bounded decoded-pixel sample
comparison.
-5. Download an individual re-encoded image, a JSON report, or a ZIP containing
- re-encoded images and the report. The report itself may be sensitive because
- it contains source filenames and metadata values.
+5. Download generic sequentially named outputs, a pseudonymized safe-share
+ report, or a ZIP containing both. The separate detailed report preserves
+ source filenames and metadata values and may itself be sensitive.
+6. Apply a reusable selective-removal policy to the scan evidence. The policy
+ matrix records remove, preserve, and review categories for every input,
+ distinguishes verified full re-encoding from inspect-only formats, and can
+ be exported without source names or raw metadata values.
+
+The safe-share report deliberately omits source names, timestamps, hashes,
+exact byte sizes, metadata values and offsets. It retains scanner coverage,
+format/dimension facts and categorized counts; unusual combinations can still
+be identifying, so the report remains subject to human review.
+
+Policy evidence is a decision aid, not a surgical metadata editor. Current
+JPEG/PNG/WebP output is a complete decoded-pixel re-encode; it does not preserve
+selected source metadata. For TIFF, GIF, HEIF-family, JPEG XL, PDF, archives,
+Office and unknown inputs the policy honestly reports `inspect-only` or
+`policy-not-executable` rather than promising a cleaned file.
## Format support
@@ -107,7 +122,7 @@ npm run release:artifact
This checks the manifest, types, lint, formatting, unit fixtures, production
build, Toolbox contract, and Chromium/Firefox workflows, then creates the
-deterministic `release/privacy-tools-0.1.0.zip` plus its SHA-256 sidecar. The
+deterministic `release/privacy-tools-0.2.0.zip` plus its SHA-256 sidecar. The
archive contains the static application, project documents, and exact runtime
dependency licence texts.
diff --git a/SOURCE.md b/SOURCE.md
index 618fc8c..fdabb44 100644
--- a/SOURCE.md
+++ b/SOURCE.md
@@ -1,8 +1,8 @@
# Corresponding source
-The corresponding source for Privacy Tools 0.1.0 is published at:
+The corresponding source for Privacy Tools 0.2.0 is published at:
-https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.1.0
+https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.2.0
Build with Node.js 22 and npm 11:
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index ef8a284..933df24 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -11,9 +11,9 @@ Material runtime components include:
| -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; |
| fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; |
-| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives |
-| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract |
-| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell |
+| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives |
+| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | Toolbox manifest contract |
+| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | Shared application shell |
| React / React DOM | 19.2.8 | MIT | User interface |
ExifReader is used unmodified. Its MPL-2.0 covered source remains available at
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 8affc35..6974a8d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -27,8 +27,14 @@ arrays, and plain objects. No parser returns HTML or a live third-party object.
decoded sample. `buildSanitizationReport` assigns verified/warning/failed
based on explicit coverage and output findings.
6. `archive` serializes bounded deterministic JSON and creates stored ZIP
- entries from application-generated `images/` paths and sanitized unique
- names.
+ entries from application-generated `images/` paths. Safe-share archives use
+ generic sequential image names and a reduced report; detailed reports remain
+ an explicit, separate download for local auditing.
+7. `policy` applies frozen, versioned category rules to normalized findings. It
+ creates pseudonymous per-file evidence across every inventoried format and
+ separately reports whether the requested removal can be executed by the
+ existing verified re-encode path. New policy profiles can be added without
+ changing a container parser.
Pixel decoding and Canvas encoding currently run on the main browser context
because portable cross-browser image encoder support is there; metadata parsing
diff --git a/docs/PRIVACY-SECURITY.md b/docs/PRIVACY-SECURITY.md
index 8717d5a..3cb22dc 100644
--- a/docs/PRIVACY-SECURITY.md
+++ b/docs/PRIVACY-SECURITY.md
@@ -55,5 +55,17 @@ filesystem metadata, clipboard/history records, application caches, backed-up
originals, or cloud/recipient copies. Review the visible output, destination,
report, and surrounding files yourself.
-The JSON report is sensitive by design: it can include original filenames,
-hashes, timestamps, and metadata values. Share or retain it only intentionally.
+The detailed JSON report is sensitive by design: it can include original
+filenames, hashes, timestamps, metadata values, offsets, and parser notes. Share
+or retain it only intentionally. The separate safe-share report replaces file
+identity with sequential pseudonyms and omits those source identifiers and raw
+values. It retains formats, dimensions, coverage states, note counts, and
+finding-category totals so it is useful for review without claiming anonymity.
+Safe-share ZIPs likewise use generic sequential output names.
+
+Selective-removal policy evidence follows the same pseudonymization boundary.
+It can show which finding categories a policy would remove, preserve, or send
+for review, but it never upgrades inspection coverage and never calls a format
+safe when no verified output exists. A requested “location-only” policy is
+therefore non-executable with the current all-metadata pixel re-encode; users
+receive evidence rather than a misleading partially preserved output.
diff --git a/package-lock.json b/package-lock.json
index 27acc8e..4ab316f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,24 +1,24 @@
{
"name": "privacy-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "privacy-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3",
- "@add-ideas/toolbox-helpers": "0.1.0",
- "@add-ideas/toolbox-shell-react": "0.2.3",
+ "@add-ideas/toolbox-contract": "0.3.0",
+ "@add-ideas/toolbox-helpers": "0.2.0",
+ "@add-ideas/toolbox-shell-react": "0.3.0",
"exifreader": "4.44.0",
"fflate": "0.8.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
- "@add-ideas/toolbox-testkit": "0.2.3",
+ "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -44,23 +44,22 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
- "version": "0.2.3",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=20"
- }
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
+ "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
+ "license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
- "version": "0.1.0",
- "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
- "integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
+ "version": "0.2.0",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
- "version": "0.2.3",
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
+ "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3"
+ "@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -68,17 +67,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
- "version": "0.2.3",
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
+ "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3"
+ "@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
- },
- "engines": {
- "node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
diff --git a/package.json b/package.json
index f079fe7..67d7be4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "privacy-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Inspect and remove shareable-file metadata locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,16 +39,16 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3",
- "@add-ideas/toolbox-helpers": "0.1.0",
- "@add-ideas/toolbox-shell-react": "0.2.3",
+ "@add-ideas/toolbox-contract": "0.3.0",
+ "@add-ideas/toolbox-helpers": "0.2.0",
+ "@add-ideas/toolbox-shell-react": "0.3.0",
"exifreader": "4.44.0",
"fflate": "0.8.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
- "@add-ideas/toolbox-testkit": "0.2.3",
+ "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
diff --git a/playwright.config.ts b/playwright.config.ts
index 731da62..2ab6a22 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
- { name: "chromium", use: { ...devices["Desktop Chrome"] } },
- { name: "firefox", use: { ...devices["Desktop Firefox"] } },
+ {
+ name: "chromium",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Chrome"] },
+ },
+ {
+ name: "firefox",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Firefox"] },
+ },
+ {
+ name: "webkit",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Safari"] },
+ },
+ {
+ name: "mobile-chromium",
+ testMatch: /responsive\.spec\.ts/,
+ use: { ...devices["Pixel 5"] },
+ },
],
});
diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md
index e911593..4028ec7 100644
--- a/public/CHANGELOG.md
+++ b/public/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 0.2.0 - 2026-09-02
+
+- Added reusable selective-removal profiles and pseudonymized per-file policy evidence across supported and inspect-only formats.
+- Distinguished verified full re-encoding, available re-encoding, inspect-only input, and non-executable selective-preservation requests; added Toolbox I/O/capability metadata.
+
## 0.1.0 - 2026-09-01
- Added byte-signature inventory, claimed-vs-detected media types, safe names,
diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt
index b143f48..1b15834 100644
--- a/public/LICENSES/npm-runtime-licenses.txt
+++ b/public/LICENSES/npm-runtime-licenses.txt
@@ -1,5 +1,5 @@
==============================================================================
-@add-ideas/toolbox-contract@0.2.3
+@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
-@add-ideas/toolbox-helpers@0.1.0
+@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
-@add-ideas/toolbox-shell-react@0.2.3
+@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
diff --git a/public/README.md b/public/README.md
index a1c415f..bc28dbe 100644
--- a/public/README.md
+++ b/public/README.md
@@ -22,9 +22,24 @@ lookups, telemetry, or runtime network calls. A source file is never modified.
removed, preserved, generated, unsupported, or incompletely checked, and
includes source/output SHA-256 hashes and a bounded decoded-pixel sample
comparison.
-5. Download an individual re-encoded image, a JSON report, or a ZIP containing
- re-encoded images and the report. The report itself may be sensitive because
- it contains source filenames and metadata values.
+5. Download generic sequentially named outputs, a pseudonymized safe-share
+ report, or a ZIP containing both. The separate detailed report preserves
+ source filenames and metadata values and may itself be sensitive.
+6. Apply a reusable selective-removal policy to the scan evidence. The policy
+ matrix records remove, preserve, and review categories for every input,
+ distinguishes verified full re-encoding from inspect-only formats, and can
+ be exported without source names or raw metadata values.
+
+The safe-share report deliberately omits source names, timestamps, hashes,
+exact byte sizes, metadata values and offsets. It retains scanner coverage,
+format/dimension facts and categorized counts; unusual combinations can still
+be identifying, so the report remains subject to human review.
+
+Policy evidence is a decision aid, not a surgical metadata editor. Current
+JPEG/PNG/WebP output is a complete decoded-pixel re-encode; it does not preserve
+selected source metadata. For TIFF, GIF, HEIF-family, JPEG XL, PDF, archives,
+Office and unknown inputs the policy honestly reports `inspect-only` or
+`policy-not-executable` rather than promising a cleaned file.
## Format support
@@ -107,7 +122,7 @@ npm run release:artifact
This checks the manifest, types, lint, formatting, unit fixtures, production
build, Toolbox contract, and Chromium/Firefox workflows, then creates the
-deterministic `release/privacy-tools-0.1.0.zip` plus its SHA-256 sidecar. The
+deterministic `release/privacy-tools-0.2.0.zip` plus its SHA-256 sidecar. The
archive contains the static application, project documents, and exact runtime
dependency licence texts.
diff --git a/public/SOURCE.md b/public/SOURCE.md
index 618fc8c..fdabb44 100644
--- a/public/SOURCE.md
+++ b/public/SOURCE.md
@@ -1,8 +1,8 @@
# Corresponding source
-The corresponding source for Privacy Tools 0.1.0 is published at:
+The corresponding source for Privacy Tools 0.2.0 is published at:
-https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.1.0
+https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.2.0
Build with Node.js 22 and npm 11:
diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md
index ef8a284..933df24 100644
--- a/public/THIRD_PARTY_NOTICES.md
+++ b/public/THIRD_PARTY_NOTICES.md
@@ -11,9 +11,9 @@ Material runtime components include:
| -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; |
| fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; |
-| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives |
-| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract |
-| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell |
+| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives |
+| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | Toolbox manifest contract |
+| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | Shared application shell |
| React / React DOM | 19.2.8 | MIT | User interface |
ExifReader is used unmodified. Its MPL-2.0 covered source remains available at
diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md
index 8affc35..6974a8d 100644
--- a/public/docs/ARCHITECTURE.md
+++ b/public/docs/ARCHITECTURE.md
@@ -27,8 +27,14 @@ arrays, and plain objects. No parser returns HTML or a live third-party object.
decoded sample. `buildSanitizationReport` assigns verified/warning/failed
based on explicit coverage and output findings.
6. `archive` serializes bounded deterministic JSON and creates stored ZIP
- entries from application-generated `images/` paths and sanitized unique
- names.
+ entries from application-generated `images/` paths. Safe-share archives use
+ generic sequential image names and a reduced report; detailed reports remain
+ an explicit, separate download for local auditing.
+7. `policy` applies frozen, versioned category rules to normalized findings. It
+ creates pseudonymous per-file evidence across every inventoried format and
+ separately reports whether the requested removal can be executed by the
+ existing verified re-encode path. New policy profiles can be added without
+ changing a container parser.
Pixel decoding and Canvas encoding currently run on the main browser context
because portable cross-browser image encoder support is there; metadata parsing
diff --git a/public/docs/PRIVACY-SECURITY.md b/public/docs/PRIVACY-SECURITY.md
index 8717d5a..3cb22dc 100644
--- a/public/docs/PRIVACY-SECURITY.md
+++ b/public/docs/PRIVACY-SECURITY.md
@@ -55,5 +55,17 @@ filesystem metadata, clipboard/history records, application caches, backed-up
originals, or cloud/recipient copies. Review the visible output, destination,
report, and surrounding files yourself.
-The JSON report is sensitive by design: it can include original filenames,
-hashes, timestamps, and metadata values. Share or retain it only intentionally.
+The detailed JSON report is sensitive by design: it can include original
+filenames, hashes, timestamps, metadata values, offsets, and parser notes. Share
+or retain it only intentionally. The separate safe-share report replaces file
+identity with sequential pseudonyms and omits those source identifiers and raw
+values. It retains formats, dimensions, coverage states, note counts, and
+finding-category totals so it is useful for review without claiming anonymity.
+Safe-share ZIPs likewise use generic sequential output names.
+
+Selective-removal policy evidence follows the same pseudonymization boundary.
+It can show which finding categories a policy would remove, preserve, or send
+for review, but it never upgrades inspection coverage and never calls a format
+safe when no verified output exists. A requested “location-only” policy is
+therefore non-executable with the current all-metadata pixel re-encode; users
+receive evidence rather than a misleading partially preserved output.
diff --git a/public/sw.js b/public/sw.js
index ea09921..84b481d 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "privacy-tools-shell-";
-const CACHE_NAME = CACHE_PREFIX + "0.1.0";
+const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
diff --git a/public/toolbox-app.json b/public/toolbox-app.json
index 4236046..c06f3bd 100644
--- a/public/toolbox-app.json
+++ b/public/toolbox-app.json
@@ -3,12 +3,20 @@
"schemaVersion": 1,
"id": "de.add-ideas.privacy-tools",
"name": "Privacy Tools",
- "version": "0.1.0",
- "description": "Inspect and remove shareable-file metadata locally in the browser.",
+ "version": "0.2.0",
+ "description": "Inspect metadata and create policy-evidenced sharing copies locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["privacy", "files", "security"],
- "tags": ["metadata", "exif", "privacy", "sanitize", "share"],
+ "tags": [
+ "metadata",
+ "exif",
+ "privacy",
+ "sanitize",
+ "policy",
+ "evidence",
+ "share"
+ ],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +29,46 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
+ "io": {
+ "accepts": [
+ {
+ "mediaType": "*/*",
+ "extensions": [],
+ "label": "Files for bounded metadata inventory"
+ }
+ ],
+ "produces": [
+ {
+ "mediaType": "image/jpeg",
+ "extensions": [".jpg", ".jpeg"],
+ "label": "Verified re-encoded JPEG"
+ },
+ {
+ "mediaType": "image/png",
+ "extensions": [".png"],
+ "label": "Verified re-encoded PNG"
+ },
+ {
+ "mediaType": "image/webp",
+ "extensions": [".webp"],
+ "label": "Verified re-encoded WebP"
+ },
+ {
+ "mediaType": "application/json",
+ "extensions": [".json"],
+ "label": "Inspection and policy evidence"
+ },
+ {
+ "mediaType": "application/zip",
+ "extensions": [".zip"],
+ "label": "Safe-share bundle"
+ }
+ ]
+ },
+ "capabilities": {
+ "required": ["canvas-2d", "web-crypto", "workers"],
+ "optional": ["create-image-bitmap"]
+ },
"privacy": {
"processing": "local",
"fileUploads": true,
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
index 6677894..3a4c529 100644
--- a/src/components/Workbench.tsx
+++ b/src/components/Workbench.tsx
@@ -1,14 +1,20 @@
import { useEffect, useMemo, useRef, useState } from "react";
-import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
+import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
createBatchArchive,
createBatchReport,
+ createPolicyEvidence,
+ createSafeShareReport,
+ genericOutputName,
+ policyById,
+ SELECTIVE_REMOVAL_POLICIES,
sanitizeStaticImage,
scanFilesInWorker,
serializeReport,
type FindingCategory,
type ImageScanResult,
+ type SelectiveRemovalPolicy,
type SanitizedAsset,
} from "../privacy";
@@ -50,6 +56,9 @@ export function Workbench() {
const [progress, setProgress] = useState(null);
const [error, setError] = useState("");
const [dragging, setDragging] = useState(false);
+ const [genericNames, setGenericNames] = useState(true);
+ const [policyId, setPolicyId] =
+ useState("safe-share");
const abortRef = useRef(null);
const inputRef = useRef(null);
const cleanable = useMemo(
@@ -60,6 +69,16 @@ export function Workbench() {
() => records.flatMap((record) => (record.asset ? [record.asset] : [])),
[records],
);
+ const policy = useMemo(() => policyById(policyId), [policyId]);
+ const policyEvidence = useMemo(
+ () =>
+ createPolicyEvidence(
+ records.map((record) => record.scan),
+ assets,
+ policy,
+ ),
+ [assets, policy, records],
+ );
useEffect(
() => () => {
@@ -119,6 +138,12 @@ export function Workbench() {
try {
const asset = await sanitizeStaticImage(record.file, record.scan, {
signal: controller.signal,
+ outputName: genericNames
+ ? genericOutputName(
+ records.findIndex((item) => item.scan.id === id),
+ record.scan.identity.detectedKind,
+ )
+ : undefined,
});
setRecords((current) =>
current.map((item) =>
@@ -173,6 +198,12 @@ export function Workbench() {
try {
const asset = await sanitizeStaticImage(record.file, record.scan, {
signal: controller.signal,
+ outputName: genericNames
+ ? genericOutputName(
+ records.findIndex((item) => item.scan.id === record.scan.id),
+ record.scan.identity.detectedKind,
+ )
+ : undefined,
});
setRecords((current) =>
current.map((item) =>
@@ -222,6 +253,36 @@ export function Workbench() {
}
};
+ const downloadSafeReport = () => {
+ setError("");
+ try {
+ const report = createSafeShareReport(
+ records.map((record) => record.scan),
+ assets,
+ );
+ triggerBlobDownload(
+ new Blob([serializeReport(report)], { type: "application/json" }),
+ "privacy-tools-safe-share-report.json",
+ );
+ } catch (caught) {
+ setError(errorMessage(caught));
+ }
+ };
+
+ const downloadPolicyEvidence = () => {
+ setError("");
+ try {
+ triggerBlobDownload(
+ new Blob([serializeReport(policyEvidence)], {
+ type: "application/json",
+ }),
+ `privacy-tools-${policy.id}-policy-evidence.json`,
+ );
+ } catch (caught) {
+ setError(errorMessage(caught));
+ }
+ };
+
const downloadArchive = async () => {
if (assets.length === 0) return;
setBusy("archive");
@@ -230,6 +291,8 @@ export function Workbench() {
const blob = await createBatchArchive(
records.map((record) => record.scan),
assets,
+ undefined,
+ "safe-share",
);
triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip");
} catch (caught) {
@@ -343,6 +406,15 @@ export function Workbench() {
+
+ Download safe-share report
+
- Download JSON report
+ Download detailed report
void downloadArchive()}
disabled={busy !== null || assets.length === 0}
>
- Download {assets.length} re-encoded{" "}
+ Download safe-share ZIP with {assets.length} re-encoded{" "}
{assets.length === 1 ? "image" : "images"} + report
+
+
+
+
Reusable policy evidence
+
Selective-removal policy
+
{policy.description}
+
+
+
+ Policy profile
+
+ setPolicyId(
+ event.target.value as SelectiveRemovalPolicy["id"],
+ )
+ }
+ >
+ {SELECTIVE_REMOVAL_POLICIES.map((item) => (
+
+ {item.name}
+
+ ))}
+
+
+
+ Download policy evidence
+
+
+
+
+
+
+
+ Evidence ID
+ Format
+ Decision
+ Operation
+ Remove / preserve / review
+
+
+
+ {policyEvidence.files.map((file) => (
+
+ {file.fileId}
+ {file.detectedKind}
+
+
+
+ {file.availableOperation}
+
+ {file.findings.remove} / {file.findings.preserve} /{" "}
+ {file.findings.review}
+
+
+ ))}
+
+
+
+
+ A policy can require preservation that the pixel re-encoder cannot
+ guarantee. In that case the evidence says “policy-not-executable”
+ instead of silently discarding metadata. Unsupported formats
+ remain inspect-only.
+
+
>
) : null}
@@ -672,12 +818,6 @@ function groupFindings(
return [...groups.entries()];
}
-function formatBytes(value: number): string {
- if (value < 1024) return `${value} B`;
- if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`;
- return `${(value / 1024 ** 2).toFixed(1)} MiB`;
-}
-
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "The operation failed.";
}
diff --git a/src/privacy/archive.ts b/src/privacy/archive.ts
index 090e974..94a4cfd 100644
--- a/src/privacy/archive.ts
+++ b/src/privacy/archive.ts
@@ -13,9 +13,13 @@ import {
} from "./limits";
import type {
BatchReport,
+ FindingCategory,
+ FindingRisk,
ImageScanResult,
+ MetadataFinding,
+ SafeShareFindingSummary,
+ SafeShareReport,
SanitizedAsset,
- SanitizationReport,
} from "./model";
export function createBatchReport(
@@ -36,9 +40,63 @@ export function createBatchReport(
};
}
-export function serializeReport(
- report: BatchReport | SanitizationReport,
-): string {
+export function createSafeShareReport(
+ files: readonly ImageScanResult[],
+ assets: readonly SanitizedAsset[],
+ generatedAt = new Date().toISOString(),
+): SafeShareReport {
+ const fileIds = new Map(
+ files.map((file, index) => [
+ file.id,
+ `file-${String(index + 1).padStart(3, "0")}`,
+ ]),
+ );
+ return {
+ schemaVersion: 1,
+ profile: "safe-share",
+ generatedAt,
+ application: { name: "Privacy Tools", version: APP_VERSION },
+ files: files.map((file, index) => ({
+ fileId:
+ fileIds.get(file.id) ?? `file-${String(index + 1).padStart(3, "0")}`,
+ detectedKind: file.identity.detectedKind,
+ detectedType: file.identity.detectedType,
+ typeMatch: file.identity.typeMatch,
+ dimensions:
+ file.width !== undefined && file.height !== undefined
+ ? { width: file.width, height: file.height }
+ : undefined,
+ animated: file.animated,
+ multiImage: file.multiImage,
+ coverage: {
+ projectScanner: file.coverage.projectScanner,
+ secondaryScanner: file.coverage.secondaryScanner,
+ noteCount: file.coverage.notes.length,
+ },
+ findings: summarizeFindings(file.findings),
+ warningCount: file.warnings.length,
+ })),
+ sanitizations: assets.map((asset, index) => ({
+ fileId: fileIds.get(asset.report.sourceId) ?? "unknown-file",
+ outputName: genericNameForAsset(asset, index),
+ outputType: asset.report.outputType,
+ status: asset.report.status,
+ orientationNormalized: asset.report.orientationNormalized,
+ removed: summarizeFindings(asset.report.removed),
+ preserved: summarizeFindings(asset.report.preserved),
+ generated: summarizeFindings(asset.report.generated),
+ unsupported: [...asset.report.unsupported],
+ incomplete: [...asset.report.incomplete],
+ })),
+ warnings: [
+ "This safe-share profile omits source filenames, timestamps, hashes, exact byte sizes, metadata values and offsets.",
+ "Counts, dimensions and format details can still identify unusual files; review before sharing.",
+ "A successful re-scan is not an anonymity guarantee.",
+ ],
+ };
+}
+
+export function serializeReport(report: object): string {
return stableStringify(report, 2, {
maxDepth: 64,
maxNodes: 500_000,
@@ -50,6 +108,7 @@ export async function createBatchArchive(
files: readonly ImageScanResult[],
assets: readonly SanitizedAsset[],
generatedAt = new Date().toISOString(),
+ reportProfile: "detailed" | "safe-share" = "detailed",
): Promise {
assertLimit(
files.length,
@@ -67,8 +126,13 @@ export async function createBatchArchive(
>;
const names = new Set();
let total = 0;
- for (const asset of assets) {
- const name = uniqueName(asset.report.outputName, names);
+ for (const [index, asset] of assets.entries()) {
+ const name = uniqueName(
+ reportProfile === "safe-share"
+ ? genericNameForAsset(asset, index)
+ : asset.report.outputName,
+ names,
+ );
const bytes = new Uint8Array(await asset.blob.arrayBuffer());
total += bytes.byteLength;
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
@@ -80,7 +144,11 @@ export async function createBatchArchive(
entries[`images/${name}`] = bytes;
}
const report = encodeText(
- serializeReport(createBatchReport(files, assets, generatedAt)),
+ serializeReport(
+ reportProfile === "safe-share"
+ ? createSafeShareReport(files, assets, generatedAt)
+ : createBatchReport(files, assets, generatedAt),
+ ),
);
total += report.byteLength;
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
@@ -104,6 +172,32 @@ export async function createBatchArchive(
return new Blob([ownedArchive], { type: "application/zip" });
}
+function summarizeFindings(
+ findings: readonly MetadataFinding[],
+): SafeShareFindingSummary {
+ const byRisk: Record = {
+ sensitive: 0,
+ context: 0,
+ technical: 0,
+ };
+ const byCategory: Partial> = {};
+ for (const finding of findings) {
+ byRisk[finding.risk] += 1;
+ byCategory[finding.category] = (byCategory[finding.category] ?? 0) + 1;
+ }
+ return { total: findings.length, byRisk, byCategory };
+}
+
+function genericNameForAsset(asset: SanitizedAsset, index: number): string {
+ const extension =
+ asset.report.outputType === "image/jpeg"
+ ? "jpg"
+ : asset.report.outputType === "image/webp"
+ ? "webp"
+ : "png";
+ return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`;
+}
+
function uniqueName(input: string, used: Set): string {
const safe = sanitizeDownloadFilename(input, "image.clean");
if (!used.has(safe)) {
diff --git a/src/privacy/index.ts b/src/privacy/index.ts
index b92c06a..41f6e61 100644
--- a/src/privacy/index.ts
+++ b/src/privacy/index.ts
@@ -2,6 +2,7 @@ export * from "./archive";
export * from "./detect";
export * from "./limits";
export * from "./model";
+export * from "./policy";
export * from "./sanitize";
export * from "./scan-client";
export * from "./scanner";
diff --git a/src/privacy/model.ts b/src/privacy/model.ts
index 2cb3a57..96496a7 100644
--- a/src/privacy/model.ts
+++ b/src/privacy/model.ts
@@ -131,12 +131,60 @@ export interface SanitizedAsset {
export interface BatchReport {
schemaVersion: 1;
generatedAt: string;
- application: { name: "Privacy Tools"; version: "0.1.0" };
+ application: {
+ name: "Privacy Tools";
+ version: typeof import("../version").APP_VERSION;
+ };
files: ImageScanResult[];
sanitizations: SanitizationReport[];
warnings: string[];
}
+export interface SafeShareFindingSummary {
+ total: number;
+ byRisk: Record;
+ byCategory: Partial>;
+}
+
+export interface SafeShareReport {
+ schemaVersion: 1;
+ profile: "safe-share";
+ generatedAt: string;
+ application: {
+ name: "Privacy Tools";
+ version: typeof import("../version").APP_VERSION;
+ };
+ files: Array<{
+ fileId: string;
+ detectedKind: DetectedKind;
+ detectedType: string;
+ typeMatch: InventoryIdentity["typeMatch"];
+ dimensions?: { width: number; height: number };
+ animated: boolean;
+ multiImage: boolean;
+ coverage: {
+ projectScanner: ParserCoverage["projectScanner"];
+ secondaryScanner: ParserCoverage["secondaryScanner"];
+ noteCount: number;
+ };
+ findings: SafeShareFindingSummary;
+ warningCount: number;
+ }>;
+ sanitizations: Array<{
+ fileId: string;
+ outputName: string;
+ outputType: string;
+ status: SanitizationStatus;
+ orientationNormalized: boolean;
+ removed: SafeShareFindingSummary;
+ preserved: SafeShareFindingSummary;
+ generated: SafeShareFindingSummary;
+ unsupported: string[];
+ incomplete: string[];
+ }>;
+ warnings: string[];
+}
+
export interface PrivacyLimits {
maxFiles: number;
maxFileBytes: number;
diff --git a/src/privacy/policy.ts b/src/privacy/policy.ts
new file mode 100644
index 0000000..a521f6b
--- /dev/null
+++ b/src/privacy/policy.ts
@@ -0,0 +1,266 @@
+import type {
+ FindingCategory,
+ ImageScanResult,
+ MetadataFinding,
+ SanitizedAsset,
+} from "./model";
+
+export type PolicyAction = "remove" | "preserve" | "review";
+
+export interface SelectiveRemovalPolicy {
+ readonly id: "safe-share" | "location-only" | "archive-review";
+ readonly name: string;
+ readonly description: string;
+ readonly actions: Readonly>;
+}
+
+export interface PolicyFileEvidence {
+ readonly fileId: string;
+ readonly detectedKind: ImageScanResult["identity"]["detectedKind"];
+ readonly coverage: ImageScanResult["coverage"];
+ readonly availableOperation:
+ | "verified-reencode"
+ | "reencode-available"
+ | "inspect-only"
+ | "policy-not-executable";
+ readonly decision: "verified" | "review" | "blocked" | "no-target-findings";
+ readonly findings: Readonly>;
+ readonly requiredRemovals: readonly string[];
+ readonly preservedAgainstPolicy: readonly string[];
+ readonly removedAgainstPolicy: readonly string[];
+ readonly unresolved: readonly string[];
+}
+
+export interface PolicyEvidence {
+ readonly schemaVersion: 1;
+ readonly profile: "selective-removal-evidence";
+ readonly generatedAt: string;
+ readonly policy: SelectiveRemovalPolicy;
+ readonly files: readonly PolicyFileEvidence[];
+ readonly limitations: readonly string[];
+}
+
+const categories: readonly FindingCategory[] = [
+ "location",
+ "identity",
+ "timestamp",
+ "device",
+ "software",
+ "document-id",
+ "comment",
+ "thumbnail",
+ "colour-profile",
+ "provenance",
+ "technical",
+ "unknown",
+];
+
+function actions(
+ remove: readonly FindingCategory[],
+ preserve: readonly FindingCategory[] = [],
+): Readonly> {
+ const removed = new Set(remove);
+ const preserved = new Set(preserve);
+ return Object.freeze(
+ Object.fromEntries(
+ categories.map((category) => [
+ category,
+ removed.has(category)
+ ? "remove"
+ : preserved.has(category)
+ ? "preserve"
+ : "review",
+ ]),
+ ) as unknown as Record,
+ );
+}
+
+export const SELECTIVE_REMOVAL_POLICIES: readonly SelectiveRemovalPolicy[] =
+ Object.freeze([
+ Object.freeze({
+ id: "safe-share",
+ name: "Strict safe-share",
+ description:
+ "Remove sensitive, contextual, preview and provenance metadata; independently review technical and colour-profile output.",
+ actions: actions([
+ "location",
+ "identity",
+ "timestamp",
+ "device",
+ "software",
+ "document-id",
+ "comment",
+ "thumbnail",
+ "provenance",
+ "unknown",
+ ]),
+ }),
+ Object.freeze({
+ id: "location-only",
+ name: "Location-only request",
+ description:
+ "Request removal of location metadata while preserving other metadata. The current pixel re-encoder cannot promise this selective preservation.",
+ actions: actions(
+ ["location"],
+ [
+ "identity",
+ "timestamp",
+ "device",
+ "software",
+ "document-id",
+ "comment",
+ "thumbnail",
+ "colour-profile",
+ "provenance",
+ "technical",
+ ],
+ ),
+ }),
+ Object.freeze({
+ id: "archive-review",
+ name: "Archival review",
+ description:
+ "Preserve provenance, colour and technical context while removing direct location and identity clues; all other categories require review.",
+ actions: actions(
+ ["location", "identity", "document-id"],
+ ["colour-profile", "provenance", "technical"],
+ ),
+ }),
+ ]);
+
+export function policyById(
+ id: SelectiveRemovalPolicy["id"],
+): SelectiveRemovalPolicy {
+ const policy = SELECTIVE_REMOVAL_POLICIES.find((item) => item.id === id);
+ if (!policy) throw new TypeError(`Unknown privacy policy: ${id}`);
+ return policy;
+}
+
+function counts(
+ findings: readonly MetadataFinding[],
+ policy: SelectiveRemovalPolicy,
+): Record {
+ const result: Record = {
+ remove: 0,
+ preserve: 0,
+ review: 0,
+ };
+ findings.forEach((finding) => {
+ result[policy.actions[finding.category]] += 1;
+ });
+ return result;
+}
+
+function signatures(findings: readonly MetadataFinding[]): string[] {
+ return findings.map(
+ (finding) => `${finding.category}: ${finding.source} / ${finding.label}`,
+ );
+}
+
+export function createPolicyEvidence(
+ files: readonly ImageScanResult[],
+ assets: readonly SanitizedAsset[],
+ policy: SelectiveRemovalPolicy,
+ generatedAt = new Date().toISOString(),
+): PolicyEvidence {
+ if (files.length > 100 || assets.length > 100)
+ throw new RangeError(
+ "Policy evidence is limited to 100 files and outputs.",
+ );
+ const outputBySource = new Map(
+ assets.map((asset) => [asset.report.sourceId, asset.report]),
+ );
+ return Object.freeze({
+ schemaVersion: 1,
+ profile: "selective-removal-evidence",
+ generatedAt,
+ policy,
+ files: Object.freeze(
+ files.map((file, index): PolicyFileEvidence => {
+ const report = outputBySource.get(file.id);
+ const grouped = counts(file.findings, policy);
+ const required = file.findings.filter(
+ (finding) => policy.actions[finding.category] === "remove",
+ );
+ const mustPreserve = file.findings.filter(
+ (finding) => policy.actions[finding.category] === "preserve",
+ );
+ const preservedAgainstPolicy = report
+ ? report.preserved.filter(
+ (finding) => policy.actions[finding.category] === "remove",
+ )
+ : [];
+ const removedAgainstPolicy = report
+ ? report.removed.filter(
+ (finding) => policy.actions[finding.category] === "preserve",
+ )
+ : [];
+ const generatedAgainstPolicy = report
+ ? report.generated.filter(
+ (finding) => policy.actions[finding.category] === "remove",
+ )
+ : [];
+ const unresolved: string[] = [];
+ if (file.coverage.projectScanner !== "complete")
+ unresolved.push("Project scanner coverage is not complete.");
+ if (file.coverage.secondaryScanner !== "complete")
+ unresolved.push("Secondary scanner coverage is not complete.");
+ if (!file.deepSupported)
+ unresolved.push(
+ "This format has inventory evidence only; metadata removal is unsupported.",
+ );
+ if (mustPreserve.length && file.cleanable)
+ unresolved.push(
+ "The pixel re-encoder cannot guarantee selective preservation of requested metadata.",
+ );
+ if (generatedAgainstPolicy.length)
+ unresolved.push(
+ `Output generated ${generatedAgainstPolicy.length} finding(s) marked for removal.`,
+ );
+ if (report?.incomplete.length) unresolved.push(...report.incomplete);
+ const violations =
+ preservedAgainstPolicy.length +
+ removedAgainstPolicy.length +
+ generatedAgainstPolicy.length;
+ const availableOperation: PolicyFileEvidence["availableOperation"] =
+ report && violations === 0
+ ? "verified-reencode"
+ : required.length === 0
+ ? "inspect-only"
+ : !file.cleanable
+ ? "inspect-only"
+ : mustPreserve.length
+ ? "policy-not-executable"
+ : "reencode-available";
+ const decision: PolicyFileEvidence["decision"] =
+ report && violations === 0 && unresolved.length === 0
+ ? "verified"
+ : required.length === 0 && unresolved.length === 0
+ ? "no-target-findings"
+ : violations > 0 || (required.length > 0 && !file.cleanable)
+ ? "blocked"
+ : "review";
+ return Object.freeze({
+ fileId: `file-${String(index + 1).padStart(3, "0")}`,
+ detectedKind: file.identity.detectedKind,
+ coverage: file.coverage,
+ availableOperation,
+ decision,
+ findings: grouped,
+ requiredRemovals: Object.freeze(signatures(required)),
+ preservedAgainstPolicy: Object.freeze(
+ signatures(preservedAgainstPolicy),
+ ),
+ removedAgainstPolicy: Object.freeze(signatures(removedAgainstPolicy)),
+ unresolved: Object.freeze(unresolved),
+ });
+ }),
+ ),
+ limitations: Object.freeze([
+ "Policy evidence summarizes bounded metadata scanners; it is not an anonymity guarantee.",
+ "Only supported static JPEG, PNG and WebP inputs can be pixel re-encoded. Other formats remain inspect-only.",
+ "Pixel re-encoding is an all-container rewrite, not a surgical metadata editor, so preservation policies can be non-executable.",
+ "Visible content, sidecars, watermarks, steganography and remote copies are outside this evidence.",
+ ]),
+ });
+}
diff --git a/src/privacy/sanitize.ts b/src/privacy/sanitize.ts
index 771b74c..6043f80 100644
--- a/src/privacy/sanitize.ts
+++ b/src/privacy/sanitize.ts
@@ -16,6 +16,8 @@ export interface SanitizeOptions {
jpegQuality?: number;
webpQuality?: number;
signal?: AbortSignal;
+ /** A caller-selected, non-identifying output name. The detected extension is enforced. */
+ outputName?: string;
}
export async function sanitizeStaticImage(
@@ -72,10 +74,9 @@ export async function sanitizeStaticImage(
);
throwIfAborted(options.signal);
const outputBytes = await blob.arrayBuffer();
- const outputName = cleanOutputName(
- source.safeName,
- source.identity.detectedKind,
- );
+ const outputName = options.outputName
+ ? enforceOutputExtension(options.outputName, source.identity.detectedKind)
+ : cleanOutputName(source.safeName, source.identity.detectedKind);
const outputScan = await scanImageBytes({
id: `${source.id}-clean`,
name: outputName,
@@ -393,6 +394,30 @@ function cleanOutputName(
return sanitizeDownloadFilename(`${withoutExtension}.clean.${extension}`);
}
+export function genericOutputName(
+ index: number,
+ kind: ImageScanResult["identity"]["detectedKind"],
+): string {
+ if (!Number.isSafeInteger(index) || index < 0)
+ throw new RangeError(
+ "Generic output index must be a non-negative integer.",
+ );
+ const extension = kind === "jpeg" ? "jpg" : kind;
+ if (!new Set(["jpg", "png", "webp"]).has(extension))
+ throw new TypeError(`No generic clean-copy name is available for ${kind}.`);
+ return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`;
+}
+
+function enforceOutputExtension(
+ input: string,
+ kind: ImageScanResult["identity"]["detectedKind"],
+): string {
+ const extension = kind === "jpeg" ? "jpg" : kind;
+ const safe = sanitizeDownloadFilename(input, `image.clean.${extension}`);
+ const stem = safe.replace(/\.[^.]*$/u, "") || "image.clean";
+ return `${stem}.${extension}`;
+}
+
function findingSignature(finding: MetadataFinding): string {
const label = finding.label
.toLowerCase()
diff --git a/src/styles.css b/src/styles.css
index 33fa604..761ea4b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -317,6 +317,19 @@ button:disabled {
flex-wrap: wrap;
gap: 0.5rem;
}
+.privacy-option {
+ display: inline-flex;
+ gap: 0.45rem;
+ align-items: center;
+ min-height: 2.55rem;
+ color: var(--toolbox-muted);
+ font-size: 0.8rem;
+ font-weight: 650;
+}
+.privacy-option input {
+ width: auto;
+ min-height: auto;
+}
.inventory-table-wrap {
overflow: auto;
diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json
index 4236046..c06f3bd 100644
--- a/src/toolbox/manifest.source.json
+++ b/src/toolbox/manifest.source.json
@@ -3,12 +3,20 @@
"schemaVersion": 1,
"id": "de.add-ideas.privacy-tools",
"name": "Privacy Tools",
- "version": "0.1.0",
- "description": "Inspect and remove shareable-file metadata locally in the browser.",
+ "version": "0.2.0",
+ "description": "Inspect metadata and create policy-evidenced sharing copies locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["privacy", "files", "security"],
- "tags": ["metadata", "exif", "privacy", "sanitize", "share"],
+ "tags": [
+ "metadata",
+ "exif",
+ "privacy",
+ "sanitize",
+ "policy",
+ "evidence",
+ "share"
+ ],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +29,46 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
+ "io": {
+ "accepts": [
+ {
+ "mediaType": "*/*",
+ "extensions": [],
+ "label": "Files for bounded metadata inventory"
+ }
+ ],
+ "produces": [
+ {
+ "mediaType": "image/jpeg",
+ "extensions": [".jpg", ".jpeg"],
+ "label": "Verified re-encoded JPEG"
+ },
+ {
+ "mediaType": "image/png",
+ "extensions": [".png"],
+ "label": "Verified re-encoded PNG"
+ },
+ {
+ "mediaType": "image/webp",
+ "extensions": [".webp"],
+ "label": "Verified re-encoded WebP"
+ },
+ {
+ "mediaType": "application/json",
+ "extensions": [".json"],
+ "label": "Inspection and policy evidence"
+ },
+ {
+ "mediaType": "application/zip",
+ "extensions": [".zip"],
+ "label": "Safe-share bundle"
+ }
+ ]
+ },
+ "capabilities": {
+ "required": ["canvas-2d", "web-crypto", "workers"],
+ "optional": ["create-image-bitmap"]
+ },
"privacy": {
"processing": "local",
"fileUploads": true,
diff --git a/src/version.ts b/src/version.ts
index 76162f8..0ba6e37 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = "0.1.0";
+export const APP_VERSION = "0.2.0";
diff --git a/tests/browser/app.spec.ts b/tests/browser/app.spec.ts
index 344a14d..4358c0d 100644
--- a/tests/browser/app.spec.ts
+++ b/tests/browser/app.spec.ts
@@ -42,7 +42,7 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/privacy/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.privacy-tools",
- version: "0.1.0",
+ version: "0.2.0",
entry: "./",
});
});
@@ -76,6 +76,14 @@ test("inspects and independently verifies a re-encoded PNG without network acces
await expect(page.getByText("Alice PNG").first()).toBeVisible();
await expect(page.getByText("PNG Alice").first()).toBeVisible();
await expect(page.getByText("inventory-only.pdf").first()).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Selective-removal policy" }),
+ ).toBeVisible();
+ await expect(page.getByText("inspect-only", { exact: true })).toBeVisible();
+ await page.getByLabel("Policy profile").selectOption("location-only");
+ await expect(
+ page.getByText("policy-not-executable", { exact: true }),
+ ).toBeVisible();
await page.getByRole("button", { name: "Re-encode & verify" }).click();
await expect(page.getByText("Mandatory output re-scan")).toBeVisible();
await expect(page.locator(".verification")).not.toHaveClass(/is-failed/u);
@@ -87,18 +95,26 @@ test("inspects and independently verifies a re-encoded PNG without network acces
await page
.getByRole("button", { name: "Download re-encoded output" })
.click();
- expect((await imageDownload).suggestedFilename()).toBe(
- "metadata-fixture.clean.png",
+ expect((await imageDownload).suggestedFilename()).toBe("image-001.clean.png");
+
+ const safeReportDownload = page.waitForEvent("download");
+ await page
+ .getByRole("button", { name: "Download safe-share report" })
+ .click();
+ expect((await safeReportDownload).suggestedFilename()).toBe(
+ "privacy-tools-safe-share-report.json",
);
- const reportDownload = page.waitForEvent("download");
- await page.getByRole("button", { name: "Download JSON report" }).click();
- expect((await reportDownload).suggestedFilename()).toBe(
+ const detailedReportDownload = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Download detailed report" }).click();
+ expect((await detailedReportDownload).suggestedFilename()).toBe(
"privacy-tools-report.json",
);
const archiveDownload = page.waitForEvent("download");
await page
- .getByRole("button", { name: "Download 1 re-encoded image + report" })
+ .getByRole("button", {
+ name: "Download safe-share ZIP with 1 re-encoded image + report",
+ })
.click();
expect((await archiveDownload).suggestedFilename()).toBe(
"privacy-tools-re-encoded-images.zip",
diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts
new file mode 100644
index 0000000..c3d2210
--- /dev/null
+++ b/tests/browser/responsive.spec.ts
@@ -0,0 +1,18 @@
+import { expect, test } from "@playwright/test";
+
+test("keeps the primary workspace inside a narrow viewport", async ({
+ page,
+}) => {
+ await page.goto("/deep/nested/privacy/");
+ await expect(page.locator("main").first()).toBeVisible();
+ await expect(
+ page.locator("main .loading, main .workbench-loading"),
+ ).toHaveCount(0);
+
+ const widths = await page.evaluate(() => ({
+ content: document.documentElement.scrollWidth,
+ viewport: document.documentElement.clientWidth,
+ }));
+ expect(widths.viewport).toBeLessThanOrEqual(430);
+ expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
+});
diff --git a/tests/privacy/report.test.ts b/tests/privacy/report.test.ts
index 2afb959..993ef19 100644
--- a/tests/privacy/report.test.ts
+++ b/tests/privacy/report.test.ts
@@ -7,6 +7,10 @@ import {
buildSanitizationReport,
createBatchArchive,
createBatchReport,
+ createPolicyEvidence,
+ createSafeShareReport,
+ genericOutputName,
+ policyById,
serializeReport,
type ImageScanResult,
type MetadataFinding,
@@ -160,6 +164,98 @@ describe("sanitization and batch reports", () => {
),
).rejects.toThrow(/Archive image count/iu);
});
+
+ it("builds a pseudonymized safe-share report and generic names", () => {
+ const source = result({
+ id: "private-id",
+ name: "Alice-at-home.png",
+ sha256: "secret-hash",
+ findings: [sensitive],
+ coverage: {
+ projectScanner: "complete",
+ secondaryScanner: "complete",
+ notes: ["source metadata value"],
+ },
+ });
+ const output = result({ id: "clean", name: "output.png" });
+ const report = buildSanitizationReport(
+ source,
+ output,
+ "Alice-at-home.clean.png",
+ "image/png",
+ 20,
+ comparison(true),
+ );
+ const safe = serializeReport(
+ createSafeShareReport(
+ [source],
+ [{ blob: new Blob(), report }],
+ "2026-09-01T00:00:00.000Z",
+ ),
+ );
+ expect(safe).toContain('"profile": "safe-share"');
+ expect(safe).toContain("image-001.clean.png");
+ expect(safe).not.toContain("Alice");
+ expect(safe).not.toContain("secret-hash");
+ expect(safe).not.toContain("48.1 N");
+ expect(safe).not.toContain("source metadata value");
+ expect(genericOutputName(9, "jpeg")).toBe("image-010.clean.jpg");
+ });
+
+ it("evaluates reusable policies across cleanable and inspect-only formats", () => {
+ const cleanable = result({ findings: [sensitive] });
+ const unsupported = result({
+ id: "pdf",
+ name: "document.pdf",
+ identity: {
+ claimedType: "application/pdf",
+ extension: "pdf",
+ detectedKind: "pdf",
+ detectedType: "application/pdf",
+ typeMatch: "match",
+ },
+ deepSupported: false,
+ cleanable: false,
+ findings: [sensitive],
+ coverage: {
+ projectScanner: "unsupported",
+ secondaryScanner: "unsupported",
+ notes: [],
+ },
+ });
+ const strict = createPolicyEvidence(
+ [cleanable, unsupported],
+ [],
+ policyById("safe-share"),
+ "2026-09-01T00:00:00.000Z",
+ );
+ expect(strict.files[0]).toMatchObject({
+ decision: "review",
+ availableOperation: "reencode-available",
+ findings: { remove: 1 },
+ });
+ expect(strict.files[1]).toMatchObject({
+ decision: "blocked",
+ availableOperation: "inspect-only",
+ });
+ expect(JSON.stringify(strict)).not.toContain("document.pdf");
+
+ const selective = createPolicyEvidence(
+ [
+ result({
+ findings: [
+ sensitive,
+ { ...sensitive, id: "author", category: "identity" },
+ ],
+ }),
+ ],
+ [],
+ policyById("location-only"),
+ );
+ expect(selective.files[0]?.availableOperation).toBe(
+ "policy-not-executable",
+ );
+ });
});
function comparison(identical: boolean) {