feat(security): migrate dashboard innerHTML to safe DOM APIs

- Replace innerHTML with document.createElement and replaceChildren in dashboard/index.html
- Add dashboard/domUtils.js for safe HTML escaping and element creation
- Add context, decisions, and test logs
- Update daily task logs

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-14 23:05:24 +00:00
1 parent 45194e7282
commit b92f0efd38
13 files changed
+261 -76

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
{
"cadence": "daily",
"operation": "retrieve",
"servicePath": "src/services/memory/index.js#getRelevantMemories",
"inputs": {
"agentId": "scheduler-memory-daily",
"query": "scheduler daily memory retrieval",
"events": 1
},
"outputs": {
"ingestedCount": 1,
"retrievedCount": 1
},
"status": "ok"
}
+1
View File
@@ -0,0 +1 @@
MEMORY_RETRIEVED
+16
View File
@@ -0,0 +1,16 @@
{
"cadence": "daily",
"operation": "store",
"servicePath": "src/services/memory/index.js#ingestEvents",
"inputs": {
"agentId": "scheduler-memory-daily",
"events": 2
},
"outputs": {
"storedCount": 1,
"summaries": [
"Store memory event A for daily Store memory event B for daily"
]
},
"status": "ok"
}
+1
View File
@@ -0,0 +1 @@
MEMORY_STORED
+30
View File
@@ -0,0 +1,30 @@
// DOM utility functions for safe rendering
/**
* Escapes unsafe HTML characters in a string.
* @param {string} unsafe The string to escape.
* @returns {string} The escaped string.
*/
export function escapeHtml(unsafe) {
if (unsafe == null) return '';
return String(unsafe)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Creates an element with optional class and text content.
* @param {string} tag The tag name.
* @param {string} [className] The class name(s).
* @param {string} [text] The text content.
* @returns {HTMLElement} The created element.
*/
export function createElement(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}
+82 -76
View File
@@ -153,6 +153,7 @@
<script type="module">
import { KIND_APP_DATA } from '../src/constants.mjs';
import { createElement, escapeHtml } from './domUtils.js';
// -----------------------------------------------------------------------
// Configuration — matches bin/torch-lock.mjs defaults
@@ -462,15 +463,81 @@
},
];
summaryBar.innerHTML = cards
.map(
(c) => `
<div class="rounded-lg bg-surface-alt p-4">
<div class="text-xs text-muted mb-1">${c.label}</div>
<div class="text-lg font-semibold ${c.color} mono">${c.value}</div>
</div>`,
)
.join('');
summaryBar.replaceChildren();
for (const c of cards) {
const card = createElement('div', 'rounded-lg bg-surface-alt p-4');
const label = createElement('div', 'text-xs text-muted mb-1', c.label);
const value = createElement('div', `text-lg font-semibold ${c.color} mono`, String(c.value));
card.appendChild(label);
card.appendChild(value);
summaryBar.appendChild(card);
}
}
function createLockCard(lock, now) {
const expired = isExpired(lock);
const isCompleted = lock.status === 'completed';
const ps = platformStyle(lock.platform);
const age = now - lock.createdAt;
const remaining = lock.expiresAt ? lock.expiresAt - now : 0;
const ttlTotal = lock.expiresAt
? lock.expiresAt - lock.createdAt
: 7200;
const ttlPct = expired
? 0
: Math.max(0, Math.min(100, (remaining / ttlTotal) * 100));
let cardBg = 'bg-surface-alt';
let cardBorder = expired ? 'border-border/50 opacity-60' : 'border-border';
if (isCompleted) {
cardBg = 'bg-success/5';
cardBorder = 'border-success/30';
}
const card = createElement('div', `rounded-lg border ${cardBorder} ${cardBg} p-4 flex flex-col gap-3`);
// Header
const header = createElement('div', 'flex items-start justify-between');
const left = createElement('div', 'flex items-center gap-2');
if (!expired && !isCompleted) {
left.appendChild(createElement('span', 'pulse-dot bg-success mt-1 shrink-0'));
}
const meta = createElement('div');
meta.appendChild(createElement('div', 'font-medium text-text-strong text-sm', lock.agent));
meta.appendChild(createElement('div', 'text-xs text-muted', `${lock.cadence} \u00B7 ${lock.date}`));
left.appendChild(meta);
header.appendChild(left);
const badge = createElement('span', `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ps.bg} ${ps.text}`, ps.label);
header.appendChild(badge);
card.appendChild(header);
// Grid
const grid = createElement('div', 'grid grid-cols-2 gap-x-4 gap-y-1 text-xs');
grid.appendChild(createElement('div', 'text-muted', 'Started'));
grid.appendChild(createElement('div', 'mono text-text', fmtTime(lock.createdAt)));
grid.appendChild(createElement('div', 'text-muted', 'Age'));
grid.appendChild(createElement('div', 'mono text-text', fmtDuration(age)));
grid.appendChild(createElement('div', 'text-muted', 'TTL remaining'));
grid.appendChild(createElement('div', `mono ${expired ? 'text-danger' : 'text-text'}`, fmtDuration(remaining)));
grid.appendChild(createElement('div', 'text-muted', 'Status'));
grid.appendChild(createElement('div', `mono ${expired ? 'text-danger' : 'text-success'}`, expired ? 'expired' : lock.status));
card.appendChild(grid);
// Progress Bar
const barContainer = createElement('div', 'w-full bg-border/30 rounded-full overflow-hidden');
const bar = createElement('div', `ttl-bar ${expired ? 'bg-danger' : 'bg-success'}`);
bar.style.width = `${ttlPct}%`;
barContainer.appendChild(bar);
card.appendChild(barContainer);
// Footer (Event ID)
const footer = createElement('div', 'text-xs text-muted mono truncate', lock.eventId ? lock.eventId.slice(0, 16) + '\u2026' : 'no id');
footer.title = lock.eventId || '';
card.appendChild(footer);
return card;
}
function renderLocks() {
@@ -484,8 +551,7 @@
renderSummary(locks);
if (locks.length === 0) {
lockGrid.innerHTML = '';
lockGrid.appendChild(emptyState);
lockGrid.replaceChildren(emptyState);
emptyState.textContent =
lockStore.size === 0
? 'Waiting for lock events from relays\u2026'
@@ -493,67 +559,8 @@
return;
}
lockGrid.innerHTML = locks
.map((lock) => {
const expired = isExpired(lock);
const isCompleted = lock.status === 'completed';
const ps = platformStyle(lock.platform);
const age = now - lock.createdAt;
const remaining = lock.expiresAt ? lock.expiresAt - now : 0;
const ttlTotal = lock.expiresAt
? lock.expiresAt - lock.createdAt
: 7200;
const ttlPct = expired
? 0
: Math.max(0, Math.min(100, (remaining / ttlTotal) * 100));
let cardBg = 'bg-surface-alt';
let cardBorder = expired ? 'border-border/50 opacity-60' : 'border-border';
if (isCompleted) {
cardBg = 'bg-success/5';
cardBorder = 'border-success/30';
}
return `
<div class="rounded-lg border ${cardBorder} ${cardBg} p-4 flex flex-col gap-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
${expired || isCompleted ? '' : '<span class="pulse-dot bg-success mt-1 shrink-0"></span>'}
<div>
<div class="font-medium text-text-strong text-sm">${lock.agent}</div>
<div class="text-xs text-muted">${lock.cadence} &middot; ${lock.date}</div>
</div>
</div>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ps.bg} ${ps.text}">
${ps.label}
</span>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div class="text-muted">Started</div>
<div class="mono text-text">${fmtTime(lock.createdAt)}</div>
<div class="text-muted">Age</div>
<div class="mono text-text">${fmtDuration(age)}</div>
<div class="text-muted">TTL remaining</div>
<div class="mono ${expired ? 'text-danger' : 'text-text'}">${fmtDuration(remaining)}</div>
<div class="text-muted">Status</div>
<div class="mono ${expired ? 'text-danger' : 'text-success'}">${expired ? 'expired' : lock.status}</div>
</div>
<div class="w-full bg-border/30 rounded-full overflow-hidden">
<div
class="ttl-bar ${expired ? 'bg-danger' : 'bg-success'}"
style="width: ${ttlPct}%"
></div>
</div>
<div class="text-xs text-muted mono truncate" title="${lock.eventId}">
${lock.eventId ? lock.eventId.slice(0, 16) + '\u2026' : 'no id'}
</div>
</div>`;
})
.join('');
const cards = locks.map((lock) => createLockCard(lock, now));
lockGrid.replaceChildren(...cards);
}
// -----------------------------------------------------------------------
@@ -564,13 +571,12 @@
if (!isAlive()) return;
if (!rawLogInitialized) {
rawLog.innerHTML = '';
rawLog.replaceChildren();
rawLogInitialized = true;
}
const line = document.createElement('div');
line.className = 'mb-2 pb-2 border-b border-border/30';
const ts = new Date(event.created_at * 1000).toISOString();
line.textContent = `[${ts}] ${JSON.stringify(event).slice(0, 300)}`;
const text = `[${ts}] ${JSON.stringify(event).slice(0, 300)}`;
const line = createElement('div', 'mb-2 pb-2 border-b border-border/30', text);
rawLog.prepend(line);
// Keep log bounded
@@ -0,0 +1,12 @@
# Context: innerHTML Migration for dashboard/index.html
- **Date:** 2026-02-14
- **Agent:** innerhtml-migration-agent
- **Target File:** `dashboard/index.html`
- **Reason:** High-count file (4 assignments) identified by `check-innerhtml.mjs`.
- **Node Version:** v22.22.0
- **Plan:**
1. Migrate `summaryBar` and `lockGrid` rendering to use `document.createElement`.
2. Use `replaceChildren()` for clearing containers.
3. Create `dashboard/domUtils.js` for `escapeHtml` helper.
4. Verify with linting and manual code review (smoke test logic).
@@ -0,0 +1,22 @@
# Decisions: dashboard/index.html innerHTML Migration
## Strategy
We are migrating `innerHTML` to safe DOM APIs (`textContent`, `createElement`, `appendChild`, `replaceChildren`).
## Decisions
1. **Helper Function**: Created `dashboard/domUtils.js` to host `escapeHtml`. Although we are moving to `createElement` which inherently escapes text in `textContent`, `escapeHtml` is useful if we ever need to set attributes that might be sensitive (though less critical there) or if we fallback to `innerHTML` (which we are trying to avoid).
* *Update*: Actually, for `lockGrid`, reconstructing the entire card with `createElement` is verbose but safer. We will do that. `escapeHtml` might not be needed if we strictly use `textContent`, but it's good to have available.
2. **`summaryBar`**:
* **Before**: Template literal with `innerHTML`.
* **After**: `document.createElement('div')`, loop through cards, create elements, set classes and `textContent`.
3. **`lockGrid`**:
* **Before**: Complex template literal with `innerHTML`.
* **After**: `createLockCard(lock)` function that builds the DOM tree for a card. This ensures no unescaped user data (agent name, cadence, etc.) can be injected.
* **Risk**: CSS classes must be copied exactly.
4. **Clearing Content**:
* **Before**: `el.innerHTML = ''`.
* **After**: `el.replaceChildren()`. This is faster and safer.
@@ -0,0 +1,27 @@
# Test Log: dashboard/index.html innerHTML Migration
- **Date:** 2026-02-14
- **Agent:** innerhtml-migration-agent
- **Test Command:** `node scripts/check-innerhtml.mjs --report`
## Results
### Before
`dashboard/index.html`: 4 assignments
### After
`dashboard/index.html`: 0 assignments (not listed in report)
### Linting
`npm run lint` passed (warnings unrelated to changes).
### Verification
- `dashboard/domUtils.js` created and verified.
- `dashboard/index.html` modified to use `createElement` and `replaceChildren`.
- `innerHTML` usage removed from `dashboard/index.html`.
- **Note**: The `check-innerhtml.mjs` script does not support `--update` or `BASELINE` tracking, so the baseline update step was skipped.
## Manual Verification Simulation
- Verified that `createElement` correctly mimics the structure of the original `innerHTML` templates.
- Ensured `escapeHtml` is available (though `createElement` + `textContent` handles escaping naturally for text nodes).
- Checked for potential regressions: None found in static analysis.
+10
View File
@@ -0,0 +1,10 @@
# TODO: dashboard/index.html innerHTML Migration
- [ ] `summaryBar.innerHTML = cards.map(...).join('')` (Line ~436)
- Strategy: Replace with `document.createElement` loop and `replaceChildren`.
- [ ] `lockGrid.innerHTML = ''` (Line ~448)
- Strategy: `lockGrid.replaceChildren()` + `appendChild(emptyState)`.
- [ ] `lockGrid.innerHTML = locks.map(...).join('')` (Line ~455)
- Strategy: Create `createLockCard` function using `document.createElement`, then `lockGrid.replaceChildren(...cards)`.
- [ ] `rawLog.innerHTML = ''` (Line ~534)
- Strategy: `rawLog.replaceChildren()`.
@@ -0,0 +1,21 @@
---
agent: innerhtml-migration-agent
status: completed
date: 2026-02-14
---
# innerHTML Migration: dashboard/index.html
- **Target:** `dashboard/index.html`
- **Action:** Replaced unsafe `innerHTML` assignments with `document.createElement`, `textContent`, and `replaceChildren`.
- **Helpers:** Created `dashboard/domUtils.js` for `escapeHtml`.
- **Verification:**
- `npm run lint` passed.
- `node scripts/check-innerhtml.mjs --report` confirmed 0 assignments in `dashboard/index.html`.
- Manual verification (simulated) of DOM structure.
- **Artifacts:**
- `src/context/CONTEXT_2026-02-14T23-00-00Z.md`
- `src/todo/TODO_2026-02-14T23-00-00Z.md`
- `src/decisions/DECISIONS_2026-02-14T23-00-00Z.md`
- `src/test_logs/TEST_LOG_2026-02-14T23-00-00Z.md`
- `dashboard/domUtils.js`
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+24
View File
@@ -0,0 +1,24 @@
from playwright.sync_api import sync_playwright
def verify_dashboard():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
try:
page.goto("http://localhost:4173/dashboard/")
# Wait for title
page.wait_for_selector("h1:has-text('TORCH Agent Dashboard')")
# Wait a bit for JS to run and potentially render the summary bar
page.wait_for_timeout(3000)
# Take screenshot
page.screenshot(path="verification/dashboard.png")
print("Screenshot saved to verification/dashboard.png")
except Exception as e:
print(f"Error: {e}")
finally:
browser.close()
if __name__ == "__main__":
verify_dashboard()