Enforce a truthful local-first AI/privacy contract
Template tests / tests (pull_request) Failing after 34s

Phase 1 of the improvement plan (PR 3 of the sequence). The docs claimed
"fully offline"/"never talks to the network," but text-intel makes HTTP
requests to a configurable Ollama host that could be remote, with no timeout,
no cancellation, and no size limit; the Windows hook logged raw keystrokes
into capture metadata that could then be sent to that host.

Privacy — raw keystroke capture:
- New capture.captureTypedText setting, default false. With it off, printable
  characters are never buffered in JS and the Windows keyboard hook never even
  emits them across the process boundary (the flag is threaded into the C#).
  Shortcut/navigation detection (Ctrl+T, Enter, …) is unaffected.

AI network hardening (app/text-intel.js):
- Every Ollama call goes through fetchJson with an AbortController deadline
  (ai.timeoutMs, default 60s): a dead endpoint fails fast instead of leaving
  UI actions pending forever.
- Cancellation: in-flight requests are tracked and cancelInflight(guideId)
  aborts them; new ai:cancel IPC + api.ai.cancel are called when the editor
  closes, and shutdown cancels everything.
- Bounded concurrency (2) for AI network work.
- Screenshots are only attached when allowed (ai.attachScreenshots), the model
  is vision-capable, and the image is within ai.maxImageBytes — no more
  unbounded base64-expanded 4K bodies.

Local-first host policy (core/text-intel.js):
- New isLoopbackHost + validateOllamaHost. By default only a loopback Ollama
  endpoint is contacted; a remote host is refused with a clear message unless
  ai.allowRemoteHost is explicitly enabled. Blocked hosts are never contacted.

Honest documentation:
- README, package.json, and the welcome screen drop "fully offline"/"never
  talks to the network"/"Electron is the only dependency" for an accurate
  local-first contract that discloses the optional AI path and the bundled
  Tesseract OCR dependency.
- New docs/PRIVACY.md details exactly what is collected locally and the one
  outbound (opt-in, loopback-by-default) AI feature.

Tests: loopback/remote host matrix, remote-blocked-without-opt-in (and never
contacted), remote-allowed-with-opt-in, request timeout, explicit cancel vs
timeout, typed-text off-by-default vs opted-in, shortcut detection still works,
and a source guard that the C# CHAR emission stays behind the opt-in. 224 unit
tests pass; startup smoke and workflow E2E pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-03 11:36:01 -07:00
co-authored by Claude Fable 5
parent 6ffef69705
commit ccbb9b03dc
12 changed files with 533 additions and 33 deletions
+15 -2
View File
@@ -1025,6 +1025,11 @@ class CaptureService {
// by other processes and a polling loop can miss short clicks under
// load; WH_MOUSE_LL gives us one event for each button-down, with the
// hook-time cursor position and timestamp.
//
// Raw typed-text capture is a keylogging surface, so the hook only
// emits printable CHAR events when the user explicitly opted in; by
// default the characters never even cross the process boundary.
const captureTypedText = this.settings.get('capture.captureTypedText') ? 'true' : 'false';
const ps = `
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @'
@@ -1054,6 +1059,7 @@ public static class SFHook {
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
private const uint HIGH_PRIORITY_CLASS = 0x00000080;
private static readonly bool CaptureTypedText = ${captureTypedText};
private static IntPtr hook = IntPtr.Zero;
private static IntPtr keyHook = IntPtr.Zero;
private static LowLevelMouseProc proc = MouseHookCallback;
@@ -1306,8 +1312,10 @@ public static class SFHook {
queue.Enqueue("KEY Escape " + unixMs); signal.Set();
} else if (vk == 0x0D) {
queue.Enqueue("KEY Enter " + unixMs); signal.Set();
} else {
// Map to Unicode character using current keyboard layout + shift state.
} else if (CaptureTypedText) {
// Map to Unicode character using current keyboard layout + shift
// state. Only reached when the user opted into typed-text capture;
// otherwise raw characters are never read or emitted.
byte[] ks = new byte[256];
GetKeyboardState(ks);
var sb = new System.Text.StringBuilder(4);
@@ -1790,6 +1798,11 @@ public static class SFHook {
this._keyLastAt = now;
if (type === 'CHAR') {
// Raw typed-text capture is off by default: it can record passwords or
// other secrets. Only buffer printable characters when the user has
// explicitly opted in via capture.captureTypedText. Shortcut/navigation
// keys below are unaffected.
if (!this.settings.get('capture.captureTypedText')) return;
const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
this._keyBuffer = (this._keyBuffer + ch).slice(-200);
} else if (type === 'KEY') {
+6
View File
@@ -599,6 +599,12 @@ function setupIpc() {
validate: (a) => c.string(a.text, 200000)
&& c.optionalString(a.guideTitle, 1000) && c.optionalString(a.stepTitle, 1000),
});
// Cancel outstanding AI requests, e.g. when a guide/editor closes, so a
// slow response can't resolve against data the user has moved on from.
h('ai:cancel', ({ guideId = null } = {}) => {
textIntel.cancelInflight(guideId || null);
return true;
}, { validate: (a) => c.optionalId(a.guideId) });
h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
+1
View File
@@ -56,6 +56,7 @@ const api = {
test: invoke('ai:test'),
fillStep: invoke('ai:fillStep'),
rewriteText: invoke('ai:rewriteText'),
cancel: invoke('ai:cancel'),
},
capture: {
shoot: invoke('capture:shoot'),
+1 -1
View File
@@ -172,7 +172,7 @@ class StepForgeApp {
el('div.welcome', {},
el('div.welcome-title', {},
el('h1', {}, 'StepForge'),
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides, fully offline.'),
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides. Local-first, no telemetry.'),
),
el('div.welcome-actions', {},
el('button.welcome-btn.primary', {
+5
View File
@@ -151,6 +151,11 @@ class GuideEditor {
setActive(active) {
this.active = Boolean(active);
// Leaving the editor cancels any in-flight AI request for this guide so a
// slow response can't resolve against a guide the user has closed.
if (!this.active && this.guideId) {
api.ai.cancel({ guideId: this.guideId }).catch(() => {});
}
}
setSettings(settings) {
+150 -23
View File
@@ -8,6 +8,7 @@ const {
DEFAULT_CAPTURE_TITLES,
buildCaptureTitle,
normalizeOllamaHost,
validateOllamaHost,
normalizeAiPatch,
buildAiPrompt,
applyAiPatchToStep,
@@ -74,9 +75,101 @@ class TextIntelService {
this.workerQueue = Promise.resolve();
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
this.modelCapabilityCache = new Map();
// In-flight AI request controllers, grouped by guide, so closing a guide
// (or quitting) can cancel outstanding requests instead of leaving them
// to resolve against stale data.
this.inflight = new Set();
// Bounded concurrency for AI network calls.
this.maxConcurrent = 2;
this.activeCount = 0;
this.waitQueue = [];
}
aiNetworkOptions() {
const ai = this.settings.get('ai') || {};
const timeoutMs = Number.isFinite(ai.timeoutMs) && ai.timeoutMs > 0 ? ai.timeoutMs : 60000;
const maxImageBytes = Number.isFinite(ai.maxImageBytes) && ai.maxImageBytes > 0
? ai.maxImageBytes
: 12 * 1024 * 1024;
return {
allowRemote: Boolean(ai.allowRemoteHost),
attachScreenshots: ai.attachScreenshots !== false,
timeoutMs,
maxImageBytes,
};
}
// Resolve the endpoint against the local-first policy or throw a clear error.
resolveHost(host) {
const { allowRemote } = this.aiNetworkOptions();
const result = validateOllamaHost(host, { allowRemote });
if (!result.ok) {
const err = new Error(result.reason);
err.code = 'STEPFORGE_AI_HOST_BLOCKED';
throw err;
}
return result.host;
}
// fetch with a hard deadline and cooperative cancellation. Every AI network
// call goes through here so a dead endpoint can never hang the UI.
async fetchJson(url, { method = 'GET', body = null, guideId = null } = {}) {
const { timeoutMs } = this.aiNetworkOptions();
const controller = new AbortController();
if (guideId) controller._guideId = guideId;
this.inflight.add(controller);
const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
try {
const res = await this.fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
return res;
} catch (err) {
if (controller.signal.aborted) {
// The abort reason distinguishes an explicit cancel from a timeout.
const reasonMsg = controller.signal.reason && controller.signal.reason.message;
const cancelled = reasonMsg === 'cancelled';
const wrapped = new Error(cancelled ? 'AI request cancelled.' : 'AI request timed out.');
wrapped.code = 'STEPFORGE_AI_ABORTED';
throw wrapped;
}
throw err;
} finally {
clearTimeout(timer);
this.inflight.delete(controller);
}
}
// Cancel in-flight AI requests, optionally scoped to one guide.
cancelInflight(guideId = null) {
for (const controller of [...this.inflight]) {
if (!guideId || controller._guideId === guideId) {
try { controller.abort(new Error('cancelled')); } catch { /* already settled */ }
this.inflight.delete(controller);
}
}
}
// Bounded-concurrency gate for AI network work.
async withConcurrency(fn) {
if (this.activeCount >= this.maxConcurrent) {
await new Promise((resolve) => this.waitQueue.push(resolve));
}
this.activeCount += 1;
try {
return await fn();
} finally {
this.activeCount -= 1;
const next = this.waitQueue.shift();
if (next) next();
}
}
async shutdown() {
this.cancelInflight();
if (this.worker) {
try {
await this.worker.terminate();
@@ -374,8 +467,19 @@ public static class Win32 {
if (!config.ollama.host) {
return { ok: false, reason: 'Set an Ollama host first.' };
}
const tagsUrl = new URL('/api/tags', `${config.ollama.host.replace(/\/+$/, '')}/`);
const res = await this.fetch(tagsUrl, { method: 'GET' });
let host;
try {
host = this.resolveHost(config.ollama.host);
} catch (err) {
return { ok: false, reason: err.message };
}
const tagsUrl = new URL('/api/tags', `${host.replace(/\/+$/, '')}/`);
let res;
try {
res = await this.fetchJson(tagsUrl, { method: 'GET' });
} catch (err) {
return { ok: false, reason: err.message };
}
if (!res.ok) {
return { ok: false, reason: `Ollama check failed (${res.status})` };
}
@@ -383,7 +487,7 @@ public static class Win32 {
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
const vision = installed ? await this.modelSupportsVision({
host: config.ollama.host,
host,
model: config.ollama.model,
}) : false;
return {
@@ -391,7 +495,7 @@ public static class Win32 {
installed,
vision,
models,
host: config.ollama.host,
host,
model: config.ollama.model,
};
}
@@ -407,10 +511,9 @@ public static class Win32 {
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
let capabilities = [];
try {
const response = await this.fetch(url, {
const response = await this.fetchJson(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: normalizedModel }),
body: { model: normalizedModel },
});
if (response.ok) {
const payload = await response.json();
@@ -439,12 +542,12 @@ public static class Win32 {
return fs.readFileSync(imagePath).toString('base64');
}
async callOllamaText({ host, model, prompt, systemPrompt }) {
async callOllamaText({ host, model, prompt, systemPrompt, guideId = null }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const response = await this.fetch(url, {
const response = await this.withConcurrency(() => this.fetchJson(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guideId,
body: {
model,
stream: false,
messages: [
@@ -452,8 +555,8 @@ public static class Win32 {
{ role: 'user', content: prompt },
],
options: { temperature: 0.4 },
}),
});
},
}));
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
const payload = await response.json();
const content = payload?.message?.content;
@@ -461,16 +564,16 @@ public static class Win32 {
return content.trim();
}
async callOllama({ host, model, prompt, systemPrompt, images = [] }) {
async callOllama({ host, model, prompt, systemPrompt, images = [], guideId = null }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const userMessage = { role: 'user', content: prompt };
if (Array.isArray(images) && images.length) {
userMessage.images = images;
}
const response = await this.fetch(url, {
const response = await this.withConcurrency(() => this.fetchJson(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guideId,
body: {
model,
stream: false,
format: 'json',
@@ -481,8 +584,8 @@ public static class Win32 {
options: {
temperature: 0.2,
},
}),
});
},
}));
if (!response.ok) {
throw new Error(`Ollama request failed (${response.status})`);
}
@@ -508,6 +611,13 @@ public static class Win32 {
if (!config.ollama.host || !config.ollama.model) {
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
}
let host;
try {
host = this.resolveHost(config.ollama.host);
} catch (err) {
return { ok: false, reason: err.message };
}
const netOptions = this.aiNetworkOptions();
const guide = this.store.getGuide(guideId);
const step = this.store.getStep(guideId, stepId);
@@ -522,10 +632,20 @@ public static class Win32 {
return { ok: false, reason: 'Block not found.' };
}
const screenshotBase64 = step.image ? this.readStepImageBase64(guideId, stepId) : '';
// Only attach a screenshot when the user allows it, the model can use
// it, and it is within the size budget (a full 4K PNG base64-expands to
// tens of MB in the request body).
let screenshotBase64 = '';
if (netOptions.attachScreenshots && step.image) {
const candidate = this.readStepImageBase64(guideId, stepId);
const bytes = candidate ? Math.floor((candidate.length * 3) / 4) : 0;
if (candidate && bytes <= netOptions.maxImageBytes) {
screenshotBase64 = candidate;
}
}
const screenshotAttached = Boolean(screenshotBase64)
? await this.modelSupportsVision({
host: config.ollama.host,
host,
model: config.ollama.model,
})
: false;
@@ -588,11 +708,12 @@ public static class Win32 {
});
const raw = await this.callOllama({
host: config.ollama.host,
host,
model: config.ollama.model,
prompt,
systemPrompt,
images: screenshotAttached ? [screenshotBase64] : [],
guideId,
});
const patch = normalizeAiPatch(raw);
const updated = applyAiPatchToStep(step, patch, { target, blockId });
@@ -610,6 +731,12 @@ public static class Win32 {
if (!config.ollama.host || !config.ollama.model) {
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
}
let host;
try {
host = this.resolveHost(config.ollama.host);
} catch (err) {
return { ok: false, reason: err.message };
}
const trimmed = normalizeWhitespace(text);
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
@@ -628,7 +755,7 @@ public static class Win32 {
].filter((l) => l !== null).join('\n');
const result = await this.callOllamaText({
host: config.ollama.host,
host,
model: config.ollama.model,
prompt,
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',