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
This commit is contained in:
rob 2025-10-06 19:43:46 +00:00
parent 945c7eca57
commit d53162e021
1 changed files with 129 additions and 146 deletions

View File

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