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