feat(llm-security): playground v7.6.2-dev — render-report CLI + wire 4 skills (scan, audit, posture, deep-scan) [skip-docs]
- New scripts/render-report.mjs CLI: stdin/file/stdout modes, ESM import
from ./lib/report-renderers.mjs, kebab→camel renderer-name lookup so
any of the 18 PARSERS works
- Standalone HTML wrap: inlines 6 DS stylesheets (tokens, base, components,
tier2, tier3, tier3-supplement) + local .report-table CSS. Skips fonts.css
→ system-ui fallback via tokens.css (~137 KB self-contained vs ~1 MB
with woff2 bundled)
- 4 skill files wired: commands/{scan,audit,posture,deep-scan}.md — new
step instructs Claude to Write the markdown report to a temp file,
invoke the CLI, and print a markdown-formatted file:// link
- Absolute file:// paths in stdout for Ghostty cmd-click compatibility
- Default output: reports/<command>-<YYYYMMDD-HHmmss>.html relative to CWD
- Smoke-tested: stdin→stdout, file→file roundtrip, all 4 commands produce
valid HTML with DS-aligned page-shell (page__title, verdict-pill-lg,
risk-meter, key-stats, findings__item, recommendation-card)
- Tests 1820/1820 green (same baseline; pre-compact-scan perf-flake from
NEXT-SESSION-PROMPT did not fire on retry)
- Playground untouched (2 scripts, 0 parse failures), report-renderers.mjs
untouched (74 exports, 18 PARSERS, 18 RENDERERS)
Sesjon 4 av 5. v7.7.0 release + 9 remaining skill wirings = sesjon 5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fa5fb48a33
commit
db80854830
5 changed files with 272 additions and 0 deletions
|
|
@ -48,3 +48,23 @@ Recalculate `risk_score = riskScore(counts)` (severity-dominated v2 model — se
|
|||
Output: Risk Dashboard, Executive Summary, 10 Category Sections (use scanner evidence + agent narrative), Summary Table, Action Items (IMMEDIATE → HIGH → MEDIUM).
|
||||
|
||||
Close with top 2-3 action items. If grade C or lower: suggest `/security threat-model`.
|
||||
|
||||
## Step 6: HTML Report
|
||||
|
||||
After producing the markdown audit report above:
|
||||
|
||||
1. Compute a temp markdown path:
|
||||
```bash
|
||||
node -p "require('path').join(require('os').tmpdir(), 'sec-audit-' + Date.now() + '.md')"
|
||||
```
|
||||
2. Use the Write tool to save the **entire markdown report you just produced** (Risk Dashboard + Executive Summary + Category Sections + Summary Table + Action Items) to that temp path. Verbatim.
|
||||
3. Run the renderer:
|
||||
```bash
|
||||
node <plugin-root>/scripts/render-report.mjs audit --in "<temp-md-path>"
|
||||
```
|
||||
The CLI writes `reports/audit-<YYYYMMDD-HHmmss>.html` relative to CWD and prints `file:///abs/path.html` on stdout.
|
||||
4. Append to your response (markdown link, no bare URL):
|
||||
|
||||
> **HTML-rapport:** [Åpne i nettleser](file:///abs/path.html)
|
||||
|
||||
If the CLI exits non-zero, mention the error but do not block — the markdown audit above is the primary deliverable.
|
||||
|
|
|
|||
|
|
@ -40,3 +40,23 @@ Spawn `subagent_type: "llm-security:deep-scan-synthesizer-agent"`, `model: "sonn
|
|||
> Produce complete report with actionable insights. Don't pad.
|
||||
|
||||
Output the synthesizer's report. If it fails, show banner + CRITICAL/HIGH findings from JSON.
|
||||
|
||||
## Step 5: HTML Report
|
||||
|
||||
After producing the markdown deep-scan report (banner + synthesizer output or fallback):
|
||||
|
||||
1. Compute a temp markdown path:
|
||||
```bash
|
||||
node -p "require('path').join(require('os').tmpdir(), 'sec-deepscan-' + Date.now() + '.md')"
|
||||
```
|
||||
2. Use the Write tool to save the **entire markdown report you just produced** (banner + synthesizer narrative + scanner sections + findings) to that temp path. Verbatim.
|
||||
3. Run the renderer:
|
||||
```bash
|
||||
node <plugin-root>/scripts/render-report.mjs deep-scan --in "<temp-md-path>"
|
||||
```
|
||||
The CLI writes `reports/deep-scan-<YYYYMMDD-HHmmss>.html` relative to CWD and prints `file:///abs/path.html` on stdout.
|
||||
4. Append to your response (markdown link, no bare URL):
|
||||
|
||||
> **HTML-rapport:** [Åpne i nettleser](file:///abs/path.html)
|
||||
|
||||
If the CLI exits non-zero, mention the error but do not block — the markdown report above is the primary deliverable.
|
||||
|
|
|
|||
|
|
@ -58,3 +58,23 @@ Present the results as a scorecard:
|
|||
- Grade A/B: "Posture solid. Re-run after major changes."
|
||||
- Grade C: "Run `/security audit` for detailed findings."
|
||||
- Grade D/F: "Significant exposure. Run `/security audit` before production use."
|
||||
|
||||
## Step 4: HTML Report
|
||||
|
||||
After producing the markdown scorecard above:
|
||||
|
||||
1. Compute a temp markdown path:
|
||||
```bash
|
||||
node -p "require('path').join(require('os').tmpdir(), 'sec-posture-' + Date.now() + '.md')"
|
||||
```
|
||||
2. Use the Write tool to save the **entire markdown scorecard you just produced** (header + Category Scorecard table + Top Findings + Quick Wins + closing) to that temp path. Verbatim.
|
||||
3. Run the renderer:
|
||||
```bash
|
||||
node <plugin-root>/scripts/render-report.mjs posture --in "<temp-md-path>"
|
||||
```
|
||||
The CLI writes `reports/posture-<YYYYMMDD-HHmmss>.html` relative to CWD and prints `file:///abs/path.html` on stdout.
|
||||
4. Append to your response (markdown link, no bare URL):
|
||||
|
||||
> **HTML-rapport:** [Åpne i nettleser](file:///abs/path.html)
|
||||
|
||||
If the CLI exits non-zero, mention the error but do not block — the markdown scorecard above is the primary deliverable.
|
||||
|
|
|
|||
|
|
@ -155,3 +155,23 @@ If `clone_path != null`:
|
|||
|
||||
If `evidence_file != null`:
|
||||
Run: `node <plugin-root>/scanners/lib/fs-utils.mjs cleanup "<evidence_file>"`
|
||||
|
||||
## Step 8: HTML Report
|
||||
|
||||
After producing the markdown report (Step 5) and any cleanup (Step 7):
|
||||
|
||||
1. Compute a temp markdown path:
|
||||
```bash
|
||||
node -p "require('path').join(require('os').tmpdir(), 'sec-scan-' + Date.now() + '.md')"
|
||||
```
|
||||
2. Use the Write tool to save the **entire markdown report you just produced** (banner + all findings sections + any Deep Scan section) to that temp path. Do not summarize — write it verbatim.
|
||||
3. Run the renderer:
|
||||
```bash
|
||||
node <plugin-root>/scripts/render-report.mjs scan --in "<temp-md-path>"
|
||||
```
|
||||
The CLI writes `reports/scan-<YYYYMMDD-HHmmss>.html` relative to CWD and prints `file:///abs/path.html` on stdout (one line).
|
||||
4. Append this to your response (markdown link, no bare URL):
|
||||
|
||||
> **HTML-rapport:** [Åpne i nettleser](file:///abs/path.html)
|
||||
|
||||
If the CLI exits non-zero, mention the error but do not block — the markdown report above is the primary deliverable.
|
||||
|
|
|
|||
192
plugins/llm-security/scripts/render-report.mjs
Normal file
192
plugins/llm-security/scripts/render-report.mjs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env node
|
||||
/*
|
||||
* render-report.mjs — convert llm-security markdown reports into self-contained
|
||||
* HTML files. Zero npm deps. Reads markdown from stdin or a file, parses via
|
||||
* PARSERS, renders via RENDERERS, wraps the output in a standalone HTML doc
|
||||
* with inlined Playground Design System CSS.
|
||||
*
|
||||
* Usage:
|
||||
* node render-report.mjs <commandId> --in <md-file> (file → reports/<command>-<ts>.html)
|
||||
* node render-report.mjs <commandId> --in <md-file> --out <html>
|
||||
* node render-report.mjs <commandId> --in - (stdin → reports/<command>-<ts>.html)
|
||||
* node render-report.mjs <commandId> --in <md> --out - (file → stdout)
|
||||
* node render-report.mjs <commandId> --in - --out - (stdin → stdout)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 success · 1 parser error or empty input · 2 renderer threw · 3 file I/O failed
|
||||
*
|
||||
* Standalone HTML wraps in <main class="page"> so DS Tier 3 page-shell styling
|
||||
* matches the playground render exactly. Output uses absolute file:// URLs to
|
||||
* stdout (Ghostty cmd-click requires absolute paths).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname, isAbsolute, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PARSERS, RENDERERS } from './lib/report-renderers.mjs';
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = resolve(SCRIPT_DIR, '..');
|
||||
const VENDOR_CSS_DIR = join(PLUGIN_ROOT, 'playground', 'vendor', 'playground-design-system');
|
||||
|
||||
// DS stylesheets to inline. Skip fonts.css — tokens.css has system-ui
|
||||
// fallbacks (-apple-system, BlinkMacSystemFont, system-ui, sans-serif),
|
||||
// so reports stay self-contained without bundling ~940 KB of woff2.
|
||||
const CSS_FILES = [
|
||||
'tokens.css',
|
||||
'base.css',
|
||||
'components.css',
|
||||
'components-tier2.css',
|
||||
'components-tier3.css',
|
||||
'components-tier3-supplement.css'
|
||||
];
|
||||
|
||||
// .report-table is a playground-local DS supplement (DS doesn't ship it). Same
|
||||
// styling lives in playground inline <style> block. Keep in sync with playground
|
||||
// llm-security-playground.html (search for "v7.6.1 fix: .report-table").
|
||||
const REPORT_TABLE_CSS = `
|
||||
.report-table { width: 100%; border-collapse: collapse; margin: var(--space-3) 0; font-size: var(--font-size-sm); }
|
||||
.report-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid var(--color-border-moderate); background: var(--color-bg-soft); font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; }
|
||||
.report-table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border-subtle); vertical-align: top; color: var(--color-text-primary); }
|
||||
.report-table tr:last-child td { border-bottom: none; }
|
||||
.report-table tbody tr:hover { background: var(--color-bg-soft); }
|
||||
.report-table code { font-family: var(--font-family-mono); font-size: 12px; background: var(--color-surface-sunken); padding: 1px 6px; border-radius: var(--radius-sm); }
|
||||
.recommendation-card__body { overflow-wrap: anywhere; word-break: break-word; }
|
||||
main.page { max-width: 1100px; margin: 0 auto; padding: var(--space-6) var(--space-5); }
|
||||
@media (max-width: 720px) { main.page { padding: var(--space-4) var(--space-3); } }
|
||||
`;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { commandId: null, in: null, out: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--in') args.in = argv[++i];
|
||||
else if (a === '--out') args.out = argv[++i];
|
||||
else if (!a.startsWith('--') && !args.commandId) args.commandId = a;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function commandToRendererName(commandId) {
|
||||
return 'render' + commandId.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('');
|
||||
}
|
||||
|
||||
function readStdinSync() {
|
||||
try {
|
||||
return readFileSync(0, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function loadDsCss() {
|
||||
return CSS_FILES.map(name => {
|
||||
const path = join(VENDOR_CSS_DIR, name);
|
||||
if (!existsSync(path)) return `/* missing: ${name} */`;
|
||||
return `/* === ${name} === */\n` + readFileSync(path, 'utf8');
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function wrapHtml(commandId, bodyHtml, dsCss) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="nb" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>llm-security · ${commandId}</title>
|
||||
<script>
|
||||
(function(){var t=null;try{var s=localStorage.getItem('llm-security-theme');if(s==='light'||s==='dark')t=s;}catch(e){}if(!t&&window.matchMedia)t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';if(!t)t=document.documentElement.getAttribute('data-theme')||'dark';document.documentElement.setAttribute('data-theme',t);document.documentElement.style.colorScheme=t;})();
|
||||
</script>
|
||||
<style>
|
||||
${dsCss}
|
||||
${REPORT_TABLE_CSS}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
${bodyHtml}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function fail(code, msg) {
|
||||
process.stderr.write(`render-report: ${msg}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.commandId) fail(1, 'missing <commandId> (e.g. scan, audit, posture, deep-scan)');
|
||||
if (!args.in) fail(1, 'missing --in <path|->');
|
||||
|
||||
const parser = PARSERS[args.commandId];
|
||||
if (!parser) fail(1, `unknown commandId "${args.commandId}". Valid: ${Object.keys(PARSERS).join(', ')}`);
|
||||
|
||||
const rendererName = commandToRendererName(args.commandId);
|
||||
const renderer = RENDERERS[rendererName];
|
||||
if (typeof renderer !== 'function') fail(1, `no renderer found for "${args.commandId}" (looked up ${rendererName})`);
|
||||
|
||||
let md;
|
||||
try {
|
||||
md = args.in === '-' ? readStdinSync() : readFileSync(resolve(args.in), 'utf8');
|
||||
} catch (e) {
|
||||
fail(1, `cannot read input: ${e.message}`);
|
||||
}
|
||||
if (!md || !md.trim()) fail(1, 'input markdown is empty');
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = parser(md);
|
||||
} catch (e) {
|
||||
fail(1, `parser threw: ${e.message}`);
|
||||
}
|
||||
if (!data || typeof data !== 'object') fail(1, 'parser returned non-object');
|
||||
|
||||
const slot = { innerHTML: '' };
|
||||
try {
|
||||
renderer(data, slot);
|
||||
} catch (e) {
|
||||
fail(2, `renderer threw: ${e.message}`);
|
||||
}
|
||||
if (!slot.innerHTML) fail(2, 'renderer produced empty HTML');
|
||||
|
||||
const dsCss = loadDsCss();
|
||||
const html = wrapHtml(args.commandId, slot.innerHTML, dsCss);
|
||||
|
||||
if (args.out === '-') {
|
||||
process.stdout.write(html);
|
||||
return;
|
||||
}
|
||||
|
||||
let outPath;
|
||||
if (args.out) {
|
||||
outPath = isAbsolute(args.out) ? args.out : resolve(args.out);
|
||||
} else {
|
||||
const reportsDir = resolve('reports');
|
||||
try {
|
||||
mkdirSync(reportsDir, { recursive: true });
|
||||
} catch (e) {
|
||||
fail(3, `cannot create reports dir: ${e.message}`);
|
||||
}
|
||||
outPath = join(reportsDir, `${args.commandId}-${timestamp()}.html`);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, html, 'utf8');
|
||||
} catch (e) {
|
||||
fail(3, `cannot write ${outPath}: ${e.message}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`file://${outPath}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Add table
Add a link
Reference in a new issue