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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`):

Expand Down
67 changes: 63 additions & 4 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -1053,6 +1103,12 @@ ipcMain.handle('app:has-token', async (): Promise<boolean> => {
}
})

ipcMain.handle('app:set-input-caps', async (_e, caps: Partial<InputCaps>): Promise<InputCaps> => {
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.
Expand Down Expand Up @@ -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<boolean> => {
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')

Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
62 changes: 62 additions & 0 deletions src/pages/SettingsPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
138 changes: 137 additions & 1 deletion src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -22,11 +32,43 @@ async function saveApiKey(key: string): Promise<void> {
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 }
}
Expand All @@ -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 = [
Expand Down Expand Up @@ -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 (
<div className={styles.page}>
<div className={styles.pageTitle}>SETTINGS</div>
Expand Down Expand Up @@ -319,6 +384,77 @@ function SettingsPage({ settings, onChange }: Props) {
</button>
</div>

{/* Input Limits */}
<div className={styles.panel}>
<div className={styles.sectionTitle}>LIMITS</div>
<div className={styles.description}>
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.
</div>

<div className={styles.limitsGrid}>
<div className={styles.limitRow}>
<div className={styles.limitInfo}>
<div className={styles.limitLabel}>Summary max length</div>
<div className={styles.limitHint}>Range: {SUMMARY_LIMIT_RANGE.min.toLocaleString()}–{SUMMARY_LIMIT_RANGE.max.toLocaleString()} chars</div>
</div>
<div className={styles.limitInputWrap}>
<input
className={styles.limitInput}
type="number"
min={SUMMARY_LIMIT_RANGE.min}
max={SUMMARY_LIMIT_RANGE.max}
value={settings.summaryMaxLength}
onChange={(e) => updateSummaryLimit(e.target.value)}
/>
<span className={styles.limitUnit}>chars</span>
</div>
</div>

<div className={styles.limitRow}>
<div className={styles.limitInfo}>
<div className={styles.limitLabel}>Notes max length</div>
<div className={styles.limitHint}>Range: {NOTES_LIMIT_RANGE.min.toLocaleString()}–{NOTES_LIMIT_RANGE.max.toLocaleString()} chars</div>
</div>
<div className={styles.limitInputWrap}>
<input
className={styles.limitInput}
type="number"
min={NOTES_LIMIT_RANGE.min}
max={NOTES_LIMIT_RANGE.max}
value={settings.notesMaxLength}
onChange={(e) => updateNotesLimit(e.target.value)}
/>
<span className={styles.limitUnit}>chars</span>
</div>
</div>

<div className={styles.limitRow}>
<div className={styles.limitInfo}>
<div className={styles.limitLabel}>Skill file max size</div>
<div className={styles.limitHint}>
Range: {formatBytes(SKILL_SIZE_LIMIT_RANGE.min)}–{formatBytes(SKILL_SIZE_LIMIT_RANGE.max)}
</div>
</div>
<div className={styles.limitInputWrap}>
<input
className={styles.limitInput}
type="number"
min={Math.round(SKILL_SIZE_LIMIT_RANGE.min / 1024)}
max={Math.round(SKILL_SIZE_LIMIT_RANGE.max / 1024)}
value={Math.round(settings.skillFileMaxBytes / 1024)}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value, 10)
if (Number.isNaN(parsed)) return
updateSkillSizeLimit(String(parsed * 1024))
}}
/>
<span className={styles.limitUnit}>KB</span>
</div>
</div>
</div>
</div>

{/* Reset */}
<div className={styles.panel}>
<div className={styles.sectionTitle}>RESET</div>
Expand Down
Loading