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
134 changes: 134 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -2141,3 +2142,136 @@ 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

/** Max length for the argument portion inside parentheses of a tool spec */
const MAX_TOOL_ARG_LENGTH = 200

/**
* 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 = 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
if (!spec || spec.length > MAX_TOOL_SPEC_LENGTH) return false
return TOOL_SPEC_PATTERN.test(spec)
}

function readPermissionsConfig(): Record<string, { allowedTools?: string[] }> {
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<string, { allowedTools?: string[] }>
} catch {
return {}
}
}

function writePermissionsConfig(config: Record<string, { allowedTools?: string[] }>): void {
fs.writeFileSync(permissionsConfigPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
}

ipcMain.handle('tools:get-permissions', async (): Promise<ProjectToolPermissions[]> => {
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' }
}

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' }
}

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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 113 additions & 0 deletions src/pages/SessionsPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,119 @@
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;
opacity: 1;
}

.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 {
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 {
Expand Down
Loading