Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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."*
Expand Down
11 changes: 4 additions & 7 deletions src/main/clipboard/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
116 changes: 7 additions & 109 deletions src/main/detect/bytes.ts
Original file line number Diff line number Diff line change
@@ -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<number, number>()
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
Expand Down
22 changes: 9 additions & 13 deletions src/main/detect/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
117 changes: 26 additions & 91 deletions src/main/detect/sensitivity.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -31,101 +28,39 @@ 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', () => {
expect(declaredConcealed({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: null }))
.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/)
})
})
Loading
Loading