Security audit fixes: path traversal, XSS, info disclosure, missing headers

CRITICAL fixes:
- Path traversal prevention in brain/backup/skill/file endpoints
- Unsafe tar extraction with safe_extractall() path validation

HIGH fixes:
- XSS: encodeURIComponent for onclick handlers in skills/memory/prompts/standards/backups
- XSS: escapeHtml() for user-controlled text in cost/plugins/settings/audit/dashboard
- XSS: safe class name mapping in setup-wizard (attribute injection)
- Security headers middleware (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)
- CORS restricted to localhost:8080
- Settings API keys masked before returning to client
- Session replay content limited to 2000 chars + path validation

MEDIUM fixes:
- Chat message length limit (10000 chars)
- Input validation on brain/skill/backup file names (reject .. and /)
This commit is contained in:
modimihir07 2026-06-29 13:57:44 +05:30
parent d9b1b0d9fd
commit a30162d114
12 changed files with 151 additions and 54 deletions

View File

@ -67,7 +67,7 @@ function applyAuditFilter() {
<tr>
<td style="font-size:12px;white-space:nowrap">${formatDate(e.timestamp)}</td>
<td><span class="badge ${e.action === 'skill_run' ? 'badge-success' : e.action === 'brain_update' ? 'badge-info' : e.action === 'backup_created' ? 'badge-accent' : 'badge-warning'}">${e.action}</span></td>
<td style="font-size:13px">${e.skill ? `<strong>${e.skill}</strong>` : ''}${e.file ? `File: ${e.file}` : ''}${e.job ? `Job: ${e.job}` : ''}${e.plugin ? `Plugin: ${e.plugin}` : ''}</td>
<td style="font-size:13px">${e.skill ? `<strong>${escapeHtml(e.skill)}</strong>` : ''}${e.file ? `File: ${escapeHtml(e.file)}` : ''}${e.job ? `Job: ${escapeHtml(e.job)}` : ''}${e.plugin ? `Plugin: ${escapeHtml(e.plugin)}` : ''}</td>
<td style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono)">${e.id || ''}</td>
</tr>
`).join('')}

View File

@ -27,10 +27,10 @@ async function renderBackups() {
<tbody>
${backups.map(b => `
<tr>
<td><strong>${b.name}</strong></td>
<td><strong>${escapeHtml(b.name)}</strong></td>
<td>${formatBytes(b.size)}</td>
<td style="font-size:12px">${formatDate(b.created)}</td>
<td><button class="btn btn-sm btn-danger" onclick="restoreBackup('${b.name}')">Restore</button></td>
<td><button class="btn btn-sm btn-danger" onclick="restoreBackup('${encodeURIComponent(b.name)}')">Restore</button></td>
</tr>
`).join('')}
</tbody>
@ -53,19 +53,21 @@ async function createBackup() {
}
}
async function restoreBackup(name) {
async function restoreBackup(encodedName) {
const name = decodeURIComponent(encodedName);
showModal('Restore Backup', `
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">Restore <strong>${name}</strong>? This will overwrite current brain, skills, agents, registry, standards, and prompts data.</p>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">Restore <strong>${escapeHtml(name)}</strong>? This will overwrite current brain, skills, agents, registry, standards, and prompts data.</p>
<div class="card" style="background:var(--red-dim);border-color:transparent">
<div class="flex items-center gap-2"><span></span><span style="font-size:13px;font-weight:500">This action cannot be undone</span></div>
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger" onclick="confirmRestore('${name}')">Restore</button>
<button class="btn btn-danger" onclick="confirmRestore('${encodeURIComponent(name)}')">Restore</button>
`);
}
async function confirmRestore(name) {
async function confirmRestore(encodedName) {
const name = decodeURIComponent(encodedName);
try {
const r = await api.restoreBackup(name);
closeModal();

View File

@ -53,8 +53,8 @@ async function renderCost() {
${entries.slice(-20).reverse().map(e => `
<tr>
<td style="font-size:12px">${formatDate(e.timestamp)}</td>
<td><span class="badge badge-accent">${e.agent}</span></td>
<td style="font-size:12px">${e.model}</td>
<td><span class="badge badge-accent">${escapeHtml(e.agent)}</span></td>
<td style="font-size:12px">${escapeHtml(e.model)}</td>
<td>${(e.tokens || 0).toLocaleString()}</td>
<td><span class="badge ${(e.cost || 0) > 0 ? 'badge-warning' : 'badge-success'}">$${(e.cost || 0).toFixed(6)}</span></td>
</tr>

View File

@ -86,7 +86,7 @@ async function renderDashboard() {
<div class="event-item">
<div class="event-dot" style="background:${e.action === 'skill_run' ? 'var(--accent)' : 'var(--blue)'}"></div>
<div class="event-content">
<div class="event-title">${e.action}${e.skill ? `: ${e.skill}` : ''}</div>
<div class="event-title">${escapeHtml(e.action)}${e.skill ? `: ${escapeHtml(e.skill)}` : ''}</div>
<div class="event-meta">${e.agent ? `via ${e.agent}` : ''} ${e.run_id ? `#${e.run_id}` : ''}</div>
</div>
<div class="event-time">${timeAgo(e.timestamp)}</div>

View File

@ -22,9 +22,10 @@ async function renderMemory() {
container.innerHTML = `<div style="display:grid;gap:12px">${files.map(([name, content]) => {
const preview = content ? content.slice(0, 200) : '';
return `<div class="card" style="cursor:pointer" onclick="editMemory('${name}')">
const safeName = escapeHtml(name.replace('.md', '').replace(/-/g, ' '));
return `<div class="card" style="cursor:pointer" onclick="editMemory('${encodeURIComponent(name)}')">
<div class="flex items-center justify-between mb-2">
<div><span class="card-title">${name.replace('.md', '').replace(/-/g, ' ')}</span></div>
<div><span class="card-title">${safeName}</span></div>
<span class="badge badge-info">${content ? content.split('\n').length : 0} lines</span>
</div>
<pre style="max-height:80px;overflow:hidden;font-size:11px;color:var(--text-muted)">${escapeHtml(preview)}${preview.length >= 200 ? '...' : ''}</pre>
@ -35,8 +36,9 @@ async function renderMemory() {
}
}
async function editMemory(name) {
const display = name.replace('.md', '').replace(/-/g, ' ');
async function editMemory(encodedName) {
const name = decodeURIComponent(encodedName);
const display = escapeHtml(name.replace('.md', '').replace(/-/g, ' '));
let content = '';
try {
const r = await api.getBrainFile(name);
@ -50,11 +52,12 @@ async function editMemory(name) {
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveMemory('${name}')">💾 Save</button>
<button class="btn btn-primary" onclick="saveMemory('${encodeURIComponent(name)}')">💾 Save</button>
`);
}
async function saveMemory(name) {
async function saveMemory(encodedName) {
const name = decodeURIComponent(encodedName);
const content = document.getElementById('memContent').value;
try {
await api.updateBrainFile(name, content);

View File

@ -28,9 +28,9 @@ async function renderPlugins() {
<tbody>
${plugins.map(p => `
<tr>
<td><strong>${p.name}</strong></td>
<td><code>${p.version || '1.0.0'}</code></td>
<td><span class="badge badge-info">${p.type || 'skill'}</span></td>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td><code>${escapeHtml(p.version || '1.0.0')}</code></td>
<td><span class="badge badge-info">${escapeHtml(p.type || 'skill')}</span></td>
<td style="font-size:12px;color:var(--text-muted)">${formatDate(p.installed)}</td>
</tr>
`).join('')}

View File

@ -24,10 +24,10 @@ async function renderPrompts() {
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const preview = content.slice(0, 180);
const lines = content.split('\n').length;
return `<div class="skill-card" onclick="viewPrompt('${name}')">
return `<div class="skill-card" onclick="viewPrompt('${encodeURIComponent(name)}')">
<div class="skill-card-header">
<div class="skill-card-icon">📝</div>
<div class="skill-card-name">${displayName}</div>
<div class="skill-card-name">${escapeHtml(displayName)}</div>
</div>
<div class="skill-card-desc"><pre style="background:none;border:none;padding:0;max-height:100px;overflow:hidden;font-size:11px;color:var(--text-muted)">${escapeHtml(preview)}${preview.length >= 180 ? '...' : ''}</pre></div>
<div class="skill-card-footer"><span class="badge badge-info">${lines} lines</span></div>
@ -38,14 +38,15 @@ async function renderPrompts() {
}
}
async function viewPrompt(name) {
async function viewPrompt(encodedName) {
const name = decodeURIComponent(encodedName);
let content = '';
try {
const prompts = await api.getPrompts();
content = prompts[name] || '';
} catch {}
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const displayName = escapeHtml(name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()));
// Store raw content for clipboard copy (avoids HTML entity encoding issue)
window._promptCopyContent = content;

View File

@ -34,7 +34,7 @@ async function renderSettings() {
</label>
<div class="form-group" style="margin-bottom:0;margin-top:8px">
<label class="form-label">Binary Path</label>
<input id="bin_${a}" class="form-input" value="${(prefs[a] && prefs[a].binary) || a}" style="font-size:12px">
<input id="bin_${a}" class="form-input" value="${escapeHtml((prefs[a] && prefs[a].binary) || a)}" style="font-size:12px">
</div>
</div>
`).join('')}
@ -50,7 +50,7 @@ async function renderSettings() {
</div>
<div class="form-group">
<label class="form-label">Host</label>
<input id="setHost" class="form-input" value="${dashboard.host || '127.0.0.1'}">
<input id="setHost" class="form-input" value="${escapeHtml(dashboard.host || '127.0.0.1')}">
</div>
</div>
<div class="form-group">
@ -67,11 +67,11 @@ async function renderSettings() {
<div class="form-row">
<div class="form-group">
<label class="form-label">Gemini API Key</label>
<input id="keyGemini" class="form-input" type="password" value="${apiKeys.gemini || ''}" placeholder="Enter Gemini API key">
<input id="keyGemini" class="form-input" type="password" value="${escapeHtml(apiKeys.gemini || '')}" placeholder="Enter Gemini API key">
</div>
<div class="form-group">
<label class="form-label">OpenRouter API Key</label>
<input id="keyOpenrouter" class="form-input" type="password" value="${apiKeys.openrouter || ''}" placeholder="Enter OpenRouter API key">
<input id="keyOpenrouter" class="form-input" type="password" value="${escapeHtml(apiKeys.openrouter || '')}" placeholder="Enter OpenRouter API key">
</div>
</div>
</div>

View File

@ -65,11 +65,12 @@ async function renderWizardStep() {
<div class="grid grid-2">
${agents.map(a => {
const sc = statusColor(a.status);
const safeStatus = ({online:'online',offline:'offline',warning:'warning'})[a.status] || 'offline';
return `<div class="agent-card">
<div class="agent-dot ${a.status}" style="width:14px;height:14px"></div>
<div class="agent-dot ${safeStatus}" style="width:14px;height:14px"></div>
<div>
<div style="font-weight:600;font-size:14px">${a.name}</div>
<div style="font-size:12px;color:${sc.text}">${a.status}</div>
<div style="font-weight:600;font-size:14px">${escapeHtml(a.name)}</div>
<div style="font-size:12px;color:${sc.text}">${escapeHtml(a.status)}</div>
</div>
</div>`;
}).join('')}

View File

@ -33,22 +33,24 @@ function renderSkillGrid(skills) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">⚡</div><div class="empty-state-title">No skills installed</div></div>';
return;
}
container.innerHTML = `<div class="grid grid-3" id="skillGrid">${skills.map(s => {
container.innerHTML = `<div class="grid grid-3" id="skillGrid">${skills.map(s => {
const lastScore = s.scores && s.scores.length > 0 ? s.scores[s.scores.length - 1] : null;
const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null;
const icons = ['⚡', '🔧', '📝', '🔍', '🔄', '🎯', '📊', '🛠', '💡', '🧪', '📋', '💾', '💰', '🔄', '🎨'];
const iconIdx = s.name.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % icons.length;
const icon = icons[iconIdx];
return `<div class="skill-card" onclick="showSkillDetail('${s.name}')">
const sName = escapeHtml(s.name);
const sDesc = escapeHtml(s.description || '').slice(0, 120) + ((s.description || '').length > 120 ? '...' : '');
return `<div class="skill-card" onclick="showSkillDetail('${encodeURIComponent(s.name)}')">
<div class="skill-card-header">
<div class="skill-card-icon">${icon}</div>
<div class="skill-card-name">${s.name.replace(/-/g, ' ')}</div>
<div class="skill-card-name">${sName.replace(/-/g, ' ')}</div>
</div>
<div class="skill-card-desc">${s.description ? s.description.slice(0, 120) + (s.description.length > 120 ? '...' : '') : 'No description'}</div>
<div class="skill-card-desc">${sDesc || 'No description'}</div>
<div class="skill-card-footer">
${avg !== null ? `<span class="badge badge-success">${(avg * 100).toFixed(0)}%</span>` : '<span class="badge badge-info">New</span>'}
${s.has_learnings ? '<span class="badge badge-accent">📖</span>' : ''}
<button class="btn btn-sm btn-primary" style="margin-left:auto" onclick="event.stopPropagation();quickRunSkill('${s.name}')"> Run</button>
<button class="btn btn-sm btn-primary" style="margin-left:auto" onclick="event.stopPropagation();quickRunSkill('${encodeURIComponent(s.name)}')"> Run</button>
</div>
</div>`;
}).join('')}</div>`;
@ -61,11 +63,12 @@ function switchSkillView(view) {
document.getElementById('skillsContainer').innerHTML = `<div class="table-wrapper"><table><thead><tr><th>Skill</th><th>Score</th><th>Learnings</th><th></th></tr></thead><tbody>${skills.map(s => {
const lastScore = s.scores && s.scores.length > 0 ? s.scores[s.scores.length - 1] : null;
const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null;
return `<tr onclick="showSkillDetail('${s.name}')" style="cursor:pointer">
<td><strong>${s.name.replace(/-/g, ' ')}</strong></td>
const sName = escapeHtml(s.name);
return `<tr onclick="showSkillDetail('${encodeURIComponent(s.name)}')" style="cursor:pointer">
<td><strong>${sName.replace(/-/g, ' ')}</strong></td>
<td>${avg !== null ? `<span class="badge badge-success">${(avg * 100).toFixed(0)}%</span>` : '<span class="badge badge-info">—</span>'}</td>
<td>${s.has_learnings ? '<span class="badge badge-accent">✓</span>' : '<span class="badge">—</span>'}</td>
<td><button class="btn btn-sm btn-primary" onclick="event.stopPropagation();quickRunSkill('${s.name}')"></button></td>
<td><button class="btn btn-sm btn-primary" onclick="event.stopPropagation();quickRunSkill('${encodeURIComponent(s.name)}')"></button></td>
</tr>`;
}).join('')}</tbody></table></div>`;
} else {
@ -79,7 +82,8 @@ function filterSkills() {
renderSkillGrid(skills);
}
async function showSkillDetail(name) {
async function showSkillDetail(encodedName) {
const name = decodeURIComponent(encodedName);
document.getElementById('skillsContainer').style.display = 'none';
document.getElementById('skillTabs').style.display = 'none';
document.getElementById('skillFilter').style.display = 'none';
@ -92,11 +96,12 @@ async function showSkillDetail(name) {
const scores = skill.score_history || [];
const lastScore = scores.length > 0 ? scores[scores.length - 1] : null;
const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null;
const safeName = escapeHtml(name);
detail.innerHTML = `
<div style="margin-bottom:16px">
<button class="btn btn-ghost" onclick="backToSkills()"> Back to Skills</button>
<button class="btn btn-primary" style="margin-left:8px" onclick="quickRunSkill('${name}')"> Run ${name.replace(/-/g, ' ')}</button>
<button class="btn btn-primary" style="margin-left:8px" onclick="quickRunSkill('${encodeURIComponent(name)}')"> Run ${safeName.replace(/-/g, ' ')}</button>
</div>
<div class="grid grid-2">
<div class="card">
@ -122,9 +127,9 @@ async function showSkillDetail(name) {
<div class="card">
<div class="card-header"><span class="card-title">📁 Context Files</span></div>
${skill.context && skill.context.length > 0
? `<div style="display:flex;flex-wrap:wrap;gap:6px">${skill.context.map(f => `<span class="badge badge-info">${f}</span>`).join('')}</div>`
? `<div style="display:flex;flex-wrap:wrap;gap:6px">${skill.context.map(f => `<span class="badge badge-info">${escapeHtml(f)}</span>`).join('')}</div>`
: '<div style="color:var(--text-muted);font-size:13px">No context files</div>'}
${skill.eval && skill.eval.criteria ? `<div style="margin-top:12px"><strong style="font-size:12px">Eval Criteria:</strong><div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">${skill.eval.criteria.map(c => `<span class="badge badge-accent">${c}</span>`).join('')}</div></div>` : ''}
${skill.eval && skill.eval.criteria ? `<div style="margin-top:12px"><strong style="font-size:12px">Eval Criteria:</strong><div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">${skill.eval.criteria.map(c => `<span class="badge badge-accent">${escapeHtml(c)}</span>`).join('')}</div></div>` : ''}
</div>
</div>
`;
@ -140,8 +145,9 @@ function backToSkills() {
document.getElementById('skillDetail').style.display = 'none';
}
async function quickRunSkill(name) {
const displayName = name.replace(/-/g, ' ');
async function quickRunSkill(encodedName) {
const name = decodeURIComponent(encodedName);
const displayName = escapeHtml(name.replace(/-/g, ' '));
showModal(`Run: ${displayName}`, `
<div class="form-group">
<label class="form-label">Input (optional)</label>
@ -159,11 +165,12 @@ async function quickRunSkill(name) {
<div id="skillResult" style="display:none"></div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="executeSkillRun('${name}')"> Run</button>
<button class="btn btn-primary" onclick="executeSkillRun('${encodeURIComponent(name)}')"> Run</button>
`);
}
async function executeSkillRun(name) {
async function executeSkillRun(encodedName) {
const name = decodeURIComponent(encodedName);
const input = document.getElementById('qrsInput').value;
const agent = document.getElementById('qrsAgent').value;
const runBtn = document.querySelector('#modalContainer .btn-primary');

View File

@ -27,8 +27,8 @@ async function renderStandards() {
html += '<div class="empty-state"><div class="empty-state-icon">📐</div><div class="empty-state-title">No standards defined</div><div class="empty-state-desc">Run "Discover Patterns" to extract conventions from your codebase</div></div>';
} else {
html += `<div class="grid grid-2">${standards.map(s => `
<div class="card" style="cursor:pointer" onclick="viewStandard('${s.name}')">
<div class="card-header"><span class="card-title">${s.name.replace(/-/g, ' ')}</span></div>
<div class="card" style="cursor:pointer" onclick="viewStandard('${encodeURIComponent(s.name)}')">
<div class="card-header"><span class="card-title">${escapeHtml(s.name.replace(/-/g, ' '))}</span></div>
<pre style="max-height:200px;overflow:hidden;font-size:12px">${escapeHtml(s.content.slice(0, 300))}${s.content.length > 300 ? '...' : ''}</pre>
</div>
`).join('')}</div>`;
@ -40,7 +40,8 @@ async function renderStandards() {
}
}
async function viewStandard(name) {
async function viewStandard(encodedName) {
const name = decodeURIComponent(encodedName);
let content = '';
try {
const data = await api.getStandards();
@ -48,7 +49,7 @@ async function viewStandard(name) {
if (std) content = std.content;
} catch {}
showModal(`Standard: ${name.replace(/-/g, ' ')}`, `
showModal(`Standard: ${escapeHtml(name.replace(/-/g, ' '))}`, `
<pre style="white-space:pre-wrap;font-size:12px;max-height:60vh;overflow:auto">${escapeHtml(content)}</pre>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Close</button>

View File

@ -95,6 +95,62 @@ def append_audit(entry: dict):
with open(audit_file, "a") as f:
f.write(json.dumps(entry) + "\n")
def safe_resolve(base: Path, user_path: str) -> Path:
"""Resolve a user-supplied path relative to base, preventing traversal."""
resolved = (base / user_path).resolve()
if not str(resolved).startswith(str(base.resolve())):
raise HTTPException(400, "Invalid path")
return resolved
def safe_extractall(tar: tarfile.TarFile, path: Path):
"""Extract tar archive with path traversal protection."""
for member in tar.getmembers():
member_path = (path / member.name).resolve()
if not str(member_path).startswith(str(path.resolve())):
raise HTTPException(400, f"Blocked path traversal: {member.name}")
tar.extractall(path=path)
# ─── Security Headers Middleware ─────────────────────────────────
class SecurityHeadersMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def send_with_headers(message):
if message["type"] == "http.response.start":
headers = message.get("headers", [])
extra = [
(b"x-content-type-options", b"nosniff"),
(b"x-frame-options", b"DENY"),
(b"x-xss-protection", b"1; mode=block"),
(b"strict-transport-security", b"max-age=31536000; includeSubDomains"),
(b"referrer-policy", b"strict-origin-when-cross-origin"),
]
# Only add CSP for non-API routes (dashboard HTML)
path = scope.get("path", "")
if not path.startswith("/api/"):
csp = (
b"default-src 'self'; "
b"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
b"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
b"font-src 'self' https://fonts.gstatic.com; "
b"img-src 'self' data:; "
b"connect-src 'self' http://127.0.0.1:* http://localhost:*; "
b"frame-ancestors 'none'"
)
extra.append((b"content-security-policy", csp))
message["headers"] = list(headers) + extra
await send(message)
await self.app(scope, receive, send_with_headers)
app.add_middleware(SecurityHeadersMiddleware)
# ─── Agent Discovery (instant filesystem checks) ────────────────────
def check_agent(name: str) -> dict:
@ -144,6 +200,8 @@ def list_brain():
@app.get("/api/brain/{file_name}")
def get_brain_file(file_name: str):
if ".." in file_name or "/" in file_name:
raise HTTPException(400, "Invalid file name")
path = BASE_DIR / "brain" / file_name
if not path.exists() or path.is_dir():
raise HTTPException(404, "File not found")
@ -151,6 +209,8 @@ def get_brain_file(file_name: str):
@app.put("/api/brain/{file_name}")
def update_brain_file(file_name: str, data: BrainUpdate):
if ".." in file_name or "/" in file_name:
raise HTTPException(400, "Invalid file name")
path = BASE_DIR / "brain" / file_name
write_file(path, data.content)
append_audit({"action": "brain_update", "file": file_name})
@ -184,6 +244,8 @@ def list_skills():
@app.get("/api/skills/{name}")
def get_skill(name: str):
if ".." in name or "/" in name:
raise HTTPException(400, "Invalid skill name")
path = BASE_DIR / "skills" / name
if not path.exists():
raise HTTPException(404, "Skill not found")
@ -198,6 +260,8 @@ def get_skill(name: str):
@app.post("/api/skills/{name}/run")
def run_skill(name: str, req: Optional[SkillRunRequest] = None):
if ".." in name or "/" in name:
raise HTTPException(400, "Invalid skill name")
path = BASE_DIR / "skills" / name
if not path.exists():
raise HTTPException(404, "Skill not found")
@ -281,6 +345,8 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
@app.get("/api/skills/{name}/eval")
def get_skill_eval(name: str):
if ".." in name or "/" in name:
raise HTTPException(400, "Invalid skill name")
path = BASE_DIR / "skills" / name / "score-history.json"
if not path.exists():
return {"scores": []}
@ -419,11 +485,13 @@ def create_backup():
@app.post("/api/backup/restore")
def restore_backup(data: BackupRestoreRequest):
if ".." in data.file or "/" in data.file:
raise HTTPException(400, "Invalid backup file")
backup_file = BASE_DIR / "backups" / data.file
if not backup_file.exists():
raise HTTPException(404, "Backup file not found")
with tarfile.open(backup_file, "r:gz") as tar:
tar.extractall(path=BASE_DIR)
safe_extractall(tar, BASE_DIR)
append_audit({"action": "backup_restored", "file": data.file})
return {"status": "restored"}
@ -444,7 +512,11 @@ def get_settings():
sf = BASE_DIR / "data" / "settings.json"
if not sf.exists():
return {}
return json.loads(sf.read_text())
data = json.loads(sf.read_text())
# Mask sensitive values
if "api_keys" in data:
data["api_keys"] = {k: v[:4] + "****" if len(v) > 8 else "****" for k, v in data["api_keys"].items()}
return data
@app.put("/api/settings")
def update_settings(data: SettingsUpdate):
@ -602,6 +674,12 @@ def chat(req: ChatRequest):
agent = req.agent.lower().strip()
if agent not in ["opencode", "hermes", "gemini"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
message = (req.message or "").strip()
if not message:
raise HTTPException(400, "Message cannot be empty")
if len(message) > 10000:
raise HTTPException(400, "Message too long (max 10000 characters)")
req.message = message
user_msg = {
"id": str(uuid.uuid4())[:8],
@ -1188,8 +1266,12 @@ def list_sessions():
except Exception as e:
return {"sessions": [], "error": str(e)}
MAX_SESSION_CONTENT = 2000
@app.get("/api/sessions/{session_id}/replay")
def get_session_replay(session_id: str):
if ".." in session_id or "/" in session_id:
raise HTTPException(400, "Invalid session ID")
try:
sessions_dir = Path.home() / ".local" / "share" / "opencode"
log_file = sessions_dir / "log" / f"{session_id}.log"
@ -1203,8 +1285,8 @@ def get_session_replay(session_id: str):
return {
"session_id": session_id,
"lines": len(lines),
"messages": messages[:100],
"content": content[:5000],
"messages": messages[:50],
"content": content[:MAX_SESSION_CONTENT],
}
return {"session_id": session_id, "messages": [], "content": "Session log not found"}
except Exception as e: