From 736c27ed9132c86d4662206cf5b126656307765f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 09:15:35 +0000 Subject: [PATCH 1/3] Initial plan From e19dc2029e3298366d7db13163b68635cdfd664e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 09:25:18 +0000 Subject: [PATCH 2/3] feat: implement Allowed Tools management (issue #24) Agent-Logs-Url: https://github.com/faesel/gridwatch/sessions/faac5005-ac9e-46c3-9b0a-d5ad4b09e0ae Co-authored-by: faesel <6319576+faesel@users.noreply.github.com> --- electron/main.ts | 137 ++++++++++++++++++++ electron/preload.ts | 7 ++ package-lock.json | 4 +- src/pages/SessionsPage.module.css | 110 +++++++++++++++++ src/pages/SessionsPage.tsx | 104 +++++++++++++++- src/pages/SettingsPage.module.css | 199 ++++++++++++++++++++++++++++++ src/pages/SettingsPage.tsx | 170 +++++++++++++++++++++++++ src/types/global.d.ts | 6 + src/types/tools.ts | 5 + 9 files changed, 737 insertions(+), 5 deletions(-) create mode 100644 src/types/tools.ts diff --git a/electron/main.ts b/electron/main.ts index 0ca27f2..aab2340 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,6 +11,7 @@ import type { CustomAgentData } from '../src/types/agent' import type { McpServerData, McpEnvVar, McpTool } from '../src/types/mcp' import type { LspServerData } from '../src/types/lsp' import type { AllowedDirectory } from '../src/types/dirs' +import type { ProjectToolPermissions } from '../src/types/tools' // Must be set before app is ready so the OS picks it up for dock/taskbar tooltip app.setName('GridWatch') @@ -2141,3 +2142,139 @@ ipcMain.handle('dirs:remove', async (_e, dirPath: string): Promise<{ ok: boolean return { ok: false, error: (err as Error).message } } }) + +// ── Tool Permissions IPC ───────────────────────────────────────────────────── + +const permissionsConfigPath = path.join(os.homedir(), '.copilot', 'permissions-config.json') +let toolPermissionsCache: { data: ProjectToolPermissions[]; timestamp: number } | null = null +const TOOL_PERMS_CACHE_TTL = 10_000 + +/** Max length for a tool spec string */ +const MAX_TOOL_SPEC_LENGTH = 512 + +/** + * Valid tool specs: + * write + * shell(COMMAND) + * IDENTIFIER + * IDENTIFIER(ARGUMENT) + * where IDENTIFIER starts with a letter and contains letters, digits, hyphens, underscores, + * and ARGUMENT may not contain parentheses (prevents unbalanced nesting). + */ +const TOOL_SPEC_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*(\([^)(]{0,200}\))?$/ + +function isValidToolSpec(spec: unknown): spec is string { + if (typeof spec !== 'string') return false + if (!spec || spec.length > MAX_TOOL_SPEC_LENGTH) return false + return TOOL_SPEC_PATTERN.test(spec) +} + +function readPermissionsConfig(): Record { + if (!fs.existsSync(permissionsConfigPath)) return {} + try { + const raw = fs.readFileSync(permissionsConfigPath, 'utf-8') + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + return parsed as Record + } catch { + return {} + } +} + +function writePermissionsConfig(config: Record): void { + fs.writeFileSync(permissionsConfigPath, JSON.stringify(config, null, 2) + '\n', 'utf-8') +} + +ipcMain.handle('tools:get-permissions', async (): Promise => { + if (toolPermissionsCache && Date.now() - toolPermissionsCache.timestamp < TOOL_PERMS_CACHE_TTL) { + return toolPermissionsCache.data + } + try { + const config = readPermissionsConfig() + const data: ProjectToolPermissions[] = Object.entries(config) + .filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)) + .map(([projectPath, entry]) => ({ + projectPath, + allowedTools: Array.isArray(entry?.allowedTools) + ? entry.allowedTools.filter((t): t is string => isValidToolSpec(t)) + : [], + })) + toolPermissionsCache = { data, timestamp: Date.now() } + return data + } catch { + return [] + } +}) + +ipcMain.handle( + 'tools:allow-tool', + async (_e, projectPath: string, toolSpec: string): Promise<{ ok: boolean; error?: string }> => { + try { + if (!isValidDirPath(projectPath)) { + return { ok: false, error: 'Invalid project path' } + } + if (PROTOTYPE_POLLUTION_KEYS.has(projectPath)) { + return { ok: false, error: 'Invalid project path' } + } + if (!isValidToolSpec(toolSpec)) { + return { ok: false, error: 'Invalid tool specification' } + } + if (PROTOTYPE_POLLUTION_KEYS.has(toolSpec)) { + return { ok: false, error: 'Invalid tool specification' } + } + + const config = readPermissionsConfig() + const entry = config[projectPath] ?? {} + const existing = Array.isArray(entry.allowedTools) ? entry.allowedTools : [] + if (existing.includes(toolSpec)) { + return { ok: false, error: 'Tool is already in the allowed list' } + } + config[projectPath] = { ...entry, allowedTools: [...existing, toolSpec] } + writePermissionsConfig(config) + toolPermissionsCache = null + return { ok: true } + } catch (err) { + return { ok: false, error: (err as Error).message } + } + }, +) + +ipcMain.handle( + 'tools:remove-tool', + async (_e, projectPath: string, toolSpec: string): Promise<{ ok: boolean; error?: string }> => { + try { + if (!isValidDirPath(projectPath)) { + return { ok: false, error: 'Invalid project path' } + } + if (PROTOTYPE_POLLUTION_KEYS.has(projectPath)) { + return { ok: false, error: 'Invalid project path' } + } + if (!isValidToolSpec(toolSpec)) { + return { ok: false, error: 'Invalid tool specification' } + } + if (PROTOTYPE_POLLUTION_KEYS.has(toolSpec)) { + return { ok: false, error: 'Invalid tool specification' } + } + + const config = readPermissionsConfig() + const entry = config[projectPath] + if (!entry) return { ok: false, error: 'Project not found in permissions' } + const existing = Array.isArray(entry.allowedTools) ? entry.allowedTools : [] + const next = existing.filter((t) => t !== toolSpec) + if (next.length === existing.length) { + return { ok: false, error: 'Tool not found in allowed list' } + } + if (next.length === 0) { + // Remove the project key entirely when no tools remain + delete config[projectPath] + } else { + config[projectPath] = { ...entry, allowedTools: next } + } + writePermissionsConfig(config) + toolPermissionsCache = null + return { ok: true } + } catch (err) { + return { ok: false, error: (err as Error).message } + } + }, +) diff --git a/electron/preload.ts b/electron/preload.ts index 2d1edc0..0009171 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -79,6 +79,13 @@ contextBridge.exposeInMainWorld('gridwatchAPI', { addAllowedDir: () => ipcRenderer.invoke('dirs:add'), removeAllowedDir: (dirPath: string) => ipcRenderer.invoke('dirs:remove', dirPath), + // Tool Permissions + getToolPermissions: () => ipcRenderer.invoke('tools:get-permissions'), + allowTool: (projectPath: string, toolSpec: string) => + ipcRenderer.invoke('tools:allow-tool', projectPath, toolSpec), + removeToolPermission: (projectPath: string, toolSpec: string) => + ipcRenderer.invoke('tools:remove-tool', projectPath, toolSpec), + // Window controls getPlatform: () => ipcRenderer.invoke('app:get-platform'), windowMinimize: () => ipcRenderer.invoke('window:minimize'), diff --git a/package-lock.json b/package-lock.json index d81a4f2..4440b09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gridwatch", - "version": "0.30.0", + "version": "0.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gridwatch", - "version": "0.30.0", + "version": "0.31.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/src/pages/SessionsPage.module.css b/src/pages/SessionsPage.module.css index 24b590d..255d12a 100644 --- a/src/pages/SessionsPage.module.css +++ b/src/pages/SessionsPage.module.css @@ -625,6 +625,116 @@ border: 1px solid var(--tron-cyan); color: var(--tron-cyan); letter-spacing: 0.5px; + background: none; + cursor: pointer; + font-family: inherit; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; +} + +.toolBadge:hover { + background: rgba(0, 245, 255, 0.08); + box-shadow: 0 0 6px rgba(0, 245, 255, 0.2); +} + +.toolBadgeAllowed { + background: rgba(0, 245, 255, 0.1) !important; + border-color: var(--tron-cyan) !important; + box-shadow: 0 0 6px rgba(0, 245, 255, 0.25); + cursor: default; +} + +.toolBadgeAllowed:hover { + background: rgba(0, 245, 255, 0.1) !important; + box-shadow: 0 0 6px rgba(0, 245, 255, 0.25); +} + +.toolUndoToast { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 10px; + margin-bottom: 8px; + background: rgba(0, 245, 255, 0.06); + border: 1px solid rgba(0, 245, 255, 0.3); + color: var(--tron-cyan); + font-size: calc(11 * var(--font-scale, 1) * 1px); + letter-spacing: 0.5px; +} + +.toolUndoBtn { + background: none; + border: 1px solid rgba(0, 245, 255, 0.4); + color: var(--tron-cyan); + cursor: pointer; + font-family: inherit; + font-size: calc(10 * var(--font-scale, 1) * 1px); + font-weight: 700; + letter-spacing: 1px; + padding: 3px 10px; + flex-shrink: 0; + transition: background 0.15s; +} + +.toolUndoBtn:hover { + background: rgba(0, 245, 255, 0.12); +} + +.toolConfirmBox { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + margin-bottom: 8px; + background: rgba(0, 128, 255, 0.06); + border: 1px solid rgba(0, 128, 255, 0.3); + color: var(--tron-text); + font-size: calc(11 * var(--font-scale, 1) * 1px); + letter-spacing: 0.5px; +} + +.toolConfirmActions { + display: flex; + gap: 8px; +} + +.toolConfirmBtn { + background: rgba(0, 128, 255, 0.1); + border: 1px solid var(--tron-blue); + color: var(--tron-blue); + cursor: pointer; + font-family: inherit; + font-size: calc(10 * var(--font-scale, 1) * 1px); + font-weight: 700; + letter-spacing: 1px; + padding: 4px 14px; + transition: background 0.15s; +} + +.toolConfirmBtn:hover { + background: rgba(0, 128, 255, 0.2); +} + +.toolConfirmBtn:disabled { + opacity: 0.4; + cursor: default; +} + +.toolConfirmCancelBtn { + background: none; + border: 1px solid var(--tron-border); + color: var(--tron-text-dim); + cursor: pointer; + font-family: inherit; + font-size: calc(10 * var(--font-scale, 1) * 1px); + letter-spacing: 1px; + padding: 4px 12px; + transition: all 0.15s; +} + +.toolConfirmCancelBtn:hover { + border-color: var(--tron-text-dim); + color: var(--tron-text); } .rewindItem { diff --git a/src/pages/SessionsPage.tsx b/src/pages/SessionsPage.tsx index 55451a3..c6895a5 100644 --- a/src/pages/SessionsPage.tsx +++ b/src/pages/SessionsPage.tsx @@ -5,6 +5,7 @@ import styles from './SessionsPage.module.css' const PAGE_SIZE = 20 const SEARCH_DEBOUNCE_MS = 250 +const UNDO_TOAST_DURATION_MS = 5000 interface Props { sessions: SessionSummary[] @@ -89,6 +90,13 @@ function SessionsPage({ sessions, onSessionRenamed }: Props) { const [expandedCompactions, setExpandedCompactions] = useState>(new Set()) const msgRefs = useRef>(new Map()) + // Tool permissions for the selected session's project + const [allowedToolsForSession, setAllowedToolsForSession] = useState>(new Set()) + const [confirmAllowTool, setConfirmAllowTool] = useState(null) + const [allowingTool, setAllowingTool] = useState(false) + const [undoTool, setUndoTool] = useState(null) + const undoTimerRef = useRef | null>(null) + const measureOverflow = useCallback((key: string, el: HTMLDivElement | null) => { if (el) { msgRefs.current.set(key, el) @@ -117,6 +125,11 @@ function SessionsPage({ sessions, onSessionRenamed }: Props) { return () => clearTimeout(timer) }, [search]) + // Clean up the undo timer on unmount to prevent state updates after unmount + useEffect(() => { + return () => { if (undoTimerRef.current) clearTimeout(undoTimerRef.current) } + }, []) + // Sync localTags, localNotes, and transfers when selected session changes useEffect(() => { setLocalTags(selectedSession?.tags ?? []) @@ -128,6 +141,10 @@ function SessionsPage({ sessions, onSessionRenamed }: Props) { setOverflowingMsgs(new Set()) setExpandedCompactions(new Set()) setSessionDetail(null) + setConfirmAllowTool(null) + setUndoTool(null) + if (undoTimerRef.current) clearTimeout(undoTimerRef.current) + setAllowedToolsForSession(new Set()) if (selectedSession) { window.gridwatchAPI.listTransfers(selectedSession.id).then(setTransfers) // Lazy-load expensive detail fields @@ -136,6 +153,13 @@ function SessionsPage({ sessions, onSessionRenamed }: Props) { .then(detail => setSessionDetail(detail)) .catch(() => {}) .finally(() => setDetailLoading(false)) + // Load allowed tools for this session's working directory + window.gridwatchAPI.getToolPermissions() + .then((perms) => { + const proj = perms.find((p) => p.projectPath === selectedSession.cwd) + setAllowedToolsForSession(new Set(proj?.allowedTools ?? [])) + }) + .catch(() => {}) } }, [selectedSession?.id]) @@ -241,6 +265,35 @@ function SessionsPage({ sessions, onSessionRenamed }: Props) { } } + const handleAllowTool = async (toolSpec: string) => { + if (!selectedSession) return + setAllowingTool(true) + try { + const result = await window.gridwatchAPI.allowTool(selectedSession.cwd, toolSpec) + if (result.ok) { + setAllowedToolsForSession((prev) => new Set([...prev, toolSpec])) + setConfirmAllowTool(null) + setUndoTool(toolSpec) + if (undoTimerRef.current) clearTimeout(undoTimerRef.current) + undoTimerRef.current = setTimeout(() => setUndoTool(null), UNDO_TOAST_DURATION_MS) + } + } catch { /* ignore */ } + setAllowingTool(false) + } + + const handleUndoAllow = async () => { + if (!selectedSession || !undoTool) return + if (undoTimerRef.current) clearTimeout(undoTimerRef.current) + const spec = undoTool + setUndoTool(null) + await window.gridwatchAPI.removeToolPermission(selectedSession.cwd, spec).catch(() => {}) + setAllowedToolsForSession((prev) => { + const next = new Set(prev) + next.delete(spec) + return next + }) + } + // Collect all unique tags across sessions const allTags = useMemo(() => Array.from( new Set(sessions.flatMap((s) => s.tags ?? [])) @@ -618,10 +671,55 @@ function SessionsPage({ sessions, onSessionRenamed }: Props) { {selectedSession.toolsUsed.length > 0 && (
TOOLS USED
+ + {/* Undo toast */} + {undoTool && ( +
+ {undoTool} added to allowed tools + +
+ )} + + {/* Confirm allow dialog */} + {confirmAllowTool && ( +
+ + Allow {confirmAllowTool} for this project? + Copilot will use it without asking in future sessions. + +
+ + +
+
+ )} +
- {selectedSession.toolsUsed.map((tool) => ( - {tool} - ))} + {selectedSession.toolsUsed.map((tool) => { + const isAllowed = allowedToolsForSession.has(tool) + return ( + + ) + })}
)} diff --git a/src/pages/SettingsPage.module.css b/src/pages/SettingsPage.module.css index 58075b1..713749a 100644 --- a/src/pages/SettingsPage.module.css +++ b/src/pages/SettingsPage.module.css @@ -338,3 +338,202 @@ padding: 0 4px; margin-left: auto; } + +/* ── Allowed Tools ─────────────────────────── */ +.toolEmpty { + font-size: calc(11 * var(--font-scale, 1) * 1px); + color: var(--tron-text-dim); + letter-spacing: 0.5px; + padding: 12px 0; +} + +.toolError { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + margin-bottom: 10px; + background: rgba(255, 102, 0, 0.08); + border: 1px solid rgba(255, 102, 0, 0.3); + color: var(--tron-orange); + font-size: calc(11 * var(--font-scale, 1) * 1px); + letter-spacing: 0.5px; +} + +.toolErrorDismiss { + background: none; + border: none; + color: var(--tron-orange); + cursor: pointer; + font-size: calc(14 * var(--font-scale, 1) * 1px); + padding: 0 4px; + margin-left: auto; +} + +.toolProjectList { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.toolProject { + padding: 10px 12px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--tron-border); +} + +.toolProjectPath { + font-size: calc(10 * var(--font-scale, 1) * 1px); + color: var(--tron-text-dim); + letter-spacing: 0.5px; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 8px; +} + +.toolChips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.toolChip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: calc(10 * var(--font-scale, 1) * 1px); + padding: 3px 6px 3px 8px; + border: 1px solid var(--tron-border); + color: var(--tron-text); + letter-spacing: 0.5px; + font-family: monospace; +} + +/* shell tools — orange tint (higher risk) */ +.toolChip_shell { + border-color: rgba(255, 102, 0, 0.4); + color: var(--tron-orange); + background: rgba(255, 102, 0, 0.06); +} + +/* write tool — blue tint */ +.toolChip_write { + border-color: rgba(0, 128, 255, 0.4); + color: var(--tron-blue); + background: rgba(0, 128, 255, 0.06); +} + +/* MCP tools — cyan tint */ +.toolChip_mcp { + border-color: rgba(0, 245, 255, 0.3); + color: var(--tron-cyan); + background: rgba(0, 245, 255, 0.05); +} + +/* other/unknown */ +.toolChip_other { + border-color: var(--tron-border); + color: var(--tron-text); +} + +.toolChipRemove { + background: none; + border: none; + cursor: pointer; + font-family: inherit; + font-size: calc(11 * var(--font-scale, 1) * 1px); + color: inherit; + opacity: 0.5; + padding: 0 2px; + line-height: 1; + transition: opacity 0.15s; +} + +.toolChipRemove:hover { + opacity: 1; +} + +.toolChipRemove:disabled { + opacity: 0.3; + cursor: default; +} + +.toolAddForm { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} + +.toolAddRow { + display: flex; + gap: 6px; + align-items: center; +} + +.toolAddInput { + flex: 1; + background: var(--tron-bg); + border: 1px solid var(--tron-border); + color: var(--tron-text); + font-family: monospace; + font-size: calc(11 * var(--font-scale, 1) * 1px); + padding: 7px 10px; + outline: none; + letter-spacing: 0.5px; + transition: border-color 0.2s; +} + +.toolAddInput:focus { + border-color: var(--tron-cyan); +} + +.toolAddInput::placeholder { + color: var(--tron-text-dim); + font-family: inherit; +} + +.toolAddBtn { + background: rgba(0, 245, 255, 0.06); + border: 1px solid rgba(0, 245, 255, 0.3); + color: var(--tron-cyan); + cursor: pointer; + font-family: inherit; + font-size: calc(10 * var(--font-scale, 1) * 1px); + font-weight: 700; + letter-spacing: 1px; + padding: 7px 16px; + transition: background 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.toolAddBtn:hover { + background: rgba(0, 245, 255, 0.12); + border-color: var(--tron-cyan); +} + +.toolAddBtn:disabled { + opacity: 0.4; + cursor: default; +} + +.toolCancelBtn { + background: none; + border: 1px solid var(--tron-border); + color: var(--tron-text-dim); + cursor: pointer; + font-family: inherit; + font-size: calc(10 * var(--font-scale, 1) * 1px); + letter-spacing: 1px; + padding: 7px 12px; + transition: all 0.15s; + white-space: nowrap; +} + +.toolCancelBtn:hover { + border-color: var(--tron-text-dim); + color: var(--tron-text); +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index ca2dd68..51715a6 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, memo } from 'react' import type { AllowedDirectory } from '../types/dirs' +import type { ProjectToolPermissions } from '../types/tools' import styles from './SettingsPage.module.css' export interface AppSettings { @@ -85,6 +86,15 @@ function SettingsPage({ settings, onChange }: Props) { const [removingDir, setRemovingDir] = useState(null) const [dirError, setDirError] = useState(null) + // Tool permissions state + const [toolPerms, setToolPerms] = useState([]) + const [removingTool, setRemovingTool] = useState(null) // "path::tool" + const [toolError, setToolError] = useState(null) + const [addToolProject, setAddToolProject] = useState('') + const [addToolSpec, setAddToolSpec] = useState('') + const [addingTool, setAddingTool] = useState(false) + const [showAddToolForm, setShowAddToolForm] = useState(false) + useEffect(() => { window.gridwatchAPI.hasToken().then(setHasToken) }, []) const loadDirs = useCallback(async () => { @@ -124,6 +134,53 @@ function SettingsPage({ settings, onChange }: Props) { setRemovingDir(null) }, [loadDirs]) + const loadToolPerms = useCallback(async () => { + try { + const data = await window.gridwatchAPI.getToolPermissions() + setToolPerms(data) + } catch { /* ignore */ } + }, []) + + useEffect(() => { loadToolPerms() }, [loadToolPerms]) + + const handleRemoveTool = useCallback(async (projectPath: string, toolSpec: string) => { + const key = `${projectPath}::${toolSpec}` + setRemovingTool(key) + setToolError(null) + try { + const result = await window.gridwatchAPI.removeToolPermission(projectPath, toolSpec) + if (result.ok) { + await loadToolPerms() + } else if (result.error) { + setToolError(result.error) + } + } catch { /* ignore */ } + setRemovingTool(null) + }, [loadToolPerms]) + + const handleAddTool = useCallback(async () => { + const projectPath = addToolProject.trim() + const toolSpec = addToolSpec.trim() + if (!projectPath || !toolSpec) { + setToolError('Project path and tool specification are required') + return + } + setAddingTool(true) + setToolError(null) + try { + const result = await window.gridwatchAPI.allowTool(projectPath, toolSpec) + if (result.ok) { + await loadToolPerms() + setAddToolSpec('') + setAddToolProject('') + setShowAddToolForm(false) + } else if (result.error) { + setToolError(result.error) + } + } catch { /* ignore */ } + setAddingTool(false) + }, [addToolProject, addToolSpec, loadToolPerms]) + const update = (patch: Partial) => { const next = { ...settings, ...patch } onChange(next) @@ -135,6 +192,13 @@ function SettingsPage({ settings, onChange }: Props) { update({ ...DEFAULT_SETTINGS }) } + function toolSpecKind(spec: string): 'shell' | 'write' | 'mcp' | 'other' { + if (spec === 'write') return 'write' + if (spec === 'shell' || spec.startsWith('shell(')) return 'shell' + if (spec.includes('(')) return 'mcp' + return 'other' + } + return (
SETTINGS
@@ -319,6 +383,112 @@ function SettingsPage({ settings, onChange }: Props) {
+ {/* Allowed Tools */} +
+
ALLOWED TOOLS
+
+ Tools that Copilot CLI can use without prompting, organised by project. Stored in{' '} + ~/.copilot/permissions-config.json. + Removing a tool resets it to "ask again" in future sessions. +
+ + {toolError && ( +
+ {toolError} + +
+ )} + + {toolPerms.length === 0 ? ( +
No tool permissions configured
+ ) : ( +
+ {toolPerms.map((proj) => ( +
+
+ {proj.projectPath} +
+
+ {proj.allowedTools.map((tool) => { + const kind = toolSpecKind(tool) + const key = `${proj.projectPath}::${tool}` + return ( + + {tool} + + + ) + })} +
+
+ ))} +
+ )} + + {showAddToolForm ? ( +
+
+ setAddToolProject(e.target.value)} + spellCheck={false} + autoComplete="off" + /> +
+
+ setAddToolSpec(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { void handleAddTool() } }} + spellCheck={false} + autoComplete="off" + /> + + +
+
+ ) : ( + + )} +
+ {/* Reset */}
RESET
diff --git a/src/types/global.d.ts b/src/types/global.d.ts index d6effc1..11eb9ff 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -4,6 +4,7 @@ import type { McpServerData } from './mcp'; import type { CustomAgentData } from './agent'; import type { LspServerData } from './lsp'; import type { AllowedDirectory } from './dirs'; +import type { ProjectToolPermissions } from './tools'; export interface PromptFeedback { prompt: string; @@ -81,6 +82,11 @@ declare global { addAllowedDir: () => Promise<{ ok: boolean; directory?: AllowedDirectory; error?: string }>; removeAllowedDir: (dirPath: string) => Promise<{ ok: boolean; error?: string }>; + // Tool Permissions + getToolPermissions: () => Promise; + allowTool: (projectPath: string, toolSpec: string) => Promise<{ ok: boolean; error?: string }>; + removeToolPermission: (projectPath: string, toolSpec: string) => Promise<{ ok: boolean; error?: string }>; + // Window controls getPlatform: () => Promise; windowMinimize: () => Promise; diff --git a/src/types/tools.ts b/src/types/tools.ts new file mode 100644 index 0000000..54b227c --- /dev/null +++ b/src/types/tools.ts @@ -0,0 +1,5 @@ +/** Tool permission entry from permissions-config.json */ +export interface ProjectToolPermissions { + projectPath: string // key in permissions-config.json (project working directory) + allowedTools: string[] // e.g. ["shell(git)", "write", "mcp-server(tool)"] +} From 47a9d9b28394749fe2ad4ca2a1094bd74a52789b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 09:27:17 +0000 Subject: [PATCH 3/3] fix: address code review feedback on allowed tools feature Agent-Logs-Url: https://github.com/faesel/gridwatch/sessions/faac5005-ac9e-46c3-9b0a-d5ad4b09e0ae Co-authored-by: faesel <6319576+faesel@users.noreply.github.com> --- electron/main.ts | 11 ++++------- src/pages/SessionsPage.module.css | 5 ++++- src/pages/SessionsPage.tsx | 3 ++- src/pages/SettingsPage.tsx | 13 +++++++------ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index aab2340..f17aacd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2152,6 +2152,9 @@ const TOOL_PERMS_CACHE_TTL = 10_000 /** Max length for a tool spec string */ const MAX_TOOL_SPEC_LENGTH = 512 +/** Max length for the argument portion inside parentheses of a tool spec */ +const MAX_TOOL_ARG_LENGTH = 200 + /** * Valid tool specs: * write @@ -2161,7 +2164,7 @@ const MAX_TOOL_SPEC_LENGTH = 512 * where IDENTIFIER starts with a letter and contains letters, digits, hyphens, underscores, * and ARGUMENT may not contain parentheses (prevents unbalanced nesting). */ -const TOOL_SPEC_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*(\([^)(]{0,200}\))?$/ +const TOOL_SPEC_PATTERN = new RegExp(`^[a-zA-Z][a-zA-Z0-9_-]*(\\([^)(]{0,${MAX_TOOL_ARG_LENGTH}}\\))?$`) function isValidToolSpec(spec: unknown): spec is string { if (typeof spec !== 'string') return false @@ -2219,9 +2222,6 @@ ipcMain.handle( if (!isValidToolSpec(toolSpec)) { return { ok: false, error: 'Invalid tool specification' } } - if (PROTOTYPE_POLLUTION_KEYS.has(toolSpec)) { - return { ok: false, error: 'Invalid tool specification' } - } const config = readPermissionsConfig() const entry = config[projectPath] ?? {} @@ -2252,9 +2252,6 @@ ipcMain.handle( if (!isValidToolSpec(toolSpec)) { return { ok: false, error: 'Invalid tool specification' } } - if (PROTOTYPE_POLLUTION_KEYS.has(toolSpec)) { - return { ok: false, error: 'Invalid tool specification' } - } const config = readPermissionsConfig() const entry = config[projectPath] diff --git a/src/pages/SessionsPage.module.css b/src/pages/SessionsPage.module.css index 255d12a..01bb586 100644 --- a/src/pages/SessionsPage.module.css +++ b/src/pages/SessionsPage.module.css @@ -641,11 +641,14 @@ border-color: var(--tron-cyan) !important; box-shadow: 0 0 6px rgba(0, 245, 255, 0.25); cursor: default; + opacity: 1; } -.toolBadgeAllowed:hover { +.toolBadgeAllowed:hover, +.toolBadgeAllowed:disabled { background: rgba(0, 245, 255, 0.1) !important; box-shadow: 0 0 6px rgba(0, 245, 255, 0.25); + opacity: 1; } .toolUndoToast { diff --git a/src/pages/SessionsPage.tsx b/src/pages/SessionsPage.tsx index c6895a5..958da1d 100644 --- a/src/pages/SessionsPage.tsx +++ b/src/pages/SessionsPage.tsx @@ -125,7 +125,7 @@ function SessionsPage({ sessions, onSessionRenamed }: Props) { return () => clearTimeout(timer) }, [search]) - // Clean up the undo timer on unmount to prevent state updates after unmount + // Prevent memory leaks from pending undo timers when the component unmounts useEffect(() => { return () => { if (undoTimerRef.current) clearTimeout(undoTimerRef.current) } }, []) @@ -713,6 +713,7 @@ function SessionsPage({ sessions, onSessionRenamed }: Props) { key={tool} className={`${styles.toolBadge} ${isAllowed ? styles.toolBadgeAllowed : ''}`} onClick={() => { if (!isAllowed) setConfirmAllowTool(tool) }} + disabled={isAllowed} title={isAllowed ? 'Already in allowed tools list' : 'Click to add to allowed tools'} aria-label={isAllowed ? `${tool} (allowed)` : `Add ${tool} to allowed tools`} > diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 51715a6..b53beab 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -73,6 +73,13 @@ const SPACING_PRESETS: { label: string; value: AppSettings['spacing'] }[] = [ { label: 'COMFORTABLE', value: 'comfortable' }, ] +function toolSpecKind(spec: string): 'shell' | 'write' | 'mcp' | 'other' { + if (spec === 'write') return 'write' + if (spec === 'shell' || spec.startsWith('shell(')) return 'shell' + if (spec.includes('(')) return 'mcp' + return 'other' +} + interface Props { settings: AppSettings onChange: (s: AppSettings) => void @@ -192,12 +199,6 @@ function SettingsPage({ settings, onChange }: Props) { update({ ...DEFAULT_SETTINGS }) } - function toolSpecKind(spec: string): 'shell' | 'write' | 'mcp' | 'other' { - if (spec === 'write') return 'write' - if (spec === 'shell' || spec.startsWith('shell(')) return 'shell' - if (spec.includes('(')) return 'mcp' - return 'other' - } return (