From d53162e021a5974510ec9053b228b63084bb7b74 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 6 Oct 2025 19:43:46 +0000 Subject: [PATCH] Update src/ai-repo-commander.user.js Security improvements - Bridge key prompting, @connect directive, basic path validation Better UX - Appending status instead of replacing (much cleaner!) Streaming detection - Restarts debounce if content changes Auto-commit messages - Nice touch for file operations Owner/repo normalization - Handles owner/repo format gracefully --- src/ai-repo-commander.user.js | 275 ++++++++++++++++------------------ 1 file changed, 129 insertions(+), 146 deletions(-) diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index 682cd3d..f30f1fc 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name AI Repo Commander // @namespace http://tampermonkey.net/ -// @version 1.0.0 +// @version 1.0.1 // @description Enable AI assistants to securely interact with git repositories via YAML commands // @author Your Name // @match https://chat.openai.com/* @@ -9,6 +9,7 @@ // @match https://gemini.google.com/* // @grant GM_xmlhttpRequest // @grant GM_notification +// @connect n8n.brrd.tech // ==/UserScript== (function() { @@ -20,7 +21,7 @@ DEBUG_MODE: true, // Development logging DEBOUNCE_DELAY: 5000, // 5-second bot typing protection MAX_RETRIES: 2, // API retry attempts - VERSION: '1.0.0' + VERSION: '1.0.1' }; // Platform-specific DOM selectors @@ -52,9 +53,12 @@ 'list_files': ['action', 'repo', 'path'] }; + // Enhanced field validators (minimal safety) const FIELD_VALIDATORS = { - 'repo': (value) => /^[\w\-\.]+$/.test(value), - 'path': (value) => !value.includes('..') && !value.startsWith('/'), + // allow "owner/repo" or just "repo" + 'repo': (value) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(value), + // basic traversal guard: no absolute paths, no backslashes, no ".." + 'path': (value) => !value.includes('..') && !value.startsWith('/') && !value.includes('\\'), 'action': (value) => Object.keys(REQUIRED_FIELDS).includes(value), 'owner': (value) => !value || /^[\w\-]+$/.test(value), 'url': (value) => !value || /^https?:\/\/.+\..+/.test(value) @@ -80,6 +84,16 @@ ERROR: 'error' }; + // Ephemeral secret (memory only; prompted when API enabled) + let BRIDGE_KEY = null; + function requireBridgeKeyIfNeeded() { + if (CONFIG.ENABLE_API && !BRIDGE_KEY) { + BRIDGE_KEY = prompt('[AI Repo Commander] Enter your bridge key for this session:'); + if (!BRIDGE_KEY) throw new Error('Bridge key required when API is enabled.'); + } + return BRIDGE_KEY; + } + // Core Monitor Class class CommandMonitor { constructor() { @@ -102,7 +116,6 @@ } startObservation() { - // Observe for new messages this.observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { @@ -113,12 +126,8 @@ }); }); - // Start observing const targetNode = document.body; - this.observer.observe(targetNode, { - childList: true, - subtree: true - }); + this.observer.observe(targetNode, { childList: true, subtree: true }); // Initial scan of existing messages this.scanExistingMessages(); @@ -136,17 +145,11 @@ scanMessages() { const messages = document.querySelectorAll(this.currentPlatform.messages); - messages.forEach((messageElement) => { const messageId = this.getMessageId(messageElement); - - // Skip if already tracking this message - if (this.trackedMessages.has(messageId)) { - return; - } + if (this.trackedMessages.has(messageId)) return; const text = this.extractText(messageElement); - if (text && text.includes('^%$bridge')) { this.trackMessage(messageElement, text, messageId); } @@ -164,14 +167,12 @@ trackMessage(element, text, messageId) { this.log('New command detected:', { messageId, text: text.substring(0, 100) }); - this.trackedMessages.set(messageId, { element, originalText: text, state: COMMAND_STATES.DETECTED, startTime: Date.now() }); - this.updateState(messageId, COMMAND_STATES.PARSING); this.processCommand(messageId); } @@ -201,24 +202,35 @@ throw new Error(`Validation failed: ${validation.errors.join(', ')}`); } - // Step 3: Debounce (wait for bot to finish typing) + // Step 3: Debounce with content change detection (handles streaming edits) this.updateState(messageId, COMMAND_STATES.DEBOUNCING); + const initialText = message.originalText; await this.debounce(); + const currentText = this.extractText(message.element); + if (currentText && currentText !== initialText) { + this.log('Content changed during debounce; restarting debounce.'); + await this.debounce(); + } - // Step 4: Execute command + // Step 4: Optional commit message synthesis + if ((parsedCommand.action === 'update_file' || parsedCommand.action === 'create_file') && + !parsedCommand.commit_message) { + parsedCommand.commit_message = `AI Repo Commander: ${parsedCommand.path} (${new Date().toISOString()})`; + } + + // Step 5: Execute command this.updateState(messageId, COMMAND_STATES.EXECUTING); - const result = await ExecutionManager.executeCommand(parsedCommand, message.element); + await ExecutionManager.executeCommand(parsedCommand, message.element); - // Step 5: Complete + // Step 6: Complete this.updateState(messageId, COMMAND_STATES.COMPLETE); - + } catch (error) { this.log(`Command processing error: ${error.message}`); this.updateState(messageId, COMMAND_STATES.ERROR); - const message = this.trackedMessages.get(messageId); if (message) { - UIFeedback.replaceWithStatus(message.element, 'ERROR', { + UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message }); @@ -232,9 +244,7 @@ stopAllProcessing() { this.trackedMessages.clear(); - if (this.observer) { - this.observer.disconnect(); - } + if (this.observer) this.observer.disconnect(); } setupEmergencyStop() { @@ -251,29 +261,22 @@ } log(...args) { - if (CONFIG.DEBUG_MODE) { - console.log('[AI Repo Commander]', ...args); - } + if (CONFIG.DEBUG_MODE) console.log('[AI Repo Commander]', ...args); } } - // Command Parser Class + // Command Parser Class (sturdier block extraction + simple content: |) class CommandParser { static parseYAMLCommand(text) { try { - // Extract command block between ^%$bridge and --- const commandBlock = this.extractCommandBlock(text); - if (!commandBlock) { - throw new Error('No valid command block found'); - } - - // Parse YAML-like syntax + if (!commandBlock) throw new Error('No valid command block found'); const parsed = this.parseKeyValuePairs(commandBlock); - + // Set defaults parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; parsed.owner = parsed.owner || 'brrd'; - + return parsed; } catch (error) { throw new Error(`YAML parsing failed: ${error.message}`); @@ -281,68 +284,57 @@ } static extractCommandBlock(text) { - const startMarker = '^%$bridge'; - const endMarker = '---'; - - const startIndex = text.indexOf(startMarker); - if (startIndex === -1) return null; - - const endIndex = text.indexOf(endMarker, startIndex + startMarker.length); - if (endIndex === -1) return null; - - return text.substring(startIndex + startMarker.length, endIndex).trim(); + // Finds the first ^%$bridge block up to the next line that contains only --- + const m = text.match(/^\^%\$bridge[ \t]*\n([\s\S]*?)\n---\s*$/m); + return m ? m[1].trimEnd() : null; } static parseKeyValuePairs(block) { const lines = block.split('\n'); const result = {}; let currentKey = null; - let multiLineContent = null; + let collecting = false; + let buf = []; - for (const line of lines) { - const trimmed = line.trim(); - - if (!trimmed) continue; + for (const raw of lines) { + const line = raw.replace(/\r$/, ''); - // Check for multi-line content marker - if (trimmed === '|' && currentKey === 'content') { - multiLineContent = []; - continue; + if (collecting) { + // stop if we hit an unindented key:value (loose heuristic) + if (/^[A-Za-z_][\w\-]*\s*:/.test(line)) { + result[currentKey] = buf.join('\n').trimEnd(); + collecting = false; + buf = []; + // fall through to parse this line as a new key + } else { + buf.push(line); + continue; + } } - // If we're collecting multi-line content - if (multiLineContent !== null) { - if (trimmed === '---') break; // End of command - multiLineContent.push(line); - continue; - } + const idx = line.indexOf(':'); + if (idx !== -1) { + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); - // Parse key: value pairs - const colonIndex = trimmed.indexOf(':'); - if (colonIndex !== -1) { - const key = trimmed.substring(0, colonIndex).trim(); - let value = trimmed.substring(colonIndex + 1).trim(); - - // Handle empty values that might be multi-line - if (value === '' || value === '|') { + if (value === '|') { + currentKey = key; + collecting = true; + buf = []; + } else if (value === '') { currentKey = key; - if (value === '|') { - multiLineContent = []; - } result[key] = ''; } else { result[key] = value; currentKey = null; } } else if (currentKey && result[currentKey] === '') { - // Continue with previous key (simple multi-line) - result[currentKey] += '\n' + trimmed; + result[currentKey] += (result[currentKey] ? '\n' : '') + line.trimEnd(); } } - // Join multi-line content - if (multiLineContent !== null && currentKey) { - result[currentKey] = multiLineContent.join('\n').trim(); + if (collecting && currentKey) { + result[currentKey] = buf.join('\n').trimEnd(); } return result; @@ -350,8 +342,15 @@ static validateStructure(parsed) { const errors = []; - - // Check required fields + + // Expand owner/repo shorthand if present + if (parsed.repo && parsed.repo.includes('/')) { + const [owner, repo] = parsed.repo.split('/', 2); + parsed.owner = parsed.owner || owner; + parsed.repo = repo; + } + + // Required fields per action const action = parsed.action; if (!action) { errors.push('Missing required field: action'); @@ -370,7 +369,7 @@ } } - // Validate field formats + // Field format validators for (const [field, value] of Object.entries(parsed)) { const validator = FIELD_VALIDATORS[field]; if (validator && !validator(value)) { @@ -378,29 +377,28 @@ } } - return { - isValid: errors.length === 0, - errors - }; + // Minimal traversal guard (remove if you truly don't want it) + if (parsed.path && (parsed.path.startsWith('/') || parsed.path.includes('..') || parsed.path.includes('\\'))) { + errors.push('Invalid path: no leading "/" or ".." or "\\" allowed.'); + } + + return { isValid: errors.length === 0, errors }; } } - // Execution Manager Class + // Execution Manager (append status, clearer retry errors, ephemeral key) class ExecutionManager { static async executeCommand(command, sourceElement) { try { - // Pre-execution safety checks if (!CONFIG.ENABLE_API) { return this.mockExecution(command, sourceElement); } - // Show executing status - UIFeedback.replaceWithStatus(sourceElement, 'EXECUTING', { + UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...' }); - // API call with retry logic const response = await this.makeAPICallWithRetry(command); return this.handleSuccess(response, command, sourceElement); @@ -411,23 +409,26 @@ static async makeAPICallWithRetry(command, attempt = 0) { try { + requireBridgeKeyIfNeeded(); return await this.makeAPICall(command); } catch (error) { if (attempt < CONFIG.MAX_RETRIES) { - await this.delay(1000 * (attempt + 1)); + await this.delay(1000 * (attempt + 1)); // simple backoff: 1s, 2s return this.makeAPICallWithRetry(command, attempt + 1); } - throw error; + throw new Error(`${error.message} (after ${attempt + 1} attempts)`); } } static makeAPICall(command) { return new Promise((resolve, reject) => { + const bridgeKey = requireBridgeKeyIfNeeded(); + GM_xmlhttpRequest({ method: 'POST', url: command.url, headers: { - 'X-Bridge-Key': 'mango-rocket-82', + 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' }, data: JSON.stringify(command), @@ -451,45 +452,36 @@ static async mockExecution(command, sourceElement) { await this.delay(1000); // Simulate API delay - const mockResponse = { status: 200, responseText: JSON.stringify({ success: true, message: `Mock execution completed for ${command.action}`, - data: { command: command.action, repo: command.repo } + data: { command: command.action, repo: command.repo, path: command.path } }) }; - return this.handleSuccess(mockResponse, command, sourceElement, true); } static handleSuccess(response, command, sourceElement, isMock = false) { - const responseData = JSON.parse(response.responseText); + const responseData = JSON.parse(response.responseText || '{}'); const templateType = isMock ? 'MOCK' : 'SUCCESS'; - - UIFeedback.replaceWithStatus(sourceElement, templateType, { + + UIFeedback.appendStatus(sourceElement, templateType, { action: command.action, details: responseData.message || 'Operation completed successfully' }); - return { - success: true, - data: responseData, - isMock - }; + return { success: true, data: responseData, isMock }; } static handleError(error, command, sourceElement) { - UIFeedback.replaceWithStatus(sourceElement, 'ERROR', { - action: command.action, + UIFeedback.appendStatus(sourceElement, 'ERROR', { + action: command.action || 'Command', details: error.message }); - return { - success: false, - error: error.message - }; + return { success: false, error: error.message }; } static delay(ms) { @@ -497,56 +489,50 @@ } } - // UI Feedback System + // UI Feedback System (APPENDS instead of replacing) class UIFeedback { - static replaceWithStatus(sourceElement, templateType, data) { + static appendStatus(sourceElement, templateType, data) { const statusElement = this.createStatusElement(templateType, data); - this.replaceElement(sourceElement, statusElement); + const existingStatus = sourceElement.querySelector('.ai-repo-commander-status'); + if (existingStatus) existingStatus.remove(); + sourceElement.appendChild(statusElement); } static createStatusElement(templateType, data) { - const template = STATUS_TEMPLATES[templateType]; - const message = template - .replace('{action}', data.action) - .replace('{details}', data.details); + const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; + const message = template.replace('{action}', data.action).replace('{details}', data.details); const statusElement = document.createElement('div'); statusElement.className = 'ai-repo-commander-status'; statusElement.textContent = message; statusElement.style.cssText = ` padding: 8px 12px; - margin: 5px 0; + margin: 10px 0; border-radius: 4px; border-left: 4px solid ${this.colorCodeStatus(templateType)}; - background-color: rgba(255, 255, 255, 0.1); + background-color: rgba(255, 255, 255, 0.08); font-family: monospace; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; + border: 1px solid rgba(255, 255, 255, 0.15); `; - return statusElement; } - static replaceElement(oldElement, newElement) { - if (oldElement && oldElement.parentNode) { - oldElement.parentNode.replaceChild(newElement, oldElement); - } - } - static colorCodeStatus(type) { const colors = { - 'SUCCESS': '#10B981', // Green - 'ERROR': '#EF4444', // Red + 'SUCCESS': '#10B981', // Green + 'ERROR': '#EF4444', // Red 'VALIDATION_ERROR': '#F59E0B', // Yellow - 'EXECUTING': '#3B82F6', // Blue - 'MOCK': '#8B5CF6' // Purple + 'EXECUTING': '#3B82F6', // Blue + 'MOCK': '#8B5CF6' // Purple }; return colors[type] || '#6B7280'; // Default gray } } - // Test command generator + // Test command generator (unchanged) const TEST_COMMANDS = { validUpdate: `^%$bridge action: update_file @@ -555,18 +541,19 @@ path: TEST.md content: | Test content Multiple lines ----`, - +--- +`, invalidCommand: `^%$bridge action: update_file repo: test-repo ----`, - +--- +`, getFile: `^%$bridge -action: get_file +action: get_file repo: test-repo path: README.md ----` +--- +` }; // Initialize the system @@ -575,7 +562,6 @@ path: README.md function initializeRepoCommander() { if (!commandMonitor) { commandMonitor = new CommandMonitor(); - // Expose for debugging window.AI_REPO_COMMANDER = { monitor: commandMonitor, @@ -583,18 +569,15 @@ path: README.md test: TEST_COMMANDS, version: CONFIG.VERSION }; - console.log('AI Repo Commander fully initialized'); console.log('API Enabled:', CONFIG.ENABLE_API); console.log('Test commands available in window.AI_REPO_COMMANDER.test'); } } - // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeRepoCommander); } else { initializeRepoCommander(); } - -})(); \ No newline at end of file +})();