From b25886e875c100e47760dbe69031fc2148166024 Mon Sep 17 00:00:00 2001
From: wkotheimer
Date: Fri, 11 Sep 2026 07:37:55 -0500
Subject: [PATCH] Stop guessing whether a clip is a secret
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The heuristics scanned every copy for PEM blocks, JWTs, key prefixes, connection
strings and high-entropy text, and prompted on a match. They went for three
reasons.
They interrupted an ordinary workflow to report something the user already knew.
Copying a credential is a normal thing to do, and the prompt arrived every time,
asking permission for the thing the person had just deliberately done.
The premise was weaker than it looked. The guessing defended against exposure,
but nothing Spool holds leaves the machine. What Spool does change is
persistence: a clipboard entry that would have lived until the next copy instead
lives in an encrypted file with a visible preview. That is a real difference and
it is the honest case for asking — it is not a strong enough one to justify
asking about every API key a developer copies.
And it was the entire cost of capture: 147ms per MiB, because each needle walked
the whole buffer separately. Classification is now 0.003ms, because it no longer
reads the content at all — classify does not take the bytes any more, which is
the strongest form that claim can take. Two tests that failed intermittently at a
five-second timeout stopped being flaky as a side effect.
What is kept is not a guess. CanIncludeInClipboardHistory = 0 is an explicit
statement from the application that owns the secret, and Windows' own Clipboard
History obeys it. Spool makes a transient thing durable, so ignoring it would
persist exactly what a password manager asked it not to, and behave worse than
the OS feature beside it. It costs a flag check.
detect/bytes.ts loses nine functions that existed only to feed the heuristics.
wipe stays: a declined clip must not be left in memory.
Co-Authored-By: Claude Opus 5
---
PLAN.md | 32 ++++-
src/main/clipboard/capture.ts | 11 +-
src/main/detect/bytes.ts | 116 +--------------
src/main/detect/consent.ts | 22 ++-
src/main/detect/sensitivity.test.ts | 117 ++++------------
src/main/detect/sensitivity.ts | 163 +++-------------------
src/main/session.test.ts | 19 ++-
src/main/session.ts | 3 -
src/renderer/components/ConsentPrompt.tsx | 10 +-
src/renderer/components/FirstRun.tsx | 5 +-
src/renderer/components/PrivacyPanel.tsx | 19 +--
src/renderer/state/useAppState.ts | 1 -
src/shared/ipc.ts | 3 -
13 files changed, 117 insertions(+), 404 deletions(-)
diff --git a/PLAN.md b/PLAN.md
index 1de8fb4..fd84044 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -210,9 +210,31 @@ on a device with far less room. See §10 for the arithmetic.
## 4. Sensitive clips
-Two tiers, with different confidence and different wording.
+One signal: what the source application declared.
-### Tier 1 — Declared (authoritative)
+**Tier 2 was removed.** The app used to also guess from content — PEM blocks, JWTs, key prefixes
+like `sk-` and `AKIA`, connection-string keywords, high-entropy strings — and prompt on a match. It
+went for three reasons, in order of weight.
+
+It interrupted an ordinary workflow to say something the user already knew. **Copying a credential
+is a normal thing to do**, and the prompt arrived every time, asking permission for the thing the
+person had just deliberately done.
+
+The premise was weaker than it looked. The heuristics existed against a risk of *exposure*, but
+nothing Spool holds leaves the machine — the guarantee of §5 is the whole product. What Spool does
+change is **persistence**: a clipboard entry that would have lived until the next copy instead lives
+in an encrypted file with a visible preview. That is a real difference, and it is the honest case
+for asking. It is not a strong enough one to justify asking about every API key a developer copies.
+
+And it was the entire cost of capture: **147ms per MiB**, because each needle walked the whole
+buffer separately. Removing it took classification from 147ms to 0.003ms, because it no longer reads
+the content at all — `classify` does not take the bytes any more, which is the strongest form that
+claim can take. Two tests that failed intermittently at a five-second timeout stopped being flaky as
+a side effect.
+
+What is kept is **not a guess**, and that distinction is the whole of the decision.
+
+### What the application declared (authoritative)
The source application marked the clipboard content as secret. Password managers do this.
@@ -222,6 +244,12 @@ The source application marked the clipboard content as secret. Password managers
Prompt names the source: *"1Password marked this as concealed. Keep it in this spool?"*
+`CanIncludeInClipboardHistory = 0` is an explicit statement from the application that owns the
+secret, saying *do not persist this*, and **Windows' own Clipboard History obeys it**. Spool makes a
+transient thing durable, so ignoring that request would persist exactly what a password manager asked
+it not to, and leave Spool behaving worse than the operating-system feature beside it. It costs a
+flag check and no scanning, which is why it survives the argument that removed the other tier.
+
### Tier 2 — Heuristic (advisory)
Pattern or entropy match. Lower confidence, softer wording: *"This looks like a secret."*
diff --git a/src/main/clipboard/capture.ts b/src/main/clipboard/capture.ts
index 0572d4c..4c2fd81 100644
--- a/src/main/clipboard/capture.ts
+++ b/src/main/clipboard/capture.ts
@@ -117,13 +117,10 @@ export function captureSnapshot(
}
}
- const sensitivity = classify(
- {
- formats: snapshot.formats,
- canIncludeInClipboardHistory: snapshot.canIncludeInClipboardHistory ?? null
- },
- bytes
- )
+ const sensitivity = classify({
+ formats: snapshot.formats,
+ canIncludeInClipboardHistory: snapshot.canIncludeInClipboardHistory ?? null
+ })
const decision = decideConsent(sensitivity, snapshot.sourceApp ?? null, cleared.sourceRules)
if (decision.kind === 'skip') {
diff --git a/src/main/detect/bytes.ts b/src/main/detect/bytes.ts
index 63f3fb1..a858b61 100644
--- a/src/main/detect/bytes.ts
+++ b/src/main/detect/bytes.ts
@@ -1,117 +1,15 @@
/**
- * Byte-level helpers for the sensitivity detectors (PLAN.md 4).
+ * Working on clipboard bytes rather than strings (PLAN.md 4).
*
- * These work on `Uint8Array` and never build a string, which is the whole point: a JavaScript
- * string is immutable and garbage-collected, so a secret that becomes one cannot be wiped and may
- * outlive the user's decision — possibly into a swap file. Detection therefore happens on the bytes
- * the addon handed over, and the bytes are what gets zeroed on Skip.
+ * This module was once a small byte-searching library — `ascii`, `startsWith`, `includes`,
+ * `indexOf`, `trim`, `isDigit`, `hasWhitespace`, `characterClasses`, `shannonEntropy` — built so the
+ * secret heuristics could scan a copy without ever turning it into a string. The heuristics were
+ * removed, and every one of those went with them: there is nothing left that reads the content.
*
- * ASCII-only comparisons are enough for every pattern in §4: PEM headers, base64url, key prefixes,
- * and connection-string keywords are all ASCII, and UTF-8 encodes ASCII as itself, so a multi-byte
- * character can never be mistaken for one of them.
+ * `wipe` stays, and it is the one that mattered. A clip the user declines must not be left in
+ * memory, and zeroing the buffer is the only thing this file does now.
*/
-const encoder = new TextEncoder()
-
-/** The ASCII bytes of a literal, for comparing against clipboard content. */
-export function ascii(literal: string): Uint8Array {
- return encoder.encode(literal)
-}
-
-const isUpper = (byte: number): boolean => byte >= 0x41 && byte <= 0x5a
-const isLower = (byte: number): boolean => byte >= 0x61 && byte <= 0x7a
-
-/** Lowercase one ASCII byte, leaving everything else alone. */
-const foldCase = (byte: number): number => (isUpper(byte) ? byte + 0x20 : byte)
-
-export function isWhitespace(byte: number): boolean {
- return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d || byte === 0x0b
-}
-
-export function isDigit(byte: number): boolean {
- return byte >= 0x30 && byte <= 0x39
-}
-
-/** Does `haystack` begin with `needle`, ignoring leading whitespace? */
-export function startsWith(haystack: Uint8Array, needle: Uint8Array): boolean {
- let start = 0
- while (start < haystack.length && isWhitespace(haystack[start])) start += 1
- if (haystack.length - start < needle.length) return false
-
- for (let i = 0; i < needle.length; i += 1) {
- if (haystack[start + i] !== needle[i]) return false
- }
- return true
-}
-
-/** Does `haystack` contain `needle` anywhere? `fold` compares case-insensitively. */
-export function includes(haystack: Uint8Array, needle: Uint8Array, fold = false): boolean {
- return indexOf(haystack, needle, fold) !== -1
-}
-
-export function indexOf(haystack: Uint8Array, needle: Uint8Array, fold = false, from = 0): number {
- if (needle.length === 0 || haystack.length < needle.length) return -1
-
- outer: for (let i = from; i <= haystack.length - needle.length; i += 1) {
- for (let j = 0; j < needle.length; j += 1) {
- const a = fold ? foldCase(haystack[i + j]) : haystack[i + j]
- const b = fold ? foldCase(needle[j]) : needle[j]
- if (a !== b) continue outer
- }
- return i
- }
- return -1
-}
-
-/** The content with leading and trailing whitespace removed — a view, not a copy. */
-export function trim(bytes: Uint8Array): Uint8Array {
- let start = 0
- let end = bytes.length
- while (start < end && isWhitespace(bytes[start])) start += 1
- while (end > start && isWhitespace(bytes[end - 1])) end -= 1
- return bytes.subarray(start, end)
-}
-
-export function hasWhitespace(bytes: Uint8Array): boolean {
- for (const byte of bytes) if (isWhitespace(byte)) return true
- return false
-}
-
-/** How many of the four character classes appear: lower, upper, digit, and everything else. */
-export function characterClasses(bytes: Uint8Array): number {
- let lower = false
- let upper = false
- let digit = false
- let symbol = false
-
- for (const byte of bytes) {
- if (isLower(byte)) lower = true
- else if (isUpper(byte)) upper = true
- else if (isDigit(byte)) digit = true
- else symbol = true
- }
-
- return [lower, upper, digit, symbol].filter(Boolean).length
-}
-
-/**
- * Shannon entropy in bits per byte. Random-looking material scores high; English prose and
- * repetitive identifiers score low.
- */
-export function shannonEntropy(bytes: Uint8Array): number {
- if (bytes.length === 0) return 0
-
- const counts = new Map()
- for (const byte of bytes) counts.set(byte, (counts.get(byte) ?? 0) + 1)
-
- let entropy = 0
- for (const count of counts.values()) {
- const probability = count / bytes.length
- entropy -= probability * Math.log2(probability)
- }
- return entropy
-}
-
/**
* Zero the bytes and drop them. Best-effort, and worth describing as exactly that: it is defeated
* by a process dump or a swapped page, and it is still far better than letting a declined password
diff --git a/src/main/detect/consent.ts b/src/main/detect/consent.ts
index 21d9051..2f46553 100644
--- a/src/main/detect/consent.ts
+++ b/src/main/detect/consent.ts
@@ -61,25 +61,21 @@ export function keepsTheClip(choice: ConsentChoice): boolean {
*/
export const CONSENT_TIMEOUT_MS = 30_000
-/** How the prompt reads. Tier 1 names the source; Tier 2 is softer, because it is a guess. */
+/**
+ * How the prompt reads. It always names the source, because the app itself is what raised this —
+ * there is no longer a softer wording for a guess, because there are no guesses.
+ */
export function promptWording(
sensitivity: Sensitivity,
sourceApp: string | null
): { headline: string; detail: string } {
const application = sourceApp === null ? null : sourceApp.replace(/\.exe$/i, '')
- if (sensitivity.tier === 1) {
- return {
- headline:
- application === null
- ? 'That copy was marked as concealed. Keep it in this spool?'
- : `${application} marked this as concealed. Keep it in this spool?`,
- detail: sensitivity.rule
- }
- }
-
return {
- headline: 'This looks like a secret. Keep it in this spool?',
- detail: `It looks like ${sensitivity.rule}.`
+ headline:
+ application === null
+ ? 'That copy was marked as concealed. Keep it in this spool?'
+ : `${application} marked this as concealed. Keep it in this spool?`,
+ detail: sensitivity.rule
}
}
diff --git a/src/main/detect/sensitivity.test.ts b/src/main/detect/sensitivity.test.ts
index 1409d21..3ae2540 100644
--- a/src/main/detect/sensitivity.test.ts
+++ b/src/main/detect/sensitivity.test.ts
@@ -1,23 +1,20 @@
import { describe, expect, it } from 'vitest'
-import { wipe } from './bytes'
-import { classify, declaredConcealed, looksLikeSecret } from './sensitivity'
+import { classify, declaredConcealed } from './sensitivity'
-const bytes = (text: string): Uint8Array => new TextEncoder().encode(text)
-
-describe('Tier 1 — declared (PLAN.md 4)', () => {
+describe('what the application declared (PLAN.md 4)', () => {
it('trusts the Windows exclusion format', () => {
const result = declaredConcealed({
formats: ['CF_UNICODETEXT', 'ExcludeClipboardContentFromMonitorProcessing'],
canIncludeInClipboardHistory: null
})
- expect(result?.tier).toBe(1)
+ expect(result?.rule).toMatch(/concealed/)
})
it('trusts CanIncludeInClipboardHistory when it says no', () => {
expect(
- declaredConcealed({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 })?.tier
- ).toBe(1)
+ declaredConcealed({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 })?.rule
+ ).toMatch(/clipboard history/)
})
it('does not fire when that format says yes', () => {
@@ -31,8 +28,8 @@ describe('Tier 1 — declared (PLAN.md 4)', () => {
declaredConcealed({
formats: ['public.utf8-plain-text', 'org.nspasteboard.ConcealedType'],
canIncludeInClipboardHistory: null
- })?.tier
- ).toBe(1)
+ })?.rule
+ ).toMatch(/concealed/)
})
it('says nothing about an ordinary copy', () => {
@@ -40,92 +37,30 @@ describe('Tier 1 — declared (PLAN.md 4)', () => {
.toBeNull()
})
- it('beats a Tier 2 guess, because one is a statement and the other is a shape', () => {
- const result = classify(
- { formats: ['ExcludeClipboardContentFromMonitorProcessing'], canIncludeInClipboardHistory: 0 },
- bytes('just some ordinary text')
- )
-
- expect(result?.tier).toBe(1)
- })
-})
-
-describe('Tier 2 — heuristics (PLAN.md 4)', () => {
- it.each([
- ['a PEM block', '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END'],
- ['a JWT', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSJ9.dBjftJeZ4CVPmB92K'],
- ['an OpenAI key', 'sk-proj-abc123def456ghi789jkl012mno345pqr'],
- ['an AWS access key', 'AKIAIOSFODNN7EXAMPLE'],
- ['a GitHub token', 'ghp_16C7e42F292c6912E7710c838347Ae178B4a'],
- ['a GitHub fine-grained token', 'github_pat_11ABCDEFG0abcdefghijkl_mnopqrstuvwxyz'],
- ['a Slack token', 'xoxb-123456789012-1234567890123-abcdefgh'],
- ['a Google API key', 'AIzaSyD-abc123DEF456ghi789JKL012mno345PQ'],
- ['a SQL Server connection string', 'Server=tcp:db.example.com;Database=app;Password=hunter2;'],
- ['a lowercase pwd= connection string', 'host=db;user=app;pwd=s3cret;'],
- ['a random-looking secret', 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY']
- ])('flags %s', (_name, content) => {
- expect(looksLikeSecret(bytes(content))?.tier).toBe(2)
- })
-
- it('names which rule matched, so the prompt can say why', () => {
- expect(looksLikeSecret(bytes('AKIAIOSFODNN7EXAMPLE'))?.rule).toMatch(/AWS/)
- expect(looksLikeSecret(bytes('-----BEGIN CERTIFICATE-----'))?.rule).toMatch(/PEM/)
- })
-})
-
-describe('Tier 2 negatives — what must not trip (PLAN.md 11, M5)', () => {
- it.each([
- ['ordinary prose', 'The quick brown fox jumps over the lazy dog, and then does it again.'],
- ['a single sentence', 'Remember to call the plumber about the leak on Tuesday morning.'],
- ['a URL', 'https://github.com/willkotheimer/Spool/blob/main/PLAN.md#milestones'],
- ['a long URL with a query', 'https://example.com/search?q=clipboard+manager&page=2&sort=recent'],
- ['a bare domain', 'www.example.com/some/deep/path/to/a/document'],
- ['a code snippet', 'const spool = createSpool({ id: "default", mode: "fifo" })'],
- ['an import line', "import { captureSnapshot } from './clipboard/capture'"],
- ['a camelCase identifier', 'getUserAccountSettingsFromDatabase'],
- ['a file path', 'C:/Users/wkoth/source/repos/Spool/src/main/detect'],
- ['a short word', 'password'],
- ['a phone number', '+1 (555) 010-9999'],
- ['an email address', 'someone@example.com'],
- ['a hex colour', '#3b82f6'],
- ['a date', '2026-08-22T15:00:00.000Z']
- ])('leaves %s alone', (_name, content) => {
- expect(looksLikeSecret(bytes(content))).toBeNull()
- })
-
- it('leaves an empty or blank clipboard alone', () => {
- expect(looksLikeSecret(bytes(''))).toBeNull()
- expect(looksLikeSecret(bytes(' \n '))).toBeNull()
- })
-})
-
-describe('wiping (PLAN.md 4)', () => {
- it('zeroes the bytes in place, so the buffer that held a secret no longer does', () => {
- const secret = bytes('AKIAIOSFODNN7EXAMPLE')
- expect(looksLikeSecret(secret)).not.toBeNull()
-
- wipe(secret)
-
- expect(secret.every((byte) => byte === 0)).toBe(true)
- })
+ it('is the only thing that raises a prompt', () => {
+ const result = classify({
+ formats: ['ExcludeClipboardContentFromMonitorProcessing'],
+ canIncludeInClipboardHistory: 0
+ })
- it('does not mind being handed nothing', () => {
- expect(() => wipe(null)).not.toThrow()
+ expect(result?.rule).toMatch(/concealed/)
})
})
-describe('the path exclusion stays narrow', () => {
- it('still flags a secret that merely contains slashes', () => {
- // The AWS secret key shape: slashes throughout, but not a path.
- expect(looksLikeSecret(bytes('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'))?.tier).toBe(2)
+// The heuristics that used to live here are gone. They guessed from content — PEM blocks, JWTs,
+// key prefixes, connection-string keywords, high-entropy strings — and prompted on a match. They
+// interrupted an ordinary workflow to report something the user already knew, and cost 147ms per
+// MiB because every needle walked the whole buffer. Nothing Spool holds leaves the machine, so the
+// guessing bought nothing it was worth paying for.
+describe('nothing is guessed from content (PLAN.md 4)', () => {
+ it('keeps a credential without asking, because copying one is an ordinary thing to do', () => {
+ // Content is not even passed in any more, which is the strongest form this claim can take.
+ expect(classify({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: null })).toBeNull()
+ expect(classify({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 1 })).toBeNull()
})
- it.each([
- ['a Windows path', 'C:/Users/wkoth/source/repos/Spool/src/main/detect'],
- ['a backslash path', 'C:\\Users\\wkoth\\AppData\\Local\\Programs\\Spool'],
- ['a POSIX path', '/usr/local/share/SpoolThings/Config'],
- ['a UNC path', '\\\\fileserver\\Shared\\Reports\\Q3Summary']
- ])('leaves %s alone', (_name, content) => {
- expect(looksLikeSecret(bytes(content))).toBeNull()
+ it('still asks when the application itself declared the copy concealed', () => {
+ const declared = { formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 }
+ expect(classify(declared)?.rule).toMatch(/clipboard history/)
})
})
diff --git a/src/main/detect/sensitivity.ts b/src/main/detect/sensitivity.ts
index a8b386c..0f8b937 100644
--- a/src/main/detect/sensitivity.ts
+++ b/src/main/detect/sensitivity.ts
@@ -1,27 +1,21 @@
-import {
- ascii,
- characterClasses,
- hasWhitespace,
- includes,
- indexOf,
- isDigit,
- shannonEntropy,
- startsWith,
- trim
-} from './bytes'
-
/**
- * The two tiers of PLAN.md 4, over bytes rather than strings.
+ * What the source application declared about a copy (PLAN.md 4), read from the clipboard formats
+ * rather than from the content.
+ *
+ * **Guessing from content was removed.** Spool used to also scan for PEM blocks, JWTs, key
+ * prefixes, connection-string keywords and high-entropy strings, and prompt when one matched. It
+ * interrupted an ordinary workflow — copying a credential is a normal thing to do, and nothing here
+ * leaves the machine — to say something the user already knew. It was also the entire cost of
+ * capture: 147ms per MiB, because each needle walked the whole buffer separately.
*
- * Different confidence, different wording: Tier 1 is what the source application *declared*, and is
- * authoritative. Tier 2 is a guess from shape, and says so. False positives are acceptable here;
- * silent capture of a secret is not.
+ * What is kept is not a guess. `CanIncludeInClipboardHistory = 0` is an explicit statement from the
+ * application that owns the secret, saying *do not persist this*; Windows' own Clipboard History
+ * obeys it. Spool makes a transient thing durable, so ignoring that request would persist exactly
+ * what a password manager asked it not to, and behave worse than the OS feature beside it. It costs
+ * a flag check.
*/
-export type Tier = 1 | 2
-
export interface Sensitivity {
- readonly tier: Tier
/** Which rule matched, for the privacy panel and for the prompt's second line. */
readonly rule: string
}
@@ -44,139 +38,22 @@ const DECLARED_FORMATS = new Set([
'org.nspasteboard.ConcealedType'
])
-/** Tier 1 — the source application marked this as secret. Password managers do this. */
+/** The source application marked this as secret. Password managers do this. */
export function declaredConcealed(signals: ConcealmentSignals): Sensitivity | null {
if (signals.formats.some((format) => DECLARED_FORMATS.has(format))) {
- return { tier: 1, rule: 'the application marked it as concealed' }
+ return { rule: 'the application marked it as concealed' }
}
if (signals.canIncludeInClipboardHistory === 0) {
- return { tier: 1, rule: 'the application asked to be kept out of clipboard history' }
+ return { rule: 'the application asked to be kept out of clipboard history' }
}
return null
}
-const PEM = ascii('-----BEGIN')
-const JWT = ascii('eyJ')
-const DOT = ascii('.')
-
-/** Key prefixes worth recognising by name (PLAN.md 4). */
-const KEY_PREFIXES: ReadonlyArray<[label: string, prefix: Uint8Array]> = [
- ['an OpenAI-style key (sk-)', ascii('sk-')],
- ['an AWS access key (AKIA)', ascii('AKIA')],
- ['a GitHub token (ghp_)', ascii('ghp_')],
- ['a GitHub token (github_pat_)', ascii('github_pat_')],
- ['a Slack token (xoxb-)', ascii('xoxb-')],
- ['a Google API key (AIza)', ascii('AIza')]
-]
-
-const CONNECTION_KEYWORDS: ReadonlyArray<[label: string, needle: Uint8Array]> = [
- ['a connection string (Password=)', ascii('Password=')],
- ['a connection string (pwd=)', ascii('pwd=')],
- ['a connection string (Server=)', ascii('Server=')]
-]
-
-/** Entropy high enough to look generated rather than written. */
-const ENTROPY_THRESHOLD = 4.0
-const ENTROPY_MIN_LENGTH = 16
-const ENTROPY_MAX_LENGTH = 200
-const ENTROPY_MIN_CLASSES = 3
-
-/** A URL is not a secret, and its punctuation would otherwise score like one. */
-const URL_MARKERS = [ascii('://'), ascii('www.')]
-
/**
- * Nor is an absolute file path, which a developer copies many times a day — and a prompt that fires
- * on every one of those teaches the user to dismiss prompts, which costs more than it saves.
+ * The whole classification. One question now: did the application say so?
*
- * Deliberately narrow: it recognises only what a path *starts* with. Checking for slashes anywhere
- * would be a hole, because an AWS secret key is full of them.
+ * It no longer takes the content, which is the point. Nothing here reads what you copied.
*/
-function looksLikeAbsolutePath(content: Uint8Array): boolean {
- const [first, second, third] = content
- const isSlash = (byte: number | undefined): boolean => byte === 0x2f || byte === 0x5c
- const isLetter =
- first !== undefined &&
- ((first >= 0x41 && first <= 0x5a) || (first >= 0x61 && first <= 0x7a))
-
- // C:/… or C:\…
- if (isLetter && second === 0x3a && isSlash(third)) return true
- // /usr/… or \\server\…
- return isSlash(first)
+export function classify(signals: ConcealmentSignals): Sensitivity | null {
+ return declaredConcealed(signals)
}
-
-/** Tier 2 — pattern or entropy match. Lower confidence, softer wording. */
-export function looksLikeSecret(bytes: Uint8Array): Sensitivity | null {
- const content = trim(bytes)
- if (content.length === 0) return null
-
- if (startsWith(content, PEM)) return { tier: 2, rule: 'a PEM block' }
- if (isJwt(content)) return { tier: 2, rule: 'a JWT' }
-
- for (const [label, prefix] of KEY_PREFIXES) {
- if (includes(content, prefix)) return { tier: 2, rule: label }
- }
-
- for (const [label, needle] of CONNECTION_KEYWORDS) {
- if (includes(content, needle, true)) return { tier: 2, rule: label }
- }
-
- if (isHighEntropy(content)) return { tier: 2, rule: 'a long random-looking string' }
-
- return null
-}
-
-/** `eyJ` followed by two dot-separated base64url segments. */
-function isJwt(content: Uint8Array): boolean {
- if (!startsWith(content, JWT)) return false
-
- const firstDot = indexOf(content, DOT)
- if (firstDot <= 0) return false
- const secondDot = indexOf(content, DOT, false, firstDot + 1)
- if (secondDot <= firstDot + 1) return false
-
- // Three segments, all base64url. The third may be empty for an unsigned token.
- return (
- isBase64Url(content.subarray(0, firstDot)) &&
- isBase64Url(content.subarray(firstDot + 1, secondDot)) &&
- isBase64Url(content.subarray(secondDot + 1))
- )
-}
-
-function isBase64Url(segment: Uint8Array): boolean {
- for (const byte of segment) {
- const alphanumeric =
- isDigit(byte) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a)
- if (!alphanumeric && byte !== 0x2d && byte !== 0x5f && byte !== 0x3d) return false
- }
- return true
-}
-
-function isHighEntropy(content: Uint8Array): boolean {
- if (content.length < ENTROPY_MIN_LENGTH || content.length > ENTROPY_MAX_LENGTH) return false
- if (hasWhitespace(content)) return false
- if (URL_MARKERS.some((marker) => includes(content, marker, true))) return false
- if (looksLikeAbsolutePath(content)) return false
- if (characterClasses(content) < ENTROPY_MIN_CLASSES) return false
-
- return shannonEntropy(content) >= ENTROPY_THRESHOLD
-}
-
-/**
- * The whole classification, in the order of PLAN.md 4: what the application declared beats what the
- * content looks like, because one is a statement and the other is a guess.
- */
-export function classify(signals: ConcealmentSignals, bytes: Uint8Array): Sensitivity | null {
- return declaredConcealed(signals) ?? looksLikeSecret(bytes)
-}
-
-/** Every Tier 2 rule, for the privacy panel — the user is owed the list of what trips a prompt. */
-export const HEURISTIC_RULES: ReadonlyArray<{ label: string; detail: string }> = [
- { label: 'PEM blocks', detail: 'text beginning -----BEGIN' },
- { label: 'JWTs', detail: 'eyJ followed by two dot-separated base64url segments' },
- { label: 'Known key prefixes', detail: 'sk-, AKIA, ghp_, github_pat_, xoxb-, AIza' },
- { label: 'Connection strings', detail: 'Password=, pwd=, Server=' },
- {
- label: 'High-entropy strings',
- detail: `${ENTROPY_MIN_LENGTH}–${ENTROPY_MAX_LENGTH} characters, no spaces, at least ${ENTROPY_MIN_CLASSES} character classes, and random-looking`
- }
-]
diff --git a/src/main/session.test.ts b/src/main/session.test.ts
index 436a968..1ef67e0 100644
--- a/src/main/session.test.ts
+++ b/src/main/session.test.ts
@@ -402,24 +402,23 @@ describe('consent (PLAN.md 4)', () => {
sourceApp: 'Code.exe'
})
- it('raises a Tier 1 prompt naming the application, and files nothing yet', () => {
+ it('raises a prompt naming the application, and files nothing yet', () => {
const { session, watcher } = started()
watcher.change(secret('hunter2'))
const { prompt, spool } = session.getState()
- expect(prompt?.tier).toBe(1)
expect(prompt?.headline).toBe('1Password marked this as concealed. Keep it in this spool?')
expect(spool.count).toBe(0)
})
- it('raises a softer Tier 2 prompt for something that merely looks like a secret', () => {
+ // The heuristics are gone: copying a credential is an ordinary thing to do, and nothing Spool
+ // holds leaves the machine, so guessing at content bought nothing worth its interruption.
+ it('does not ask about something that merely looks like a secret', () => {
const { session, watcher } = started()
watcher.change(heuristic('AKIAIOSFODNN7EXAMPLE'))
- const { prompt } = session.getState()
- expect(prompt?.tier).toBe(2)
- expect(prompt?.headline).toBe('This looks like a secret. Keep it in this spool?')
- expect(prompt?.detail).toMatch(/AWS/)
+ expect(session.getState().prompt).toBeNull()
+ expect(session.getState().spool.count).toBe(1)
})
it('never shows the content of the clip it is asking about', () => {
@@ -499,10 +498,10 @@ describe('consent (PLAN.md 4)', () => {
watcher.change(secret('from the manager'))
session.answerConsent('always_skip')
- watcher.change(heuristic('AKIAIOSFODNN7EXAMPLE'))
+ // A different application that also declares its copy concealed is still asked about.
+ watcher.change(secret('from somewhere else', 'Bitwarden.exe'))
- // A different application still gets asked about.
- expect(session.getState().prompt?.tier).toBe(2)
+ expect(session.getState().prompt?.headline).toMatch(/Bitwarden/)
})
it('an ordinary copy is never asked about', () => {
diff --git a/src/main/session.ts b/src/main/session.ts
index 1496e54..1f0080d 100644
--- a/src/main/session.ts
+++ b/src/main/session.ts
@@ -47,7 +47,6 @@ import {
ruleFromChoice
} from './detect/consent'
import { emptyLedger, NOTHING_TO_PASTE } from './detect/notices'
-import { HEURISTIC_RULES } from './detect/sensitivity'
import { toSpoolView } from './ipc/view'
import type { Store } from './store'
@@ -901,7 +900,6 @@ export class Session {
autoPaste: this.autoPaste,
prompt: this.promptView(),
privacy: {
- heuristics: HEURISTIC_RULES,
consentTimeoutSeconds: Math.round(this.settings.consentTimeoutMs / 1000),
sourceRules: [...this.state.sourceRules].map(([sourceApp, action]) => ({
sourceApp,
@@ -953,7 +951,6 @@ export class Session {
const { headline, detail } = promptWording(this.pending.sensitivity, this.pending.sourceApp)
return {
- tier: this.pending.sensitivity.tier,
headline,
detail,
sourceApp: this.pending.sourceApp,
diff --git a/src/renderer/components/ConsentPrompt.tsx b/src/renderer/components/ConsentPrompt.tsx
index bcbf371..ca6a81c 100644
--- a/src/renderer/components/ConsentPrompt.tsx
+++ b/src/renderer/components/ConsentPrompt.tsx
@@ -16,16 +16,12 @@ export function ConsentPrompt({
prompt: PendingPrompt
onAnswer: (choice: ConsentChoice) => void
}): JSX.Element {
+ // One voice now: the only thing that raises this is the application saying so, so there is no
+ // softer styling for a guess.
const application = sourceName(prompt.sourceApp)
return (
-
+
{prompt.headline}
{prompt.detail}
diff --git a/src/renderer/components/FirstRun.tsx b/src/renderer/components/FirstRun.tsx
index 26c65f4..5860284 100644
--- a/src/renderer/components/FirstRun.tsx
+++ b/src/renderer/components/FirstRun.tsx
@@ -39,9 +39,8 @@ export function FirstRun({
- Before anything that looks like a secret is stored, Spool asks. It looks for{' '}
- {privacy.heuristics.map((rule) => rule.label.toLowerCase()).join(', ')}, and it treats an
- application marking a copy as concealed — as password managers do — as authoritative. A
+ Spool does not inspect what you copy or guess whether it is a secret. When an application
+ marks a copy as concealed — as password managers do — Spool asks before keeping it, and a
prompt left unanswered for {privacy.consentTimeoutSeconds} seconds is treated as Skip.
diff --git a/src/renderer/components/PrivacyPanel.tsx b/src/renderer/components/PrivacyPanel.tsx
index 6690a6a..7259d14 100644
--- a/src/renderer/components/PrivacyPanel.tsx
+++ b/src/renderer/components/PrivacyPanel.tsx
@@ -49,21 +49,16 @@ export function PrivacyPanel({
-
+
- Spool asks before keeping anything that matches one of these. It never decides for you,
- and it never drops a clip on its own.
+ Spool does not read your clips looking for secrets, and does not guess. It once scanned
+ for key prefixes, connection strings and random-looking text; that was removed. Copying a
+ credential is an ordinary thing to do, and nothing here leaves this machine.
-
- {privacy.heuristics.map(({ label, detail }) => (
- -
- {label} — {detail}
-
- ))}
-
- An application can also mark a copy as concealed — password managers do — and that
- marking is treated as authoritative.
+ What remains is not a guess. An application can mark a copy as concealed — password
+ managers do, and Windows' own clipboard history obeys it — and that marking is treated as
+ authoritative.
diff --git a/src/renderer/state/useAppState.ts b/src/renderer/state/useAppState.ts
index 21d5040..50b471b 100644
--- a/src/renderer/state/useAppState.ts
+++ b/src/renderer/state/useAppState.ts
@@ -15,7 +15,6 @@ const initialState: AppState = {
capture: { available: false, reason: null },
prompt: null,
privacy: {
- heuristics: [],
consentTimeoutSeconds: 30,
dataFilePath: null,
sourceRules: [],
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts
index 6689c8e..341a9ae 100644
--- a/src/shared/ipc.ts
+++ b/src/shared/ipc.ts
@@ -76,8 +76,6 @@ export type ConsentChoice = 'keep_once' | 'skip' | 'always_keep' | 'always_skip'
/** A clip held in memory, unwritten, while the user decides (PLAN.md 4). */
export interface PendingPrompt {
- /** 1 is what the application declared and is authoritative; 2 is a guess from shape. */
- readonly tier: 1 | 2
readonly headline: string
readonly detail: string
/** Named so the standing-answer choices can say which application they apply to. */
@@ -88,7 +86,6 @@ export interface PendingPrompt {
/** What the privacy panel says Spool looks for, taken from the detectors themselves. */
export interface PrivacyFacts {
- readonly heuristics: ReadonlyArray<{ readonly label: string; readonly detail: string }>
readonly consentTimeoutSeconds: number
/** Where the encrypted store lives, or null while there is not one yet (M6). */
readonly dataFilePath: string | null