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