Author SHA1 Message Date
TylerandClaude Fable 5 ccbb9b03dc 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]>
2026-07-03 11:36:01 -07:00
Tyler 6ffef69705 Merge pull request #6 from Twest2/StepForge pr/02-security-boundary
Template tests / tests (push) Failing after 2m37s
Close the renderer privilege boundary: navigation, IPC, permissions, shell (plan PR 2)
2026-07-03 11:27:28 -07:00
TylerandClaude Fable 5 50e445e7c3 Close the renderer privilege boundary: navigation, IPC, permissions, shell
Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 2 of the sequence). A remote page could
previously inherit the privileged preload bridge: the main window had no
navigation guard or popup handler, IPC handlers accepted any sender, every
Electron permission was granted to everyone, and shell:openPath accepted an
arbitrary renderer-supplied target.

- New app/security.js (plain-Node testable) centralizes the policy:
  app-page identity, navigation/popup denial, deny-by-default permissions,
  external-URL validation, IPC sender guard, payload budgets, field
  validators, and a produced-files registry.
- Every window (main, region overlay, capture worker) is sandboxed, denies
  all navigation away from its own page, denies every popup, and refuses
  webview attachment.
- Every IPC channel now rejects events that are not from the main window's
  top frame on index.html, rejects non-plain/oversized argument bags, and
  channels with risky inputs validate ids, enums, names, and sizes
  (path-traversal and prototype-pollution guards included).
- Permissions are deny-by-default; the only grant in the app is display
  capture (+media) for the dedicated capture-worker page. The display-media
  handler also verifies the requesting frame.
- shell:openPath/showItemInFolder are gone. Replacements are intent-specific:
  openProduced (only files the main process produced this session),
  revealLinkedArchive (path read from the store, not the renderer), and
  openExternal (parsed, scheme-checked http(s)/mailto only).
- archive:peek removed: unused, and it let the renderer read arbitrary
  archives by path.
- export:run only accepts output directories that came from a dialog pick or
  remembered settings; anything else re-prompts.
- Renderer: description links never navigate; http(s)/mailto open externally
  via the validated handler.
- 12 new security regression tests: hostile navigation targets, permission
  matrix, sender spoofing (wrong window/subframe/navigated/disposed frames),
  oversized payloads, traversal/pollution attempts, produced-file registry,
  and source-level guards (no blanket grants, no generic shell channels,
  sandbox on every window).

Verified: 215 unit tests pass; startup smoke, unit-workflows, sample
artifacts, and build-release E2E pass; click self-test still reaches
source: stream with markers 3/3 and burst 8/8 under the deny-by-default
policy (arm/debounce remain the known pre-existing failures, untouched
here); UI screenshot confirms the sandboxed renderer boots.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:25:36 -07:00
15 changed files with 1280 additions and 94 deletions
+15 -6
View File
@@ -1,17 +1,26 @@
# StepForge # 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 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 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), exports the result to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP),
confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current
reconmendations for exporting is Markdown and PDF. reconmendations for exporting is Markdown and PDF.
It is an independent offline desktop guide-capture tool inspired by publicly It is an independent desktop guide-capture tool inspired by publicly
documented workflow patterns of commercial documentation tools like Folge. It contains no documented workflow patterns of commercial documentation tools like Folge. It
third-party branding, assets, or code from those tools, and it never talks to contains no third-party branding, assets, or code from those tools.
the network: no telemetry, no update checks, no license checks, no cloud, no
remote AI. **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 ## Overview
+19 -2
View File
@@ -1025,6 +1025,11 @@ class CaptureService {
// by other processes and a polling loop can miss short clicks under // 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 // load; WH_MOUSE_LL gives us one event for each button-down, with the
// hook-time cursor position and timestamp. // 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 = ` const ps = `
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @' Add-Type -TypeDefinition @'
@@ -1054,6 +1059,7 @@ public static class SFHook {
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1; private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
private const uint HIGH_PRIORITY_CLASS = 0x00000080; private const uint HIGH_PRIORITY_CLASS = 0x00000080;
private static readonly bool CaptureTypedText = ${captureTypedText};
private static IntPtr hook = IntPtr.Zero; private static IntPtr hook = IntPtr.Zero;
private static IntPtr keyHook = IntPtr.Zero; private static IntPtr keyHook = IntPtr.Zero;
private static LowLevelMouseProc proc = MouseHookCallback; private static LowLevelMouseProc proc = MouseHookCallback;
@@ -1306,8 +1312,10 @@ public static class SFHook {
queue.Enqueue("KEY Escape " + unixMs); signal.Set(); queue.Enqueue("KEY Escape " + unixMs); signal.Set();
} else if (vk == 0x0D) { } else if (vk == 0x0D) {
queue.Enqueue("KEY Enter " + unixMs); signal.Set(); queue.Enqueue("KEY Enter " + unixMs); signal.Set();
} else { } else if (CaptureTypedText) {
// Map to Unicode character using current keyboard layout + shift state. // 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]; byte[] ks = new byte[256];
GetKeyboardState(ks); GetKeyboardState(ks);
var sb = new System.Text.StringBuilder(4); var sb = new System.Text.StringBuilder(4);
@@ -1790,6 +1798,11 @@ public static class SFHook {
this._keyLastAt = now; this._keyLastAt = now;
if (type === 'CHAR') { 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); const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
this._keyBuffer = (this._keyBuffer + ch).slice(-200); this._keyBuffer = (this._keyBuffer + ch).slice(-200);
} else if (type === 'KEY') { } else if (type === 'KEY') {
@@ -2019,8 +2032,12 @@ public static class SFHook {
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'region-preload.js'), preload: path.join(__dirname, 'region-preload.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false,
sandbox: true,
}, },
}); });
// The overlay may only display region.html; deny navigation/popups.
require('./security').installWindowSecurity(overlay, 'region');
let settled = false; let settled = false;
const finish = (rect) => { const finish = (rect) => {
if (settled) return; if (settled) return;
+201 -57
View File
@@ -15,12 +15,13 @@ const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates')
const { buildRenderAst } = require('../core/renderast'); const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters'); const { runExport, EXPORTERS } = require('../exporters');
const { runExportInWorker } = require('./export-runner'); const { runExportInWorker } = require('./export-runner');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive'); const { exportGuideArchive, importGuideArchive, saveLinkedGuide } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots'); const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { readLock } = require('../core/locks'); const { readLock } = require('../core/locks');
const CaptureService = require('./capture'); const CaptureService = require('./capture');
const { TextIntelService } = require('./text-intel'); const { TextIntelService } = require('./text-intel');
const { keepProcessesResponsive } = require('./win-power'); const { keepProcessesResponsive } = require('./win-power');
const security = require('./security');
const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json')); const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json'));
const APP_ID = 'com.stepforge.app'; const APP_ID = 'com.stepforge.app';
@@ -95,6 +96,7 @@ function createWindow() {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: true,
spellcheck: Boolean(settings.get('spellcheck')), spellcheck: Boolean(settings.get('spellcheck')),
// During a recording the window is minimized (Linux) or hidden (Windows). // During a recording the window is minimized (Linux) or hidden (Windows).
// A throttled renderer stops processing capture:added events, so the step // A throttled renderer stops processing capture:added events, so the step
@@ -103,6 +105,10 @@ function createWindow() {
backgroundThrottling: false, backgroundThrottling: false,
}, },
}); });
// The main window may only ever display our index.html: all navigation
// away from it and every popup is denied, so no other document can run
// with this window's preload bridge.
security.installWindowSecurity(mainWindow, 'main');
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
mainWindow.show(); mainWindow.show();
@@ -375,7 +381,37 @@ function sendToRenderer(channel, payload) {
// ---- IPC ------------------------------------------------------------------ // ---- IPC ------------------------------------------------------------------
function setupIpc() { function setupIpc() {
const h = (channel, fn) => ipcMain.handle(channel, async (event, args = {}) => fn(args)); // Every invoke channel is guarded: the event must come from the current
// main window's top frame showing our index.html, the argument bag must be
// a plain object within a per-channel payload budget, and channels with
// risky inputs additionally validate fields before the handler runs.
const trustedSender = security.makeIpcSenderGuard({
getMainWebContents: () => (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null),
});
const c = security.check;
const IMAGE_BUDGET = 256 * 1024 * 1024; // channels that carry base64 PNGs
const h = (channel, fn, opts = {}) => {
const { maxChars = 2 * 1024 * 1024, validate = null } = opts;
ipcMain.handle(channel, async (event, args = {}) => {
if (!trustedSender(event)) {
throw new Error(`${channel}: rejected — untrusted IPC sender`);
}
const a = args === undefined || args === null ? {} : args;
if (!security.isPlainArgs(a) || !security.payloadWithinBudget(a, maxChars)) {
throw new Error(`${channel}: rejected — invalid or oversized arguments`);
}
if (validate && !validate(a)) {
throw new Error(`${channel}: rejected — arguments failed validation`);
}
return fn(a);
});
};
// Files the main process itself produced (exports, previews); only these
// may be re-opened via the shell on renderer request.
const producedFiles = new security.ProducedFiles();
// Output directories the user actually picked in a dialog this session.
const chosenOutputDirs = new Set();
// library // library
h('library:list', () => ({ h('library:list', () => ({
@@ -393,60 +429,71 @@ function setupIpc() {
}); });
reindex(guide.guideId); reindex(guide.guideId);
return guide; return guide;
}); }, { validate: (a) => c.optionalString(a.title, 500) });
h('library:duplicate', ({ guideId }) => { h('library:duplicate', ({ guideId }) => {
const copy = store.duplicateGuide(guideId); const copy = store.duplicateGuide(guideId);
reindex(copy.guideId); reindex(copy.guideId);
return copy; return copy;
}); }, { validate: (a) => c.id(a.guideId) });
h('library:delete', ({ guideId }) => { h('library:delete', ({ guideId }) => {
store.deleteGuide(guideId); store.deleteGuide(guideId);
searchIndex.removeGuide(guideId); searchIndex.removeGuide(guideId);
return true; return true;
}); }, { validate: (a) => c.id(a.guideId) });
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite)); h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite),
{ validate: (a) => c.id(a.guideId) });
h('library:trash:list', () => store.listTrash()); h('library:trash:list', () => store.listTrash());
h('library:trash:restore', ({ name }) => { h('library:trash:restore', ({ name }) => {
const id = store.restoreFromTrash(name); const id = store.restoreFromTrash(name);
reindex(id); reindex(id);
return id; return id;
}); }, { validate: (a) => c.fileName(a.name) });
h('library:trash:purge', ({ names } = {}) => { h('library:trash:purge', ({ names } = {}) => {
if (names && names.length) store.purgeTrashItems(names); if (names && names.length) store.purgeTrashItems(names);
else store.purgeTrash(); else store.purgeTrash();
return true; return true;
}, {
validate: (a) => a.names === undefined || a.names === null
|| (Array.isArray(a.names) && a.names.length <= 1000 && a.names.every((n) => c.fileName(n))),
}); });
h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null)); h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null),
h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name)); { validate: (a) => c.string(a.name, 200) && c.optionalId(a.parentId) });
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId)); h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name),
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null)); { validate: (a) => c.id(a.folderId) && c.string(a.name, 200) });
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId),
{ validate: (a) => c.id(a.folderId) });
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null),
{ validate: (a) => c.id(a.guideId) && c.optionalId(a.folderId) });
// guide + steps // guide + steps
h('guide:get', ({ guideId }) => ({ h('guide:get', ({ guideId }) => ({
guide: store.getGuide(guideId), guide: store.getGuide(guideId),
steps: orderedSteps(guideId), steps: orderedSteps(guideId),
})); }), { validate: (a) => c.id(a.guideId) });
h('guide:save', ({ guide }) => { h('guide:save', ({ guide }) => {
const saved = store.saveGuide(guide); const saved = store.saveGuide(guide);
reindex(guide.guideId); reindex(guide.guideId);
return saved; return saved;
}); }, { validate: (a) => security.isPlainArgs(a.guide) && a.guide && c.id(a.guide.guideId) });
h('step:add', ({ guideId, fields, imageBase64, size, position }) => { h('step:add', ({ guideId, fields, imageBase64, size, position }) => {
const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null; const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null;
const step = store.addStep(guideId, fields || {}, buf, size || null, { position }); const step = store.addStep(guideId, fields || {}, buf, size || null, { position });
reindex(guideId); reindex(guideId);
return step; return step;
}, {
maxChars: IMAGE_BUDGET,
validate: (a) => c.id(a.guideId) && c.optionalBase64(a.imageBase64) && c.optionalNumber(a.position, 0, 100000),
}); });
h('step:save', ({ guideId, step }) => { h('step:save', ({ guideId, step }) => {
const saved = store.saveStep(guideId, step); const saved = store.saveStep(guideId, step);
reindex(guideId); reindex(guideId);
return saved; return saved;
}); }, { validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step && c.id(a.step.stepId) });
h('step:delete', ({ guideId, stepId }) => { h('step:delete', ({ guideId, stepId }) => {
store.deleteStep(guideId, stepId); store.deleteStep(guideId, stepId);
reindex(guideId); reindex(guideId);
return true; return true;
}); }, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => { h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
const images = { const images = {
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null, original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
@@ -455,20 +502,34 @@ function setupIpc() {
const restored = store.restoreStep(guideId, step, images, position); const restored = store.restoreStep(guideId, step, images, position);
reindex(guideId); reindex(guideId);
return restored; return restored;
}, {
maxChars: IMAGE_BUDGET,
validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step
&& c.optionalBase64(a.originalBase64) && c.optionalBase64(a.workingBase64)
&& c.optionalNumber(a.position, 0, 100000),
});
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order), {
validate: (a) => c.id(a.guideId)
&& Array.isArray(a.order) && a.order.length <= 100000 && a.order.every((id) => c.id(id)),
}); });
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
h('step:imagePath', ({ guideId, stepId, which }) => { h('step:imagePath', ({ guideId, stepId, which }) => {
const p = store.stepImagePath(guideId, stepId, which || 'working'); const p = store.stepImagePath(guideId, stepId, which || 'working');
return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null; return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null;
}, {
validate: (a) => c.id(a.guideId) && c.id(a.stepId)
&& (a.which === undefined || a.which === null || c.oneOf(a.which, ['original', 'working'])),
}); });
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) => h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) =>
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null)); store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null), {
maxChars: IMAGE_BUDGET,
validate: (a) => c.id(a.guideId) && c.id(a.stepId) && c.base64(a.pngBase64),
});
h('step:resetWorkingImage', ({ guideId, stepId }) => { h('step:resetWorkingImage', ({ guideId, stepId }) => {
const p = store.stepImagePath(guideId, stepId, 'original'); const p = store.stepImagePath(guideId, stepId, 'original');
const img = nativeImage.createFromPath(p); const img = nativeImage.createFromPath(p);
const { width, height } = img.getSize(); const { width, height } = img.getSize();
return store.resetWorkingImage(guideId, stepId, { width, height }); return store.resetWorkingImage(guideId, stepId, { width, height });
}); }, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
h('step:fromClipboard', ({ guideId, position }) => { h('step:fromClipboard', ({ guideId, position }) => {
const img = clipboard.readImage(); const img = clipboard.readImage();
if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' }; if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' };
@@ -479,7 +540,7 @@ function setupIpc() {
}, img.toPNG(), { width, height }, { position }); }, img.toPNG(), { width, height }, { position });
reindex(guideId); reindex(guideId);
return { ok: true, step }; return { ok: true, step };
}); }, { validate: (a) => c.id(a.guideId) && c.optionalNumber(a.position, 0, 100000) });
h('step:importImage', async ({ guideId }) => { h('step:importImage', async ({ guideId }) => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Import images as steps', title: 'Import images as steps',
@@ -497,11 +558,13 @@ function setupIpc() {
} }
reindex(guideId); reindex(guideId);
return { ok: true, steps }; return { ok: true, steps };
}); }, { validate: (a) => c.id(a.guideId) });
// search // search
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null })); h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }),
h('search:titles', ({ q }) => searchIndex.searchTitles(q)); { validate: (a) => c.optionalString(a.q, 1000) && c.optionalId(a.guideId) });
h('search:titles', ({ q }) => searchIndex.searchTitles(q),
{ validate: (a) => c.optionalString(a.q, 1000) });
// settings + placeholders // settings + placeholders
h('settings:all', () => settings.data); h('settings:all', () => settings.data);
@@ -510,13 +573,13 @@ function setupIpc() {
if (keyPath === 'appearance') applyTheme(); if (keyPath === 'appearance') applyTheme();
if (keyPath.startsWith('capture.hotkey')) registerHotkeys(); if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
return settings.data; return settings.data;
}); }, { validate: (a) => c.settingsKeyPath(a.keyPath) });
h('ai:test', async ({ enabled = null, ollama = null } = {}) => { h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
return textIntel.testAiConnection({ return textIntel.testAiConnection({
enabled, enabled,
ollama, ollama,
}); });
}); }, { validate: (a) => (a.ollama === undefined || a.ollama === null || security.isPlainArgs(a.ollama)) });
h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => { h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => {
const result = await textIntel.generateStepPatch({ const result = await textIntel.generateStepPatch({
guideId, guideId,
@@ -526,10 +589,22 @@ function setupIpc() {
}); });
if (result.ok) reindex(guideId); if (result.ok) reindex(guideId);
return result; return result;
}, {
validate: (a) => c.id(a.guideId) && c.id(a.stepId) && c.optionalId(a.blockId)
&& (a.target === undefined || c.oneOf(a.target, ['all', 'title', 'description', 'block'])),
}); });
h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => { h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => {
return textIntel.rewriteText({ text, guideTitle, stepTitle }); return textIntel.rewriteText({ text, guideTitle, stepTitle });
}, {
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:get', () => settings.getGlobalPlaceholders());
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values)); h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
@@ -552,6 +627,10 @@ function setupIpc() {
} }
} }
return result; return result;
}, {
validate: (a) => c.id(a.guideId)
&& (a.mode === undefined || c.oneOf(a.mode, ['fullscreen', 'window', 'region']))
&& c.optionalNumber(a.delayMs, 0, 600000),
}); });
h('capture:region', async ({ guideId }) => { h('capture:region', async ({ guideId }) => {
const result = await capture.regionCapture(guideId); const result = await capture.regionCapture(guideId);
@@ -571,7 +650,7 @@ function setupIpc() {
} }
} }
return result; return result;
}); }, { validate: (a) => c.id(a.guideId) });
let capturePowerBlocker = -1; let capturePowerBlocker = -1;
const startCapturePower = () => { const startCapturePower = () => {
if (!powerSaveBlocker.isStarted(capturePowerBlocker)) { if (!powerSaveBlocker.isStarted(capturePowerBlocker)) {
@@ -615,6 +694,10 @@ function setupIpc() {
const state = capture.state(); const state = capture.state();
sendToRenderer('capture:state', state); sendToRenderer('capture:state', state);
return state; return state;
}, {
validate: (a) => c.oneOf(a.action, ['start', 'pause', 'resume', 'finish', 'interval'])
&& (a.action !== 'start' || c.id(a.guideId))
&& c.optionalNumber(a.intervalSec, 0, 86400),
}); });
h('capture:state', () => capture.state()); h('capture:state', () => capture.state());
@@ -629,7 +712,7 @@ function setupIpc() {
if (res.canceled) return { ok: false }; if (res.canceled) return { ok: false };
exportGuideArchive(store, guideId, res.filePath); exportGuideArchive(store, guideId, res.filePath);
return { ok: true, path: res.filePath }; return { ok: true, path: res.filePath };
}); }, { validate: (a) => c.id(a.guideId) });
h('archive:open', async ({ mode }) => { h('archive:open', async ({ mode }) => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Open guide archive', title: 'Open guide archive',
@@ -644,30 +727,38 @@ function setupIpc() {
} catch (err) { } catch (err) {
return { ok: false, reason: err.message }; return { ok: false, reason: err.message };
} }
}); }, { validate: (a) => a.mode === undefined || c.oneOf(a.mode, ['copy', 'linked']) });
h('archive:peek', ({ file }) => { // archive:peek was removed: nothing in the renderer used it, and it let a
const { manifest } = readArchive(file); // compromised renderer read arbitrary local archives by path.
return manifest; h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }),
}); { validate: (a) => c.id(a.guideId) });
h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }));
// snapshots // snapshots
h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId)); h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId),
{ validate: (a) => c.id(a.guideId) });
h('snapshots:create', ({ guideId, label }) => h('snapshots:create', ({ guideId, label }) =>
createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') })); createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }),
{ validate: (a) => c.id(a.guideId) && c.optionalString(a.label, 200) });
h('snapshots:restore', ({ guideId, name }) => { h('snapshots:restore', ({ guideId, name }) => {
const guide = restoreSnapshot(store, guideId, name); const guide = restoreSnapshot(store, guideId, name);
reindex(guideId); reindex(guideId);
return guide; return guide;
}); }, { validate: (a) => c.id(a.guideId) && c.fileName(a.name) });
// templates // templates
h('templates:list', ({ format }) => templates.list(format)); const validFormat = (v) => c.oneOf(v, FORMATS);
h('templates:load', ({ format, name }) => templates.load(format, name)); h('templates:list', ({ format }) => templates.list(format),
h('templates:save', ({ format, name, options }) => templates.save(format, name, options)); { validate: (a) => validFormat(a.format) });
h('templates:delete', ({ format, name }) => templates.remove(format, name)); h('templates:load', ({ format, name }) => templates.load(format, name),
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName)); { validate: (a) => validFormat(a.format) && c.fileName(a.name) });
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name)); h('templates:save', ({ format, name, options }) => templates.save(format, name, options),
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) && security.isPlainArgs(a.options) });
h('templates:delete', ({ format, name }) => templates.remove(format, name),
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName),
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) && c.fileName(a.newName) });
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name),
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
h('templates:export', async ({ format, name }) => { h('templates:export', async ({ format, name }) => {
const res = await dialog.showSaveDialog(mainWindow, { const res = await dialog.showSaveDialog(mainWindow, {
defaultPath: `${name}.sfglt`, defaultPath: `${name}.sfglt`,
@@ -676,7 +767,7 @@ function setupIpc() {
if (res.canceled) return { ok: false }; if (res.canceled) return { ok: false };
templates.exportTemplate(format, name, res.filePath); templates.exportTemplate(format, name, res.filePath);
return { ok: true }; return { ok: true };
}); }, { validate: (a) => validFormat(a.format) && c.fileName(a.name) });
h('templates:import', async () => { h('templates:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
filters: [{ name: 'StepForge template', extensions: ['sfglt'] }], filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
@@ -709,15 +800,24 @@ function setupIpc() {
}[format]; }[format];
if (!mod) return {}; if (!mod) return {};
return { ...require(mod).DEFAULT_TEMPLATE }; return { ...require(mod).DEFAULT_TEMPLATE };
}); }, { validate: (a) => c.string(a.format, 40) });
h('export:run', async ({ guideId, format, options, outDir }) => { h('export:run', async ({ guideId, format, options, outDir }) => {
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`); // The renderer may only nominate directories that came from this main
// process: a dialog pick from this session or a remembered last-output
// directory. Anything else is ignored and re-asked via the dialog.
const rememberedDirs = Object.values(settings.get('exports.lastOutputDirs') || {});
let dir = null;
if (outDir && (chosenOutputDirs.has(outDir) || rememberedDirs.includes(outDir))) {
dir = outDir;
}
if (!dir) dir = settings.get(`exports.lastOutputDirs.${format}`);
if (!dir) { if (!dir) {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'], title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
}); });
if (res.canceled) return { ok: false }; if (res.canceled) return { ok: false };
dir = res.filePaths[0]; dir = res.filePaths[0];
chosenOutputDirs.add(dir);
} }
settings.set(`exports.lastOutputDirs.${format}`, dir); settings.set(`exports.lastOutputDirs.${format}`, dir);
const result = await runExportInWorker({ const result = await runExportInWorker({
@@ -728,17 +828,23 @@ function setupIpc() {
outDir: dir, outDir: dir,
globals: settings.getGlobalPlaceholders(), globals: settings.getGlobalPlaceholders(),
}); });
producedFiles.add(result.file);
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file); if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
return { ok: true, ...result }; return { ok: true, ...result };
}, {
validate: (a) => c.id(a.guideId) && validFormat(a.format)
&& (a.options === undefined || security.isPlainArgs(a.options))
&& c.optionalString(a.outDir, 1000),
}); });
h('export:chooseDir', async ({ format }) => { h('export:chooseDir', async ({ format }) => {
const res = await dialog.showOpenDialog(mainWindow, { const res = await dialog.showOpenDialog(mainWindow, {
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'], title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
}); });
if (res.canceled) return null; if (res.canceled) return null;
chosenOutputDirs.add(res.filePaths[0]);
settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]); settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]);
return res.filePaths[0]; return res.filePaths[0];
}); }, { validate: (a) => validFormat(a.format) });
h('export:preview', ({ guideId, format, options }) => { h('export:preview', ({ guideId, format, options }) => {
const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`); const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`);
fs.rmSync(previewDir, { recursive: true, force: true }); fs.rmSync(previewDir, { recursive: true, force: true });
@@ -747,7 +853,11 @@ function setupIpc() {
maxSteps: settings.get('exports.previewStepCount') || 3, maxSteps: settings.get('exports.previewStepCount') || 3,
}); });
const result = runExport(format, ast, previewDir, options || {}); const result = runExport(format, ast, previewDir, options || {});
producedFiles.add(result.file);
return { ok: true, file: result.file, fileUrl: `file://${result.file}` }; return { ok: true, file: result.file, fileUrl: `file://${result.file}` };
}, {
validate: (a) => c.id(a.guideId) && validFormat(a.format)
&& (a.options === undefined || security.isPlainArgs(a.options)),
}); });
h('preview:cleanup', () => { h('preview:cleanup', () => {
for (const entry of fs.readdirSync(store.tempDir)) { for (const entry of fs.readdirSync(store.tempDir)) {
@@ -758,9 +868,32 @@ function setupIpc() {
return true; return true;
}); });
// shell helpers // shell helpers — intent-specific, no arbitrary paths from the renderer.
h('shell:openPath', ({ target }) => shell.openPath(target)); // Only files this main process produced (exports/previews) may be opened.
h('shell:showItemInFolder', ({ target }) => shell.showItemInFolder(target)); h('shell:openProduced', ({ target }) => {
if (!producedFiles.has(target)) {
return { ok: false, reason: 'not a StepForge-produced file' };
}
shell.openPath(target);
return { ok: true };
}, { validate: (a) => c.string(a.target, 2000) });
// Reveal the linked archive of a guide; the path comes from the store,
// never from the renderer.
h('shell:revealLinkedArchive', ({ guideId }) => {
const guide = store.getGuide(guideId);
const target = guide && guide.linkedSource && guide.linkedSource.path;
if (!target || !fs.existsSync(target)) return { ok: false, reason: 'no linked archive' };
shell.showItemInFolder(target);
return { ok: true };
}, { validate: (a) => c.id(a.guideId) });
// Open a user-clicked link in the system browser. Scheme-validated;
// everything that is not plain http(s)/mailto is refused.
h('shell:openExternal', ({ url }) => {
const safe = security.validateExternalUrl(url);
if (!safe) return { ok: false, reason: 'blocked URL' };
shell.openExternal(safe);
return { ok: true };
}, { validate: (a) => c.string(a.url, 2048) });
h('app:info', () => ({ h('app:info', () => ({
version: app.getVersion(), version: app.getVersion(),
buildVersion: PACKAGE_JSON.buildVersion || app.getVersion(), buildVersion: PACKAGE_JSON.buildVersion || app.getVersion(),
@@ -842,15 +975,19 @@ if (!gotLock) {
textIntel, textIntel,
}); });
// Allow the hidden capture-worker renderer to open a desktop media stream. // Deny-by-default permission policy. The only grant in the entire app is
// Electron 29+ requires an explicit permission grant for display-capture in // display capture (and the media permission getDisplayMedia consults) for
// renderer windows; without it getUserMedia/getDisplayMedia fails, the // the dedicated hidden capture-worker page. Electron 29+ requires that
// stream backend never starts, and every capture falls back to // explicit grant; everything else — including our own main window — is
// desktopCapturer.getSources() — which triggers the portal dialog on Linux // rejected. "Content is local" is not a security control.
// on every single capture. StepForge is fully local/offline so allowing session.defaultSession.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
// all permissions for our own content is safe. const url = (details && details.requestingUrl) || (wc && wc.getURL()) || requestingOrigin;
session.defaultSession.setPermissionCheckHandler(() => true); return security.permissionAllowed(permission, url);
session.defaultSession.setPermissionRequestHandler((_wc, _perm, cb) => cb(true)); });
session.defaultSession.setPermissionRequestHandler((wc, permission, cb, details) => {
const url = (details && details.requestingUrl) || (wc && wc.getURL());
cb(security.permissionAllowed(permission, url));
});
// On GNOME Wayland the only working screen-capture path is the portal-backed // On GNOME Wayland the only working screen-capture path is the portal-backed
// getDisplayMedia (desktopCapturer source ids fail with "device not found"). // getDisplayMedia (desktopCapturer source ids fail with "device not found").
@@ -860,6 +997,13 @@ if (!gotLock) {
// the chosen source then streams for the whole session. (useSystemPicker is // the chosen source then streams for the whole session. (useSystemPicker is
// macOS-only today, harmless elsewhere.) // macOS-only today, harmless elsewhere.)
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => { session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
// Only the capture worker page may open a desktop stream.
const frameUrl = request && request.frame && request.frame.url;
if (!security.isAppPageUrl(frameUrl, 'captureWorker')) {
console.error('[stepforge] display-media request denied for', frameUrl || '(unknown frame)');
callback({});
return;
}
desktopCapturer.getSources({ types: ['screen'] }) desktopCapturer.getSources({ types: ['screen'] })
.then((sources) => { .then((sources) => {
console.log(`[stepforge] display-media request resolved: ${sources.length} screen source(s)`); console.log(`[stepforge] display-media request resolved: ${sources.length} screen source(s)`);
+6 -2
View File
@@ -56,6 +56,7 @@ const api = {
test: invoke('ai:test'), test: invoke('ai:test'),
fillStep: invoke('ai:fillStep'), fillStep: invoke('ai:fillStep'),
rewriteText: invoke('ai:rewriteText'), rewriteText: invoke('ai:rewriteText'),
cancel: invoke('ai:cancel'),
}, },
capture: { capture: {
shoot: invoke('capture:shoot'), shoot: invoke('capture:shoot'),
@@ -95,8 +96,11 @@ const api = {
cleanupPreviews: invoke('preview:cleanup'), cleanupPreviews: invoke('preview:cleanup'),
}, },
shell: { shell: {
openPath: invoke('shell:openPath'), // Intent-specific shell access only: files the main process produced,
showItemInFolder: invoke('shell:showItemInFolder'), // the guide's linked archive, and scheme-validated external links.
openProduced: invoke('shell:openProduced'),
revealLinkedArchive: invoke('shell:revealLinkedArchive'),
openExternal: invoke('shell:openExternal'),
}, },
app: { app: {
info: invoke('app:info'), info: invoke('app:info'),
+19 -1
View File
@@ -172,7 +172,7 @@ class StepForgeApp {
el('div.welcome', {}, el('div.welcome', {},
el('div.welcome-title', {}, el('div.welcome-title', {},
el('h1', {}, 'StepForge'), 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('div.welcome-actions', {},
el('button.welcome-btn.primary', { el('button.welcome-btn.primary', {
@@ -934,6 +934,24 @@ class StepForgeApp {
window.StepForgeApp = StepForgeApp; window.StepForgeApp = StepForgeApp;
// Links never navigate this window. http(s)/mailto links from guide content
// open externally via the scheme-validated main-process handler; internal
// step:/# links are handled by their own click handlers; everything else is
// inert. The main process additionally denies all navigation, so this is the
// user-experience half of a two-layer guarantee.
document.addEventListener('click', (e) => {
const anchor = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!anchor) return;
const href = anchor.getAttribute('href') || '';
if (/^(https?|mailto):/i.test(href)) {
e.preventDefault();
api.shell.openExternal({ url: href });
return;
}
// Ensure a stray href can never navigate the privileged window.
if (!href.startsWith('#')) e.preventDefault();
}, true);
function boot() { function boot() {
const app = new StepForgeApp(); const app = new StepForgeApp();
app.init(); app.init();
+7 -2
View File
@@ -151,6 +151,11 @@ class GuideEditor {
setActive(active) { setActive(active) {
this.active = Boolean(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) { setSettings(settings) {
@@ -1899,7 +1904,7 @@ class GuideEditor {
onPreview: async ({ format, options }) => { onPreview: async ({ format, options }) => {
const preview = await api.export.preview({ guideId: this.guideId, format, options }); const preview = await api.export.preview({ guideId: this.guideId, format, options });
if (preview && preview.file) { if (preview && preview.file) {
await api.shell.openPath({ target: preview.file }); // open in default viewer await api.shell.openProduced({ target: preview.file }); // open in default viewer
this.onToast('Preview opened (first steps only).'); this.onToast('Preview opened (first steps only).');
} }
return true; return true;
@@ -1935,7 +1940,7 @@ class GuideEditor {
else this.onToast('Could not save linked archive.', { error: true }); else this.onToast('Could not save linked archive.', { error: true });
}, },
onOpenArchive: async () => { onOpenArchive: async () => {
await api.shell.showItemInFolder({ target: this.guide.linkedSource.path }); await api.shell.revealLinkedArchive({ guideId: this.guideId });
}, },
}); });
} }
+302
View File
@@ -0,0 +1,302 @@
'use strict';
/**
* Privilege-boundary policy for every renderer surface.
*
* This module is deliberately loadable under plain Node (no electron
* require at module scope) so the decision logic is unit-testable:
* - which URLs count as our own app pages,
* - whether a navigation/popup may proceed (it may not),
* - which permission a renderer may be granted (display capture for the
* dedicated capture worker only),
* - whether an IPC event comes from the trusted main-window frame,
* - which external URLs may be handed to shell.openExternal,
* - which filesystem paths the renderer may ask the shell to open
* (only files the main process itself produced).
*/
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const RENDERER_DIR = path.join(__dirname, 'renderer');
const APP_PAGES = {
main: pathToFileURL(path.join(RENDERER_DIR, 'index.html')).href,
region: pathToFileURL(path.join(RENDERER_DIR, 'region.html')).href,
captureWorker: pathToFileURL(path.join(RENDERER_DIR, 'capture-worker.html')).href,
};
/** Normalize a URL for identity comparison: drop query/hash, decode path. */
function normalizeAppUrl(rawUrl) {
let url;
try {
url = new URL(String(rawUrl));
} catch {
return null;
}
if (url.protocol !== 'file:') return null;
let pathname;
try {
pathname = decodeURIComponent(url.pathname);
} catch {
pathname = url.pathname;
}
// Windows drive letters may differ in case between loadFile and senderFrame.
if (process.platform === 'win32') pathname = pathname.toLowerCase();
return pathname;
}
function isAppPageUrl(rawUrl, page) {
const expected = APP_PAGES[page];
if (!expected) return false;
const a = normalizeAppUrl(rawUrl);
const b = normalizeAppUrl(expected);
return a !== null && a === b;
}
/**
* Navigation policy: a privileged window may only ever stay on the exact
* page it was created with. Everything else — remote URLs, other local
* files, javascript:, data: — is denied.
*/
function navigationAllowed(targetUrl, page) {
return isAppPageUrl(targetUrl, page);
}
/**
* Permission policy for Electron sessions. Display capture (and the media
* permission that getDisplayMedia consults) is granted only to the dedicated
* hidden capture-worker page. Everything else is denied for everyone,
* including our own main window.
*/
function permissionAllowed(permission, requestingUrl) {
if (permission === 'media' || permission === 'display-capture') {
return isAppPageUrl(requestingUrl, 'captureWorker');
}
return false;
}
/**
* External-link policy: only well-formed http(s) and mailto URLs may reach
* shell.openExternal. Returns the normalized URL string or null.
*/
function validateExternalUrl(rawUrl) {
if (typeof rawUrl !== 'string' || rawUrl.length > 2048) return null;
let url;
try {
url = new URL(rawUrl);
} catch {
return null;
}
if (url.protocol === 'https:' || url.protocol === 'http:') {
if (!url.hostname) return null;
return url.href;
}
if (url.protocol === 'mailto:') return url.href;
return null;
}
/**
* IPC sender guard: an invoke event is trusted only when it originates from
* the current main window's top frame, and that frame is our index.html.
* Destroyed frames, subframes, other windows, and navigated-away frames are
* all rejected.
*/
function makeIpcSenderGuard({ getMainWebContents }) {
return function trustedSender(event) {
if (!event || typeof event !== 'object') return false;
const expected = getMainWebContents();
if (!expected || event.sender !== expected) return false;
const frame = event.senderFrame;
if (!frame) return false;
try {
if (frame.parent) return false; // top frame only
return isAppPageUrl(frame.url, 'main');
} catch {
// Accessing a disposed WebFrameMain throws — reject.
return false;
}
};
}
/**
* Cheap recursive payload budget: sums string lengths (and key counts) with
* early exit. Protects handlers from absurd payloads without JSON.stringify
* on hundred-megabyte image saves.
*/
function payloadWithinBudget(value, maxChars, depth = 0) {
let budget = maxChars;
const walk = (v, d) => {
if (budget < 0 || d > 16) return false;
if (v == null) return true;
const t = typeof v;
if (t === 'string') {
budget -= v.length;
return budget >= 0;
}
if (t === 'number' || t === 'boolean') {
budget -= 8;
return budget >= 0;
}
if (t === 'function' || t === 'symbol' || t === 'bigint') return false;
if (Array.isArray(v)) {
if (v.length > 100000) return false;
for (const item of v) if (!walk(item, d + 1)) return false;
return true;
}
if (t === 'object') {
const keys = Object.keys(v);
if (keys.length > 4096) return false;
for (const key of keys) {
budget -= key.length;
if (budget < 0) return false;
if (!walk(v[key], d + 1)) return false;
}
return true;
}
return false;
};
return walk(value, depth);
}
/** True for plain-object argument bags (what every IPC channel expects). */
function isPlainArgs(args) {
if (args === undefined || args === null) return true;
if (typeof args !== 'object' || Array.isArray(args)) return false;
const proto = Object.getPrototypeOf(args);
return proto === Object.prototype || proto === null;
}
// ---- field validators (used by main.js per-channel checks) ----------------
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const check = {
/** Guide/step/folder/template identifiers and snapshot/trash entry names:
* single path segment, no separators, no dot-dot, sane length. */
id(v) {
return typeof v === 'string' && ID_RE.test(v) && !v.includes('..');
},
optionalId(v) {
return v === null || v === undefined || check.id(v);
},
string(v, max = 4096) {
return typeof v === 'string' && v.length <= max;
},
optionalString(v, max = 4096) {
return v === null || v === undefined || check.string(v, max);
},
bool(v) {
return typeof v === 'boolean';
},
number(v, min = -1e15, max = 1e15) {
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
},
optionalNumber(v, min, max) {
return v === null || v === undefined || check.number(v, min, max);
},
oneOf(v, values) {
return values.includes(v);
},
base64(v, maxChars = 192 * 1024 * 1024) {
return typeof v === 'string' && v.length <= maxChars && /^[A-Za-z0-9+/=\r\n]*$/.test(v.slice(0, 4096));
},
optionalBase64(v, maxChars) {
return v === null || v === undefined || check.base64(v, maxChars);
},
/** Single filesystem name (template/snapshot/trash entries): no path
* separators, no traversal, no NUL, printable, bounded length. */
fileName(v, max = 160) {
return (
typeof v === 'string' &&
v.trim().length > 0 &&
v.length <= max &&
!/[/\\\0]/.test(v) &&
!v.includes('..') &&
v !== '.' &&
// eslint-disable-next-line no-control-regex
!/[\x00-\x1f]/.test(v)
);
},
optionalFileName(v, max) {
return v === null || v === undefined || check.fileName(v, max);
},
/** settings keyPath: dotted segments, no prototype-pollution segments. */
settingsKeyPath(v) {
if (typeof v !== 'string' || v.length === 0 || v.length > 200) return false;
const segments = v.split('.');
return segments.every(
(segment) =>
/^[A-Za-z0-9_-]+$/.test(segment) &&
!['__proto__', 'constructor', 'prototype'].includes(segment)
);
},
};
/**
* Registry of files the main process itself produced (export outputs,
* previews) and may therefore re-open on renderer request. Bounded LRU.
*/
class ProducedFiles {
constructor(limit = 256) {
this.limit = limit;
this.paths = new Set();
}
key(p) {
const resolved = path.resolve(String(p));
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
add(p) {
if (!p || typeof p !== 'string') return;
const key = this.key(p);
this.paths.delete(key);
this.paths.add(key);
while (this.paths.size > this.limit) {
this.paths.delete(this.paths.values().next().value);
}
}
has(p) {
if (!p || typeof p !== 'string') return false;
return this.paths.has(this.key(p));
}
}
/**
* Attach the navigation/popup policy to a BrowserWindow. `page` names the
* app page this window is allowed to display (key of APP_PAGES).
*/
function installWindowSecurity(win, page) {
const contents = win.webContents;
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
contents.on('will-navigate', (event, url) => {
if (!navigationAllowed(url, page)) event.preventDefault();
});
contents.on('will-frame-navigate', (details) => {
if (!navigationAllowed(details.url, page) && typeof details.preventDefault === 'function') {
details.preventDefault();
}
});
// Defense in depth: if a disallowed document somehow starts loading,
// never let it attach webviews either.
contents.on('will-attach-webview', (event) => event.preventDefault());
}
module.exports = {
APP_PAGES,
ProducedFiles,
check,
installWindowSecurity,
isAppPageUrl,
isPlainArgs,
makeIpcSenderGuard,
navigationAllowed,
normalizeAppUrl,
payloadWithinBudget,
permissionAllowed,
validateExternalUrl,
};
+3
View File
@@ -343,11 +343,14 @@ async function createElectronHost(onEvent) {
preload: path.join(__dirname, 'capture-worker-preload.js'), preload: path.join(__dirname, 'capture-worker-preload.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: true,
// The worker must keep sampling while hidden — throttling a hidden // The worker must keep sampling while hidden — throttling a hidden
// window is exactly the wrong default for a frame recorder. // window is exactly the wrong default for a frame recorder.
backgroundThrottling: false, backgroundThrottling: false,
}, },
}); });
// The worker may only display capture-worker.html; deny navigation/popups.
require('./security').installWindowSecurity(win, 'captureWorker');
const listener = (event, msg) => { const listener = (event, msg) => {
if (event.sender === win.webContents) onEvent(msg); if (event.sender === win.webContents) onEvent(msg);
}; };
+150 -23
View File
@@ -8,6 +8,7 @@ const {
DEFAULT_CAPTURE_TITLES, DEFAULT_CAPTURE_TITLES,
buildCaptureTitle, buildCaptureTitle,
normalizeOllamaHost, normalizeOllamaHost,
validateOllamaHost,
normalizeAiPatch, normalizeAiPatch,
buildAiPrompt, buildAiPrompt,
applyAiPatchToStep, applyAiPatchToStep,
@@ -74,9 +75,101 @@ class TextIntelService {
this.workerQueue = Promise.resolve(); this.workerQueue = Promise.resolve();
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng'); this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
this.modelCapabilityCache = new Map(); 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() { async shutdown() {
this.cancelInflight();
if (this.worker) { if (this.worker) {
try { try {
await this.worker.terminate(); await this.worker.terminate();
@@ -374,8 +467,19 @@ public static class Win32 {
if (!config.ollama.host) { if (!config.ollama.host) {
return { ok: false, reason: 'Set an Ollama host first.' }; return { ok: false, reason: 'Set an Ollama host first.' };
} }
const tagsUrl = new URL('/api/tags', `${config.ollama.host.replace(/\/+$/, '')}/`); let host;
const res = await this.fetch(tagsUrl, { method: 'GET' }); 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) { if (!res.ok) {
return { ok: false, reason: `Ollama check failed (${res.status})` }; 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 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 installed = config.ollama.model ? models.includes(config.ollama.model) : false;
const vision = installed ? await this.modelSupportsVision({ const vision = installed ? await this.modelSupportsVision({
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
}) : false; }) : false;
return { return {
@@ -391,7 +495,7 @@ public static class Win32 {
installed, installed,
vision, vision,
models, models,
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
}; };
} }
@@ -407,10 +511,9 @@ public static class Win32 {
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`); const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
let capabilities = []; let capabilities = [];
try { try {
const response = await this.fetch(url, { const response = await this.fetchJson(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { model: normalizedModel },
body: JSON.stringify({ model: normalizedModel }),
}); });
if (response.ok) { if (response.ok) {
const payload = await response.json(); const payload = await response.json();
@@ -439,12 +542,12 @@ public static class Win32 {
return fs.readFileSync(imagePath).toString('base64'); 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 url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const response = await this.fetch(url, { const response = await this.withConcurrency(() => this.fetchJson(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, guideId,
body: JSON.stringify({ body: {
model, model,
stream: false, stream: false,
messages: [ messages: [
@@ -452,8 +555,8 @@ public static class Win32 {
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
], ],
options: { temperature: 0.4 }, options: { temperature: 0.4 },
}), },
}); }));
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`); if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
const payload = await response.json(); const payload = await response.json();
const content = payload?.message?.content; const content = payload?.message?.content;
@@ -461,16 +564,16 @@ public static class Win32 {
return content.trim(); 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 url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const userMessage = { role: 'user', content: prompt }; const userMessage = { role: 'user', content: prompt };
if (Array.isArray(images) && images.length) { if (Array.isArray(images) && images.length) {
userMessage.images = images; userMessage.images = images;
} }
const response = await this.fetch(url, { const response = await this.withConcurrency(() => this.fetchJson(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, guideId,
body: JSON.stringify({ body: {
model, model,
stream: false, stream: false,
format: 'json', format: 'json',
@@ -481,8 +584,8 @@ public static class Win32 {
options: { options: {
temperature: 0.2, temperature: 0.2,
}, },
}), },
}); }));
if (!response.ok) { if (!response.ok) {
throw new Error(`Ollama request failed (${response.status})`); throw new Error(`Ollama request failed (${response.status})`);
} }
@@ -508,6 +611,13 @@ public static class Win32 {
if (!config.ollama.host || !config.ollama.model) { if (!config.ollama.host || !config.ollama.model) {
return { ok: false, reason: 'Configure Ollama host and model in Settings.' }; 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 guide = this.store.getGuide(guideId);
const step = this.store.getStep(guideId, stepId); const step = this.store.getStep(guideId, stepId);
@@ -522,10 +632,20 @@ public static class Win32 {
return { ok: false, reason: 'Block not found.' }; 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) const screenshotAttached = Boolean(screenshotBase64)
? await this.modelSupportsVision({ ? await this.modelSupportsVision({
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
}) })
: false; : false;
@@ -588,11 +708,12 @@ public static class Win32 {
}); });
const raw = await this.callOllama({ const raw = await this.callOllama({
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
prompt, prompt,
systemPrompt, systemPrompt,
images: screenshotAttached ? [screenshotBase64] : [], images: screenshotAttached ? [screenshotBase64] : [],
guideId,
}); });
const patch = normalizeAiPatch(raw); const patch = normalizeAiPatch(raw);
const updated = applyAiPatchToStep(step, patch, { target, blockId }); const updated = applyAiPatchToStep(step, patch, { target, blockId });
@@ -610,6 +731,12 @@ public static class Win32 {
if (!config.ollama.host || !config.ollama.model) { if (!config.ollama.host || !config.ollama.model) {
return { ok: false, reason: 'Configure Ollama host and model in Settings.' }; 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); const trimmed = normalizeWhitespace(text);
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' }; if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
@@ -628,7 +755,7 @@ public static class Win32 {
].filter((l) => l !== null).join('\n'); ].filter((l) => l !== null).join('\n');
const result = await this.callOllamaText({ const result = await this.callOllamaText({
host: config.ollama.host, host,
model: config.ollama.model, model: config.ollama.model,
prompt, prompt,
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.', systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
+23
View File
@@ -45,6 +45,13 @@ const DEFAULT_SETTINGS = {
// user is likely to click so the buffer holds frames of the now-visible // user is likely to click so the buffer holds frames of the now-visible
// screen rather than the just-dismissed app window. // screen rather than the just-dismissed app window.
postHideSettleMs: 150, 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: { editor: {
focusedViewDefaultForNewSteps: false, focusedViewDefaultForNewSteps: false,
@@ -52,6 +59,22 @@ const DEFAULT_SETTINGS = {
}, },
ai: { ai: {
enabled: false, 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: { ollama: {
host: 'http://127.0.0.1:11434', host: 'http://127.0.0.1:11434',
model: 'llama3.2:1b', model: 'llama3.2:1b',
+56
View File
@@ -538,6 +538,60 @@ function normalizeOllamaHost(host) {
return `http://${raw.replace(/\/+$/, '')}`; 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) { function normalizeAiLevel(level) {
const key = normalizeWhitespace(level).toLowerCase(); const key = normalizeWhitespace(level).toLowerCase();
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info'); return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
@@ -845,6 +899,8 @@ module.exports = {
buildCaptureTitle, buildCaptureTitle,
plainTextToHtml, plainTextToHtml,
normalizeOllamaHost, normalizeOllamaHost,
isLoopbackHost,
validateOllamaHost,
normalizeAiPatch, normalizeAiPatch,
buildAiPrompt, buildAiPrompt,
applyAiPatchToStep, applyAiPatchToStep,
+65
View File
@@ -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
View File
@@ -2,7 +2,7 @@
"name": "stepforge", "name": "stepforge",
"version": "0.3.2", "version": "0.3.2",
"buildVersion": "0.3.2.1", "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", "main": "app/main.js",
"author": "StepForge [email protected]", "author": "StepForge [email protected]",
"license": "MPL-2.0", "license": "MPL-2.0",
+195
View File
@@ -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\) \{/);
});
+218
View File
@@ -0,0 +1,218 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const security = require('../../app/security');
const MAIN_URL = security.APP_PAGES.main;
const WORKER_URL = security.APP_PAGES.captureWorker;
const REGION_URL = security.APP_PAGES.region;
const ROOT = path.resolve(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
// ---- navigation policy -----------------------------------------------------
test('navigation: a window may only stay on its own app page', () => {
assert.equal(security.navigationAllowed(MAIN_URL, 'main'), true);
assert.equal(security.navigationAllowed(`${MAIN_URL}?q=1#frag`, 'main'), true);
assert.equal(security.navigationAllowed(REGION_URL, 'region'), true);
// Hostile / cross-page navigations are all denied.
for (const target of [
'https://evil.example/phish.html',
'http://127.0.0.1:8080/',
'javascript:alert(1)',
'data:text/html,<script>1</script>',
'about:blank',
REGION_URL, // a *different* app page is still a denial for 'main'
'file:///etc/passwd',
'not a url',
'',
]) {
assert.equal(security.navigationAllowed(target, 'main'), false, `should deny ${target}`);
}
assert.equal(security.navigationAllowed(MAIN_URL, 'nonexistent-page'), false);
});
// ---- permission policy -----------------------------------------------------
test('permissions: display capture only for the capture worker, nothing else for anyone', () => {
assert.equal(security.permissionAllowed('display-capture', WORKER_URL), true);
assert.equal(security.permissionAllowed('media', WORKER_URL), true);
// The main window gets nothing, including display capture.
assert.equal(security.permissionAllowed('display-capture', MAIN_URL), false);
assert.equal(security.permissionAllowed('media', MAIN_URL), false);
// Everything else is denied even for the worker.
for (const permission of ['geolocation', 'notifications', 'clipboard-read', 'openExternal', 'fullscreen', 'pointerLock', 'hid', 'usb', 'serial']) {
assert.equal(security.permissionAllowed(permission, WORKER_URL), false, permission);
}
// Remote origins never get anything.
assert.equal(security.permissionAllowed('display-capture', 'https://evil.example/'), false);
assert.equal(security.permissionAllowed('media', undefined), false);
});
// ---- external URL policy ---------------------------------------------------
test('external links: only well-formed http(s)/mailto pass', () => {
assert.equal(security.validateExternalUrl('https://example.com/docs'), 'https://example.com/docs');
assert.equal(security.validateExternalUrl('http://example.com'), 'http://example.com/');
assert.match(security.validateExternalUrl('mailto:[email protected]'), /^mailto:/);
for (const url of [
'javascript:alert(1)',
'file:///etc/passwd',
'data:text/html,x',
'ftp://example.com/x',
'smb://server/share',
'chrome://settings',
'vbscript:x',
'https://',
'not a url',
123,
null,
`https://example.com/${'a'.repeat(3000)}`,
]) {
assert.equal(security.validateExternalUrl(url), null, `should reject ${String(url).slice(0, 60)}`);
}
});
// ---- IPC sender guard --------------------------------------------------------
function makeGuard(mainWc) {
return security.makeIpcSenderGuard({ getMainWebContents: () => mainWc });
}
test('ipc guard: accepts only the main window top frame on index.html', () => {
const wc = { id: 1 };
const guard = makeGuard(wc);
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: MAIN_URL } }), true);
});
test('ipc guard: rejects other windows, subframes, navigated and disposed frames', () => {
const wc = { id: 1 };
const otherWc = { id: 2 };
const guard = makeGuard(wc);
// Different webContents (popup, worker, region overlay).
assert.equal(guard({ sender: otherWc, senderFrame: { parent: null, url: MAIN_URL } }), false);
// Subframe of our own window.
assert.equal(guard({ sender: wc, senderFrame: { parent: {}, url: MAIN_URL } }), false);
// Frame that navigated somewhere else but kept the preload bridge.
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: 'https://evil.example/' } }), false);
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: WORKER_URL } }), false);
// Disposed frame accessor throws.
const disposed = {};
Object.defineProperty(disposed, 'parent', { get() { throw new Error('disposed'); } });
assert.equal(guard({ sender: wc, senderFrame: disposed }), false);
// Missing frame or window entirely.
assert.equal(guard({ sender: wc, senderFrame: null }), false);
assert.equal(makeGuard(null)({ sender: wc, senderFrame: { parent: null, url: MAIN_URL } }), false);
assert.equal(guard(null), false);
});
// ---- argument hygiene --------------------------------------------------------
test('args must be plain object bags', () => {
assert.equal(security.isPlainArgs({ a: 1 }), true);
assert.equal(security.isPlainArgs(Object.create(null)), true);
assert.equal(security.isPlainArgs(undefined), true);
assert.equal(security.isPlainArgs(null), true);
assert.equal(security.isPlainArgs([1, 2]), false);
assert.equal(security.isPlainArgs('x'), false);
class Weird {}
assert.equal(security.isPlainArgs(new Weird()), false);
});
test('payload budget rejects oversized and non-data values', () => {
assert.equal(security.payloadWithinBudget({ a: 'small' }, 1000), true);
assert.equal(security.payloadWithinBudget({ a: 'x'.repeat(2000) }, 1000), false);
assert.equal(security.payloadWithinBudget({ fn: () => 1 }, 1000), false);
// Deep nesting is cut off.
let deep = 'leaf';
for (let i = 0; i < 40; i += 1) deep = { deep };
assert.equal(security.payloadWithinBudget(deep, 100000), false);
// Numbers/booleans/null are fine.
assert.equal(security.payloadWithinBudget({ n: 5, b: true, z: null }, 1000), true);
});
test('field validators refuse traversal, separators, and pollution', () => {
const c = security.check;
assert.equal(c.id('guide-123_A.b'), true);
assert.equal(c.id('../../etc/passwd'), false);
assert.equal(c.id('a/b'), false);
assert.equal(c.id('a\\b'), false);
assert.equal(c.id(''), false);
assert.equal(c.id(42), false);
assert.equal(c.fileName('My snapshot 2026-07-03'), true);
assert.equal(c.fileName('..'), false);
assert.equal(c.fileName('a/../b'), false);
assert.equal(c.fileName('a/b'), false);
assert.equal(c.fileName('a\\b'), false);
assert.equal(c.fileName('a\0b'), false);
assert.equal(c.fileName(' '), false);
assert.equal(c.settingsKeyPath('capture.hotkeyCapture'), true);
assert.equal(c.settingsKeyPath('__proto__.polluted'), false);
assert.equal(c.settingsKeyPath('a.constructor.b'), false);
assert.equal(c.settingsKeyPath('a..b'), false);
assert.equal(c.settingsKeyPath(''), false);
assert.equal(c.base64('aGVsbG8=', 100), true);
assert.equal(c.base64('<script>', 100), false);
});
test('produced-files registry only re-opens what main created', () => {
const produced = new security.ProducedFiles(3);
produced.add('/tmp/exports/guide.pdf');
assert.equal(produced.has('/tmp/exports/guide.pdf'), true);
assert.equal(produced.has('/tmp/exports/../exports/guide.pdf'), true, 'path normalization');
assert.equal(produced.has('/etc/passwd'), false);
assert.equal(produced.has(null), false);
produced.add('/tmp/a');
produced.add('/tmp/b');
produced.add('/tmp/c'); // evicts guide.pdf (LRU bound)
assert.equal(produced.has('/tmp/exports/guide.pdf'), false);
assert.equal(produced.has('/tmp/c'), true);
});
// ---- source-level regression guards -----------------------------------------
test('main process never grants blanket permissions again', () => {
const src = read('app/main.js');
assert.doesNotMatch(src, /setPermissionCheckHandler\(\(\)\s*=>\s*true\)/);
assert.doesNotMatch(src, /cb\(true\)\)/);
assert.match(src, /security\.permissionAllowed/);
assert.match(src, /installWindowSecurity\(mainWindow, 'main'\)/);
});
test('no generic shell path channels remain on the bridge', () => {
const preload = read('app/preload.js');
assert.doesNotMatch(preload, /shell:openPath/);
assert.doesNotMatch(preload, /shell:showItemInFolder/);
assert.match(preload, /shell:openProduced/);
assert.match(preload, /shell:openExternal/);
});
test('every renderer window is created sandboxed', () => {
for (const file of ['app/main.js', 'app/capture.js', 'app/stream-backend.js']) {
const src = read(file);
const created = src.match(/new BrowserWindow\(/g) || [];
const sandboxed = src.match(/sandbox: true/g) || [];
assert.ok(created.length > 0, `${file} should create a window`);
assert.equal(
sandboxed.length,
created.length,
`${file}: every BrowserWindow must set sandbox: true (${sandboxed.length}/${created.length})`
);
}
});