fix: security, correctness, and test hardening from review

- routes.py: reject glob wildcards (* ? [ ]) in skill name param to
  prevent rglob wildcard injection when serving linked files
- panels.js: replace inline onclick+esc() with data-* attributes and
  addEventListener for skill tag removal and linked-file clicks;
  esc() is HTML-safe but not JS-safe -- apostrophes in names caused
  JS syntax errors and _cronSelectedSkills array corruption
- ui.js: fix _fmtTokens(null/undefined) returning 'null'/'undefined'
  by guarding with (!n||n<0) -> '0'; add data-role attribute to msg-row
  elements so usage badge correctly targets the last assistant row
  instead of the last row regardless of speaker
- tests: rename test_sprint24.py -> test_sprint23.py (wrong sprint #);
  add 3 new tests: path traversal rejection, wildcard name rejection,
  cron create with skills; strengthen existing tests to assert field
  presence explicitly (was using .get(field, 0)==0 which never caught
  a missing field)
This commit is contained in:
Nathan Esquenazi
2026-04-04 01:54:53 +00:00
parent df06c1cdca
commit c1dcd73502
5 changed files with 180 additions and 287 deletions

View File

@@ -112,7 +112,12 @@ function _renderCronSkillTags(){
for(const name of _cronSelectedSkills){
const tag=document.createElement('span');
tag.className='skill-tag';
tag.innerHTML=esc(name)+'<span class="remove-tag" onclick="this.parentElement.remove();_cronSelectedSkills=_cronSelectedSkills.filter(s=>s!==\''+esc(name)+'\')">×</span>';
tag.dataset.skill=name;
const rm=document.createElement('span');
rm.className='remove-tag';rm.textContent='×';
rm.onclick=()=>{_cronSelectedSkills=_cronSelectedSkills.filter(s=>s!==name);tag.remove();};
tag.appendChild(document.createTextNode(name));
tag.appendChild(rm);
wrap.appendChild(tag);
}
}
@@ -414,13 +419,17 @@ async function openSkill(name, el) {
for (const [cat, files] of categories) {
html += `<div class="skill-linked-section"><h4>${esc(cat)}</h4>`;
for (const f of files) {
html += `<a class="skill-linked-file" onclick="openSkillFile('${esc(name)}','${esc(f)}');return false" href="#">${esc(f)}</a>`;
html += `<a class="skill-linked-file" href="#" data-skill-name="${esc(name)}" data-skill-file="${esc(f)}">${esc(f)}</a>`;
}
html += '</div>';
}
html += '</div>';
}
$('previewMd').innerHTML = html;
// Wire linked-file clicks via data attributes (avoids inline JS XSS with apostrophes)
$('previewMd').querySelectorAll('.skill-linked-file').forEach(a=>{
a.addEventListener('click',e=>{e.preventDefault();openSkillFile(a.dataset.skillName,a.dataset.skillFile);});
});
$('previewArea').classList.add('visible');
$('fileTree').style.display = 'none';
} catch(e) { setStatus('Could not load skill: ' + e.message); }

View File

@@ -82,7 +82,7 @@ let _scrollPinned=true;
_scrollPinned=nearBottom;
});
})();
function _fmtTokens(n){if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
function scrollIfPinned(){
if(!_scrollPinned) return;
@@ -426,7 +426,7 @@ function renderMessages(){
inner.appendChild(thinkRow);
}
const row=document.createElement('div');row.className='msg-row';
row.dataset.msgIdx=rawIdx;
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
let filesHtml='';
if(m.attachments&&m.attachments.length)
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">&#128206; ${esc(f)}</div>`).join('')}</div>`;
@@ -488,9 +488,11 @@ function renderMessages(){
else inner.appendChild(frag);
}
}
// Render per-turn usage badge on the last assistant message (if usage data exists)
// Render usage badge on the last assistant message row (if usage data exists)
if(S.session&&(S.session.input_tokens||S.session.output_tokens)){
const lastAssist=inner.querySelector('.msg-row:last-child');
const rows=inner.querySelectorAll('.msg-row');
let lastAssist=null;
for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}}
if(lastAssist&&!lastAssist.querySelector('.msg-usage')){
const usage=document.createElement('div');
usage.className='msg-usage';