diff --git a/README.md b/README.md index fac80c0..b0c68b4 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ GridWatch reads exclusively from local files — no network requests are made ex - **URL restriction** — `shell.openExternal` limited to HTTP(S) URLs only - **Navigation guards** — `will-navigate` and `setWindowOpenHandler` prevent the Electron window from being redirected to external origins - **Token isolation** — the GitHub PAT never leaves the main process. The renderer only knows whether a token exists (`hasToken`); API calls that require authentication are made entirely within main, keeping the token out of the renderer's JavaScript heap -- **Input size limits** — freeform IPC inputs are capped to prevent a renderer bug from writing unbounded data to disk. Session summaries are limited to 1,000 characters, session notes to 100,000 characters, and skill file content to 512KB. Skill filenames are restricted to `.md` extensions only +- **Input size limits** — freeform IPC inputs are capped to prevent a renderer bug from writing unbounded data to disk. Session summary, session notes, and skill file caps are configurable from Settings (with safe ranges and defaults matching previous releases). Skill filenames are restricted to `.md` extensions only - **HTTP response caps** — outbound HTTP responses (update checks, GitHub Models API) are capped at 1MB. If a response exceeds this limit, the stream is destroyed immediately. This prevents a compromised or misbehaving endpoint from exhausting main process memory - **MCP tool discovery** — GridWatch reads your `~/.copilot/mcp-config.json` and briefly spawns each configured local MCP server to query its tool list via JSON-RPC. GridWatch does not install or modify MCP servers — it only reads what you have already configured. Commands with shell metacharacters are rejected as a safety measure - **Prototype pollution guards** — object property keys sourced from external config (e.g. MCP server names, LSP server names) are validated against known dangerous keys (`__proto__`, `constructor`, `prototype`) and use `hasOwnProperty` checks to prevent prototype chain corruption @@ -278,6 +278,9 @@ The Settings page provides UI preferences and Copilot CLI configuration manageme | Font Size | Base text size independent of scale | 10px – 16px | | Density | Padding and spacing between elements | Compact / Default / Comfortable | | Theme | Colour scheme | The Grid (cyan/blue) / Programs (red) | +| Summary max length | Maximum session summary length passed over IPC | 100 – 5,000 chars (default 1,000) | +| Notes max length | Maximum session notes length passed over IPC | 10,000 – 500,000 chars (default 100,000) | +| Skill file max size | Maximum skill markdown file size passed over IPC | 64KB – 2MB (default 512KB) | **Copilot CLI configuration** (stored in `~/.copilot/config.json`): diff --git a/electron/main.ts b/electron/main.ts index 0ca27f2..fc7253a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -125,9 +125,59 @@ function isPathWithin(filePath: string, parentDir: string): boolean { } const MAX_TRANSFER_SIZE = 1_048_576 // 1 MB +const MAX_SKILL_DESCRIPTION_LENGTH = 10_000 const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/ +interface InputCaps { + summaryMaxLength: number + notesMaxLength: number + skillFileMaxBytes: number +} + +const INPUT_CAP_BOUNDS = { + summaryMaxLength: { min: 100, max: 5_000 }, + notesMaxLength: { min: 10_000, max: 500_000 }, + skillFileMaxBytes: { min: 65_536, max: 2_097_152 }, +} as const + +const DEFAULT_INPUT_CAPS: InputCaps = { + summaryMaxLength: 1_000, + notesMaxLength: 100_000, + skillFileMaxBytes: 524_288, +} + +let inputCaps: InputCaps = { ...DEFAULT_INPUT_CAPS } + +function clampInputCap(value: unknown, min: number, max: number, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback + const rounded = Math.round(value) + return Math.min(max, Math.max(min, rounded)) +} + +function normaliseInputCaps(caps: Partial): InputCaps { + return { + summaryMaxLength: clampInputCap( + caps.summaryMaxLength, + INPUT_CAP_BOUNDS.summaryMaxLength.min, + INPUT_CAP_BOUNDS.summaryMaxLength.max, + inputCaps.summaryMaxLength, + ), + notesMaxLength: clampInputCap( + caps.notesMaxLength, + INPUT_CAP_BOUNDS.notesMaxLength.min, + INPUT_CAP_BOUNDS.notesMaxLength.max, + inputCaps.notesMaxLength, + ), + skillFileMaxBytes: clampInputCap( + caps.skillFileMaxBytes, + INPUT_CAP_BOUNDS.skillFileMaxBytes.min, + INPUT_CAP_BOUNDS.skillFileMaxBytes.max, + inputCaps.skillFileMaxBytes, + ), + } +} + // ── IPC response cache ──────────────────────────────────────────────────────── let sessionsCache: { data: SessionData[]; timestamp: number } | null = null const CACHE_TTL = 15_000 // 15 seconds — aligned with renderer refresh @@ -663,7 +713,7 @@ ipcMain.handle('sessions:rename', async (_event, sessionId: string, newSummary: // Sanitise newlines to prevent YAML key injection const safeSummary = newSummary.replace(/[\r\n]+/g, ' ').trim() - if (!safeSummary || safeSummary.length > 1000) return false + if (!safeSummary || safeSummary.length > inputCaps.summaryMaxLength) return false const raw = fs.readFileSync(yamlPath, 'utf-8') let updated: string @@ -767,7 +817,7 @@ ipcMain.handle('sessions:set-notes', async (_e, sessionId: string, notes: string invalidateSessionsCache() try { if (!isValidSessionId(sessionId)) return false - const safeNotes = typeof notes === 'string' ? notes.slice(0, 100_000) : '' + const safeNotes = typeof notes === 'string' ? notes.slice(0, inputCaps.notesMaxLength) : '' const sessionDir = path.join(os.homedir(), '.copilot', 'session-state', sessionId) if (!fs.existsSync(sessionDir)) return false const metaFile = path.join(sessionDir, 'gridwatch.json') @@ -1053,6 +1103,12 @@ ipcMain.handle('app:has-token', async (): Promise => { } }) +ipcMain.handle('app:set-input-caps', async (_e, caps: Partial): Promise => { + if (!caps || typeof caps !== 'object') return inputCaps + inputCaps = normaliseInputCaps(caps) + return inputCaps +}) + // ── IPC: insights:analyse ────────────────────────────────────────────────── const INSIGHTS_SYSTEM_PROMPT = `You are an expert prompt engineering coach. You analyse prompts sent to GitHub Copilot CLI and provide actionable feedback. @@ -1277,7 +1333,7 @@ ipcMain.handle('skills:get-file', async (_e, skillName: string, fileName: string ipcMain.handle('skills:save-file', async (_e, skillName: string, fileName: string, content: string): Promise => { if (!isValidSkillName(skillName) || !fileName || typeof content !== 'string') return false - if (!fileName.endsWith('.md') || content.length > 524_288) return false + if (!fileName.endsWith('.md') || content.length > inputCaps.skillFileMaxBytes) return false const enabledDir = path.join(os.homedir(), '.copilot', 'skills') const disabledDir = path.join(os.homedir(), '.copilot', 'skills-disabled') @@ -1299,7 +1355,10 @@ ipcMain.handle('skills:create', async (_e, name: string, description: string): P const disabledDir = path.join(os.homedir(), '.copilot', 'skills-disabled', name) if (fs.existsSync(disabledDir)) return { ok: false, error: 'A disabled skill with this name already exists.' } - const safeDescription = (description || 'TODO: Add a description').replace(/[\r\n]+/g, ' ').trim() + const safeDescription = (description || '') + .replace(/[\r\n]+/g, ' ') + .trim() + .slice(0, MAX_SKILL_DESCRIPTION_LENGTH) || 'TODO: Add a description' const template = `---\nname: ${name}\ndescription: ${safeDescription}\n---\n\n# ${name}\n\nAdd your skill instructions here.\n` try { diff --git a/electron/preload.ts b/electron/preload.ts index 2d1edc0..e076e0f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -33,6 +33,8 @@ contextBridge.exposeInMainWorld('gridwatchAPI', { openItemFolder: (type: string, name: string) => ipcRenderer.invoke('app:open-item-folder', type, name), saveToken: (token: string) => ipcRenderer.invoke('app:save-token', token), hasToken: () => ipcRenderer.invoke('app:has-token'), + setInputCaps: (caps: { summaryMaxLength: number; notesMaxLength: number; skillFileMaxBytes: number }) => + ipcRenderer.invoke('app:set-input-caps', caps), analyseSession: (messages: string[]) => ipcRenderer.invoke('insights:analyse', messages), diff --git a/src/pages/SettingsPage.module.css b/src/pages/SettingsPage.module.css index 58075b1..eb6bd36 100644 --- a/src/pages/SettingsPage.module.css +++ b/src/pages/SettingsPage.module.css @@ -338,3 +338,65 @@ padding: 0 4px; margin-left: auto; } + +/* ── Limits ───────────────────────────────────── */ +.limitsGrid { + display: flex; + flex-direction: column; + gap: 10px; +} + +.limitRow { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 10px; + background: rgba(0, 0, 0, 0.22); + border: 1px solid var(--tron-border); +} + +.limitInfo { + min-width: 0; +} + +.limitLabel { + font-size: calc(11 * var(--font-scale, 1) * 1px); + color: var(--tron-text); + letter-spacing: 0.8px; + margin-bottom: 2px; +} + +.limitHint { + font-size: calc(10 * var(--font-scale, 1) * 1px); + color: var(--tron-text-dim); + letter-spacing: 0.4px; +} + +.limitInputWrap { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.limitInput { + width: 108px; + background: var(--tron-bg); + border: 1px solid var(--tron-border); + color: var(--tron-text); + font-family: inherit; + font-size: calc(11 * var(--font-scale, 1) * 1px); + padding: 6px 8px; + outline: none; +} + +.limitInput:focus { + border-color: var(--tron-cyan); +} + +.limitUnit { + font-size: calc(10 * var(--font-scale, 1) * 1px); + color: var(--tron-text-dim); + letter-spacing: 1px; +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index ca2dd68..b08f6b8 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -7,13 +7,23 @@ export interface AppSettings { fontSize: number // 10 – 16 (px, overrides body font-size) spacing: 'compact' | 'default' | 'comfortable' theme: 'grid' | 'programs' + summaryMaxLength: number + notesMaxLength: number + skillFileMaxBytes: number } +const SUMMARY_LIMIT_RANGE = { min: 100, max: 5_000, defaultValue: 1_000 } as const +const NOTES_LIMIT_RANGE = { min: 10_000, max: 500_000, defaultValue: 100_000 } as const +const SKILL_SIZE_LIMIT_RANGE = { min: 65_536, max: 2_097_152, defaultValue: 524_288 } as const + export const DEFAULT_SETTINGS: AppSettings = { zoom: 1.0, fontSize: 13, spacing: 'default', theme: 'grid', + summaryMaxLength: SUMMARY_LIMIT_RANGE.defaultValue, + notesMaxLength: NOTES_LIMIT_RANGE.defaultValue, + skillFileMaxBytes: SKILL_SIZE_LIMIT_RANGE.defaultValue, } const STORAGE_KEY = 'gridwatch-settings' @@ -22,11 +32,43 @@ async function saveApiKey(key: string): Promise { try { await window.gridwatchAPI.saveToken(key) } catch { /* ignore */ } } +function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback + const rounded = Math.round(value) + return Math.min(max, Math.max(min, rounded)) +} + +function formatBytes(bytes: number): string { + if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(2)} MB` + return `${Math.round(bytes / 1024)} KB` +} + export function loadSettings(): AppSettings { try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return { ...DEFAULT_SETTINGS } - return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) } + const merged = { ...DEFAULT_SETTINGS, ...JSON.parse(raw) } as AppSettings + return { + ...merged, + summaryMaxLength: clampNumber( + merged.summaryMaxLength, + SUMMARY_LIMIT_RANGE.min, + SUMMARY_LIMIT_RANGE.max, + DEFAULT_SETTINGS.summaryMaxLength, + ), + notesMaxLength: clampNumber( + merged.notesMaxLength, + NOTES_LIMIT_RANGE.min, + NOTES_LIMIT_RANGE.max, + DEFAULT_SETTINGS.notesMaxLength, + ), + skillFileMaxBytes: clampNumber( + merged.skillFileMaxBytes, + SKILL_SIZE_LIMIT_RANGE.min, + SKILL_SIZE_LIMIT_RANGE.max, + DEFAULT_SETTINGS.skillFileMaxBytes, + ), + } } catch { return { ...DEFAULT_SETTINGS } } @@ -45,6 +87,11 @@ export function applySettings(s: AppSettings): void { document.documentElement.style.setProperty('--font-scale', String(s.fontSize / 13)) document.documentElement.setAttribute('data-density', s.spacing) document.documentElement.setAttribute('data-theme', s.theme ?? 'grid') + window.gridwatchAPI.setInputCaps({ + summaryMaxLength: s.summaryMaxLength, + notesMaxLength: s.notesMaxLength, + skillFileMaxBytes: s.skillFileMaxBytes, + }).catch(() => {}) } const ZOOM_PRESETS = [ @@ -135,6 +182,24 @@ function SettingsPage({ settings, onChange }: Props) { update({ ...DEFAULT_SETTINGS }) } + const updateSummaryLimit = (value: string) => { + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) return + update({ summaryMaxLength: clampNumber(parsed, SUMMARY_LIMIT_RANGE.min, SUMMARY_LIMIT_RANGE.max, settings.summaryMaxLength) }) + } + + const updateNotesLimit = (value: string) => { + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) return + update({ notesMaxLength: clampNumber(parsed, NOTES_LIMIT_RANGE.min, NOTES_LIMIT_RANGE.max, settings.notesMaxLength) }) + } + + const updateSkillSizeLimit = (value: string) => { + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) return + update({ skillFileMaxBytes: clampNumber(parsed, SKILL_SIZE_LIMIT_RANGE.min, SKILL_SIZE_LIMIT_RANGE.max, settings.skillFileMaxBytes) }) + } + return (
SETTINGS
@@ -319,6 +384,77 @@ function SettingsPage({ settings, onChange }: Props) {
+ {/* Input Limits */} +
+
LIMITS
+
+ These caps protect against unbounded disk writes from malformed renderer or IPC payloads. Defaults match prior releases. + The 1MB HTTP response cap remains fixed as a hard security boundary. +
+ +
+
+
+
Summary max length
+
Range: {SUMMARY_LIMIT_RANGE.min.toLocaleString()}–{SUMMARY_LIMIT_RANGE.max.toLocaleString()} chars
+
+
+ updateSummaryLimit(e.target.value)} + /> + chars +
+
+ +
+
+
Notes max length
+
Range: {NOTES_LIMIT_RANGE.min.toLocaleString()}–{NOTES_LIMIT_RANGE.max.toLocaleString()} chars
+
+
+ updateNotesLimit(e.target.value)} + /> + chars +
+
+ +
+
+
Skill file max size
+
+ Range: {formatBytes(SKILL_SIZE_LIMIT_RANGE.min)}–{formatBytes(SKILL_SIZE_LIMIT_RANGE.max)} +
+
+
+ { + const parsed = Number.parseInt(e.target.value, 10) + if (Number.isNaN(parsed)) return + updateSkillSizeLimit(String(parsed * 1024)) + }} + /> + KB +
+
+
+
+ {/* Reset */}
RESET
diff --git a/src/types/global.d.ts b/src/types/global.d.ts index d6effc1..0c85848 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -48,6 +48,15 @@ declare global { openItemFolder: (type: 'session' | 'skill' | 'mcp' | 'agent' | 'lsp' | 'dirs', name: string) => Promise; saveToken: (token: string) => Promise; hasToken: () => Promise; + setInputCaps: (caps: { + summaryMaxLength: number; + notesMaxLength: number; + skillFileMaxBytes: number; + }) => Promise<{ + summaryMaxLength: number; + notesMaxLength: number; + skillFileMaxBytes: number; + }>; analyseSession: (messages: string[]) => Promise; // Skills