Enforce a truthful local-first AI/privacy contract
Template tests / tests (pull_request) Failing after 34s
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:
@@ -1,17 +1,26 @@
|
||||
# StepForge
|
||||
|
||||
StepForge is a **fully offline**, open-source desktop app for Windows, with
|
||||
StepForge is a **local-first**, open-source desktop app for Windows, with
|
||||
Linux (WIP) builds. It captures step-by-step workflows as screenshots, lets
|
||||
you annotate and describe each step in a focused three-pane editor, and
|
||||
exports the result to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP),
|
||||
confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current
|
||||
reconmendations for exporting is Markdown and PDF.
|
||||
|
||||
It is an independent offline desktop guide-capture tool inspired by publicly
|
||||
documented workflow patterns of commercial documentation tools like Folge. It contains no
|
||||
third-party branding, assets, or code from those tools, and it never talks to
|
||||
the network: no telemetry, no update checks, no license checks, no cloud, no
|
||||
remote AI.
|
||||
It is an independent desktop guide-capture tool inspired by publicly
|
||||
documented workflow patterns of commercial documentation tools like Folge. It
|
||||
contains no third-party branding, assets, or code from those tools.
|
||||
|
||||
**Network and privacy contract.** StepForge has no telemetry, no update
|
||||
checks, no license checks, and no cloud. Guides never leave your machine on
|
||||
their own. The only outbound network feature is the **optional** AI
|
||||
integration: when *you* enable it and configure an [Ollama](https://ollama.com)
|
||||
endpoint, StepForge sends step screenshots and text to that endpoint to
|
||||
generate titles and descriptions. By default that endpoint must be **local
|
||||
(loopback)**; sending data to a remote host requires the explicit "Allow
|
||||
remote AI host" opt-in. See [docs/PRIVACY.md](docs/PRIVACY.md) for exactly
|
||||
what is collected and sent. Note that OCR (Tesseract) and its English language
|
||||
data are bundled production dependencies — Electron is not the only one.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
+15
-2
@@ -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') {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
@@ -45,6 +45,13 @@ const DEFAULT_SETTINGS = {
|
||||
// user is likely to click so the buffer holds frames of the now-visible
|
||||
// screen rather than the just-dismissed app window.
|
||||
postHideSettleMs: 150,
|
||||
// Raw typed-text capture. When true, printable characters typed between
|
||||
// captures are buffered and stored in step capture metadata (and can be
|
||||
// sent to a configured AI host). This can record passwords or other
|
||||
// secrets, so it is OFF by default and must be explicitly opted into.
|
||||
// Shortcut detection (Ctrl+T, Enter, …) is unaffected — only raw
|
||||
// character logging is gated here.
|
||||
captureTypedText: false,
|
||||
},
|
||||
editor: {
|
||||
focusedViewDefaultForNewSteps: false,
|
||||
@@ -52,6 +59,22 @@ const DEFAULT_SETTINGS = {
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
// Auto-document captured steps in the background when a session capture
|
||||
// lands. Requires enabled + a reachable model.
|
||||
autoDoc: false,
|
||||
// Local-first: only a loopback Ollama endpoint is contacted unless this
|
||||
// is explicitly turned on. Turning it on sends screenshots and text to
|
||||
// the configured remote host.
|
||||
allowRemoteHost: false,
|
||||
// Attach the step screenshot to AI requests (only for vision-capable
|
||||
// models). Turning this off keeps requests text-only.
|
||||
attachScreenshots: true,
|
||||
// Per-request network deadline (ms). A dead endpoint fails fast instead
|
||||
// of leaving UI actions pending forever.
|
||||
timeoutMs: 60000,
|
||||
// Skip attaching a screenshot larger than this many bytes (pre-base64)
|
||||
// to avoid multi-hundred-MB request bodies.
|
||||
maxImageBytes: 12 * 1024 * 1024,
|
||||
ollama: {
|
||||
host: 'http://127.0.0.1:11434',
|
||||
model: 'llama3.2:1b',
|
||||
|
||||
@@ -538,6 +538,60 @@ function normalizeOllamaHost(host) {
|
||||
return `http://${raw.replace(/\/+$/, '')}`;
|
||||
}
|
||||
|
||||
// A hostname/IP that refers to this machine only. StepForge is local-first:
|
||||
// by default the Ollama endpoint must be loopback so screenshots and text
|
||||
// never leave the device, unless the user explicitly opts into a remote host.
|
||||
function isLoopbackHost(host) {
|
||||
const normalized = normalizeOllamaHost(host);
|
||||
if (!normalized) return false;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(normalized);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const name = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (name === 'localhost' || name === '::1' || name === '0.0.0.0' || name === '::') return true;
|
||||
// IPv4 loopback block 127.0.0.0/8.
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(name);
|
||||
if (m && Number(m[1]) === 127 && m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255)) {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped IPv6 loopback, e.g. ::ffff:127.0.0.1.
|
||||
if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a configured Ollama endpoint against the local-first policy.
|
||||
* Returns { ok, host, reason }. Remote hosts are rejected unless the caller
|
||||
* passes allowRemote: true (the explicit ai.allowRemoteHost opt-in).
|
||||
*/
|
||||
function validateOllamaHost(host, { allowRemote = false } = {}) {
|
||||
const normalized = normalizeOllamaHost(host);
|
||||
if (!normalized) return { ok: false, host: '', reason: 'No Ollama host configured.' };
|
||||
let url;
|
||||
try {
|
||||
url = new URL(normalized);
|
||||
} catch {
|
||||
return { ok: false, host: normalized, reason: 'Ollama host is not a valid URL.' };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { ok: false, host: normalized, reason: 'Ollama host must use http or https.' };
|
||||
}
|
||||
if (!allowRemote && !isLoopbackHost(normalized)) {
|
||||
return {
|
||||
ok: false,
|
||||
host: normalized,
|
||||
reason:
|
||||
'Remote Ollama hosts are disabled. StepForge only contacts a local (loopback) ' +
|
||||
'Ollama by default. Enable "Allow remote AI host" in AI settings to send ' +
|
||||
'screenshots and text to this host.',
|
||||
};
|
||||
}
|
||||
return { ok: true, host: normalized, reason: '' };
|
||||
}
|
||||
|
||||
function normalizeAiLevel(level) {
|
||||
const key = normalizeWhitespace(level).toLowerCase();
|
||||
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
|
||||
@@ -845,6 +899,8 @@ module.exports = {
|
||||
buildCaptureTitle,
|
||||
plainTextToHtml,
|
||||
normalizeOllamaHost,
|
||||
isLoopbackHost,
|
||||
validateOllamaHost,
|
||||
normalizeAiPatch,
|
||||
buildAiPrompt,
|
||||
applyAiPatchToStep,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# StepForge privacy and network contract
|
||||
|
||||
StepForge is **local-first**. Guides, screenshots, and settings live on your
|
||||
machine and are never uploaded on their own. This document describes exactly
|
||||
what data StepForge collects locally and the one situation in which data
|
||||
leaves your device.
|
||||
|
||||
## What never happens
|
||||
|
||||
- No telemetry or analytics.
|
||||
- No update checks, license checks, or "phone home".
|
||||
- No cloud storage or sync.
|
||||
- No dependency downloads at runtime (dependencies are installed only by you,
|
||||
via `npm ci`).
|
||||
|
||||
## Data StepForge collects locally
|
||||
|
||||
When you capture a step, StepForge may record, **stored only on disk in your
|
||||
data directory**, capture context to help title and describe the step:
|
||||
|
||||
- The screenshot image.
|
||||
- OCR text read from the region around your click (via the bundled Tesseract
|
||||
engine — this runs locally, it is not a network call).
|
||||
- The foreground window title and application name.
|
||||
- The accessibility label/role/value of the clicked UI element (Windows).
|
||||
- Keyboard shortcuts you pressed (for example `Ctrl+T`).
|
||||
|
||||
### Raw typed text is OFF by default
|
||||
|
||||
StepForge can additionally record the **raw printable characters** you type
|
||||
between captures. Because this can capture passwords or other secrets, it is
|
||||
**disabled by default**. It is only recorded when you explicitly enable
|
||||
`capture.captureTypedText`, and even then the characters are used only to
|
||||
title the current step and are not retained beyond it. With the setting off,
|
||||
raw characters are never read or stored (on Windows they never even leave the
|
||||
keyboard-hook process).
|
||||
|
||||
## The one outbound feature: optional AI
|
||||
|
||||
StepForge has an **optional** AI integration that generates step titles and
|
||||
descriptions with a local large-language-model runtime
|
||||
([Ollama](https://ollama.com)). It is **off by default**. When you turn it on
|
||||
and configure an endpoint:
|
||||
|
||||
- StepForge sends the step **screenshot** (only to vision-capable models, only
|
||||
when "Attach screenshots" is on, and only if within the size limit) and the
|
||||
step **text/capture context** to the configured Ollama endpoint.
|
||||
- By default the endpoint must be a **local (loopback) address** — for example
|
||||
`http://127.0.0.1:11434`. StepForge refuses to send data to a non-loopback
|
||||
host unless you explicitly enable **"Allow remote AI host"**. Enabling that
|
||||
option means your screenshots and text are sent to the remote host you
|
||||
configured; StepForge cannot control what that host does with them.
|
||||
- Every AI request has a timeout, can be cancelled (closing the guide cancels
|
||||
in-flight requests), and runs under a bounded concurrency limit.
|
||||
|
||||
## Bundled dependencies
|
||||
|
||||
Beyond the Electron desktop shell, StepForge bundles the Tesseract OCR engine
|
||||
and its English language data as production dependencies. All OCR runs locally.
|
||||
|
||||
## Where your data lives
|
||||
|
||||
- Windows: `%APPDATA%\stepforge`
|
||||
- Linux: `~/.local/share/stepforge` (or `$XDG_DATA_HOME/stepforge`)
|
||||
- Override with the `STEPFORGE_DATA_DIR` environment variable.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "stepforge",
|
||||
"version": "0.3.2",
|
||||
"buildVersion": "0.3.2.1",
|
||||
"description": "Fully offline desktop tool for capturing, annotating, and exporting step-by-step guides.",
|
||||
"description": "Local-first desktop tool for capturing, annotating, and exporting step-by-step guides, with an optional user-configured local AI integration.",
|
||||
"main": "app/main.js",
|
||||
"author": "StepForge [email protected]",
|
||||
"license": "MPL-2.0",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
const { isLoopbackHost, validateOllamaHost } = require('../../core/text-intel');
|
||||
const { TextIntelService } = require('../../app/text-intel');
|
||||
const CaptureService = require('../../app/capture');
|
||||
|
||||
function makeSettings(ai = {}) {
|
||||
const data = {
|
||||
ai: {
|
||||
enabled: true,
|
||||
allowRemoteHost: false,
|
||||
attachScreenshots: true,
|
||||
timeoutMs: 60000,
|
||||
maxImageBytes: 12 * 1024 * 1024,
|
||||
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b' },
|
||||
...ai,
|
||||
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b', ...(ai.ollama || {}) },
|
||||
},
|
||||
};
|
||||
return {
|
||||
get(key) {
|
||||
return key.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- loopback policy (pure core) -------------------------------------------
|
||||
|
||||
test('isLoopbackHost recognizes local addresses and rejects remote ones', () => {
|
||||
for (const host of [
|
||||
'http://127.0.0.1:11434',
|
||||
'127.0.0.1',
|
||||
'localhost:11434',
|
||||
'http://[::1]:11434',
|
||||
'http://127.5.6.7',
|
||||
'0.0.0.0',
|
||||
]) {
|
||||
assert.equal(isLoopbackHost(host), true, `loopback: ${host}`);
|
||||
}
|
||||
for (const host of [
|
||||
'http://10.0.0.5:11434',
|
||||
'http://192.168.1.20',
|
||||
'https://ollama.example.com',
|
||||
'http://8.8.8.8',
|
||||
'http://ollama.internal',
|
||||
]) {
|
||||
assert.equal(isLoopbackHost(host), false, `remote: ${host}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('validateOllamaHost blocks remote hosts unless explicitly allowed', () => {
|
||||
assert.equal(validateOllamaHost('http://127.0.0.1:11434').ok, true);
|
||||
const blocked = validateOllamaHost('http://10.0.0.5:11434');
|
||||
assert.equal(blocked.ok, false);
|
||||
assert.match(blocked.reason, /remote/i);
|
||||
assert.equal(validateOllamaHost('http://10.0.0.5:11434', { allowRemote: true }).ok, true);
|
||||
assert.equal(validateOllamaHost('').ok, false);
|
||||
assert.equal(validateOllamaHost('ftp://127.0.0.1').ok, false);
|
||||
});
|
||||
|
||||
// ---- host policy enforced by the service -----------------------------------
|
||||
|
||||
test('AI connection test refuses a remote host without the opt-in', async (t) => {
|
||||
const root = makeTmpDir('ai-remote-block');
|
||||
t.after(() => rmrf(root));
|
||||
let called = false;
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' } }),
|
||||
dataDir: root,
|
||||
fetchImpl: async () => { called = true; return { ok: true, json: async () => ({}) }; },
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /remote/i);
|
||||
assert.equal(called, false, 'must not contact a blocked host at all');
|
||||
});
|
||||
|
||||
test('AI connection test allows a remote host with the opt-in', async (t) => {
|
||||
const root = makeTmpDir('ai-remote-allow');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({
|
||||
allowRemoteHost: true,
|
||||
ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' },
|
||||
}),
|
||||
dataDir: root,
|
||||
fetchImpl: async (url) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === '/api/tags') return { ok: true, json: async () => ({ models: [{ name: 'llama3.2:1b' }] }) };
|
||||
if (pathname === '/api/show') return { ok: true, json: async () => ({ capabilities: ['vision'] }) };
|
||||
throw new Error(`unexpected ${pathname}`);
|
||||
},
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.host, 'http://10.0.0.5:11434');
|
||||
});
|
||||
|
||||
// ---- request timeout / cancellation ----------------------------------------
|
||||
|
||||
test('a hung endpoint times out instead of hanging forever', async (t) => {
|
||||
const root = makeTmpDir('ai-timeout');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ timeoutMs: 40 }),
|
||||
dataDir: root,
|
||||
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
||||
// Never resolves on its own; only the abort signal ends it.
|
||||
init.signal.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
});
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /timed out|timeout/i);
|
||||
});
|
||||
|
||||
test('cancelInflight aborts an outstanding request', async (t) => {
|
||||
const root = makeTmpDir('ai-cancel');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings({ timeoutMs: 60000 }),
|
||||
dataDir: root,
|
||||
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
});
|
||||
const pending = service.testAiConnection();
|
||||
// Give the request a tick to register in the inflight set.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(service.inflight.size, 1);
|
||||
service.cancelInflight();
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /cancel/i);
|
||||
});
|
||||
|
||||
// ---- typed-text capture is off by default ----------------------------------
|
||||
|
||||
function makeCaptureService(settingsOverrides = {}) {
|
||||
const settingsData = { 'capture.mode': 'fullscreen', ...settingsOverrides };
|
||||
return new CaptureService({
|
||||
store: {},
|
||||
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
|
||||
getWindow: () => null,
|
||||
notify: () => {},
|
||||
screenApi: { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getAllDisplays: () => [] },
|
||||
});
|
||||
}
|
||||
|
||||
test('raw typed text is ignored unless explicitly enabled', () => {
|
||||
const off = makeCaptureService();
|
||||
off.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
||||
off.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
||||
assert.equal(off.snapshotKeyContext().recentTyped, '', 'typed text must not be buffered by default');
|
||||
|
||||
const on = makeCaptureService({ 'capture.captureTypedText': true });
|
||||
on.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
||||
on.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
||||
assert.equal(on.snapshotKeyContext().recentTyped, 'hi');
|
||||
});
|
||||
|
||||
test('shortcut detection still works with typed text disabled', () => {
|
||||
const off = makeCaptureService();
|
||||
off.onKeyboardEvent('KEY', 'Ctrl+T', Date.now());
|
||||
assert.equal(off.snapshotKeyContext().recentShortcut, 'Ctrl+T');
|
||||
});
|
||||
|
||||
// ---- source-level guards ----------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
test('the Windows keyboard hook only emits characters when opted in', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'app/capture.js'), 'utf8');
|
||||
// The CHAR emission must be guarded by the opt-in flag threaded into C#.
|
||||
assert.match(src, /CaptureTypedText = \$\{captureTypedText\}/);
|
||||
assert.match(src, /else if \(CaptureTypedText\) \{/);
|
||||
});
|
||||
Reference in New Issue
Block a user