feat(ql3): export redacted cluster evidence bundle

This commit is contained in:
whyour
2026-08-16 07:07:32 +08:00
parent 7955d55629
commit 4f62a86d15
17 changed files with 1308 additions and 25 deletions
+9
View File
@@ -18,6 +18,15 @@ responses and a closed CSP, renders all returned data only via `textContent`,
and keeps start/diagnose/cancel, polling, cache, WebSocket, ServiceWorker and
legacy session authority absent.
The fourth digest-bound Console asset builds an explicit redacted evidence
bundle from only the current page's completed reads. The in-memory ledger is
bounded to the newest 16 entries and 8 MiB; export makes no network request and
produces at most 512 KiB of UTF-8 JSON. Identifiers are correlated only through
per-bundle typed aliases, while free text, names, paths, commands, inputs,
outputs, environment, errors, credentials, unknown fields and Copilot model
text are omitted. Per-fact and top-level SHA-256 values provide self-integrity,
not a server signature, durable audit or action authority.
The reviewed operator-workstation setup, private-file ceremony, release
verification, preflight and session lifecycle are documented in
`deploy/console/ql3-cluster-copilot/README.md`. Native execution binds host
@@ -315,6 +315,25 @@ h3 {
padding-bottom: 1rem;
border-bottom: 1px solid var(--line);
}
.evidence-actions {
display: grid;
justify-items: end;
gap: 0.55rem;
}
.ledger-meta {
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.64rem;
color: var(--muted);
}
.evidence-buttons {
display: grid;
grid-template-columns: repeat(2, minmax(0, auto));
gap: 0.45rem;
}
.evidence-buttons button {
padding: 0.52rem 0.65rem;
font-size: 0.72rem;
}
.status-chip {
border: 1px solid currentColor;
border-radius: 999px;
@@ -483,6 +502,17 @@ footer {
.evidence-panel {
padding: 1.1rem;
}
.evidence-header {
flex-direction: column;
}
.evidence-actions {
width: 100%;
justify-items: start;
}
.evidence-buttons {
width: 100%;
grid-template-columns: 1fr 1fr;
}
.ledger-entry {
padding-left: 2.8rem;
}
@@ -32,6 +32,7 @@
workflow_event_list: 'Workflow Run Events',
workflow_step_list: 'Workflow Run Steps',
});
const bundleApi = globalThis.QingLongEvidenceBundle;
const sessionForm = document.getElementById('session-form');
const sessionInput = document.getElementById('session-token');
const controls = document.getElementById('console-controls');
@@ -39,8 +40,14 @@
const emptyState = document.getElementById('empty-state');
const message = document.getElementById('message');
const statusChip = document.getElementById('status-chip');
const ledgerMeta = document.getElementById('ledger-meta');
const exportButton = document.getElementById('export-evidence');
const clearButton = document.getElementById('clear-evidence');
const evidenceRecords = [];
let sessionToken = '';
let busy = false;
let exporting = false;
let evidenceBytes = 0;
const value = function (id) {
return document.getElementById(id).value.trim();
@@ -55,6 +62,21 @@
message.dataset.tone = tone || 'neutral';
};
const updateLedgerState = function () {
const count = evidenceRecords.length;
ledgerMeta.textContent =
String(count) +
'/' +
String(bundleApi.limits.maximumRecords) +
' 条 · ' +
String(Math.ceil(evidenceBytes / 1024)) +
' KiB 原始事实';
emptyState.hidden = count !== 0;
ledger.hidden = count === 0;
exportButton.disabled = busy || exporting || count === 0;
clearButton.disabled = busy || exporting || count === 0;
};
const setBusy = function (next) {
busy = next;
document.querySelectorAll('[data-read]').forEach(function (button) {
@@ -67,6 +89,7 @@
statusChip.textContent = '只读就绪';
statusChip.dataset.tone = 'success';
}
updateLedgerState();
};
const base = function (operation) {
@@ -179,6 +202,14 @@
const appendEvidence = function (operation, request, response) {
const fact = response.result.result;
const observedAtMs = Date.now();
const record = {
operation: operation,
observedAtMs: observedAtMs,
request: request,
fact: fact,
};
const recordBytes = bundleApi.measureClusterConsoleEvidenceRecord(record);
const entry = document.createElement('li');
entry.className = 'ledger-entry';
const header = document.createElement('header');
@@ -189,7 +220,7 @@
time.textContent = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'short',
timeStyle: 'medium',
}).format(new Date());
}).format(new Date(observedAtMs));
output.tabIndex = 0;
output.textContent = JSON.stringify(fact, null, 2);
header.append(title, time);
@@ -205,8 +236,20 @@
entry.append(button);
}
ledger.prepend(entry);
emptyState.hidden = true;
ledger.hidden = false;
evidenceRecords.push({ record: record, bytes: recordBytes, entry: entry });
evidenceBytes += recordBytes;
let evicted = 0;
while (
evidenceRecords.length > bundleApi.limits.maximumRecords ||
evidenceBytes > bundleApi.limits.maximumRawBytes
) {
const oldest = evidenceRecords.shift();
evidenceBytes -= oldest.bytes;
oldest.entry.remove();
evicted += 1;
}
updateLedgerState();
return evicted;
};
const execute = async function (operation, prepared) {
@@ -236,9 +279,13 @@
: 'console_request_failed',
);
}
appendEvidence(operation, body, responseBody);
const evicted = appendEvidence(operation, body, responseBody);
setMessage(
labels[operation] + ' 已加入本页证据账本。刷新页面会清空。',
labels[operation] +
' 已加入本页证据账本。' +
(evicted === 0
? '刷新页面会清空。'
: '为保持容量上限,已淘汰最旧记录。'),
'success',
);
} catch (error) {
@@ -254,6 +301,72 @@
}
};
const clearEvidence = function () {
for (const evidence of evidenceRecords) evidence.entry.remove();
evidenceRecords.length = 0;
evidenceBytes = 0;
statusChip.textContent = '账本已清空';
statusChip.dataset.tone = 'success';
updateLedgerState();
setMessage('本页证据账本已清空;没有向服务端发送请求。', 'success');
};
const exportEvidence = async function () {
if (busy || exporting || evidenceRecords.length === 0) return;
exporting = true;
updateLedgerState();
setMessage('正在本页内存中生成脱敏证据包…');
try {
const generatedAtMs = Date.now();
const bundle = await bundleApi.createClusterConsoleEvidenceBundle(
evidenceRecords.map(function (evidence) {
return evidence.record;
}),
generatedAtMs,
);
const encoded = bundleApi.serializeClusterConsoleEvidenceBundle(bundle);
const blob = new Blob([encoded], {
type: 'application/json;charset=utf-8',
});
const objectUrl = URL.createObjectURL(blob);
try {
const anchor = document.createElement('a');
anchor.download =
'qinglong-cluster-evidence-' +
new Date(generatedAtMs).toISOString().replaceAll(':', '-') +
'.json';
anchor.href = objectUrl;
anchor.rel = 'noopener';
document.body.append(anchor);
anchor.click();
anchor.remove();
} finally {
URL.revokeObjectURL(objectUrl);
}
statusChip.textContent = '脱敏包已生成';
statusChip.dataset.tone = 'success';
setMessage(
'已下载 ' +
String(bundle.source.entryCount) +
' 条脱敏事实;未发起额外 Cluster 读取。',
'success',
);
} catch (error) {
statusChip.textContent = '导出失败';
statusChip.dataset.tone = 'failed';
setMessage(
'无法导出:' +
(error instanceof Error
? error.message
: 'cluster_evidence_bundle_failed'),
'error',
);
} finally {
exporting = false;
updateLedgerState();
}
};
sessionForm.addEventListener('submit', function (event) {
event.preventDefault();
const candidate = sessionInput.value.trim();
@@ -290,8 +403,18 @@
});
});
exportButton.addEventListener('click', function () {
void exportEvidence();
});
clearButton.addEventListener('click', clearEvidence);
window.addEventListener('pagehide', function () {
sessionToken = '';
evidenceRecords.length = 0;
evidenceBytes = 0;
ledger.textContent = '';
});
updateLedgerState();
})();
@@ -0,0 +1,641 @@
'use strict';
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module && module.exports) {
module.exports = api;
}
if (root && typeof root === 'object') {
Object.defineProperty(root, 'QingLongEvidenceBundle', {
configurable: false,
enumerable: false,
value: api,
writable: false,
});
}
})(typeof globalThis === 'object' ? globalThis : this, function () {
const schema = 'qinglong/cluster-console-redacted-evidence-bundle@v1';
const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1';
const limits = Object.freeze({
maximumArrayItems: 64,
maximumBundleBytes: 512 * 1024,
maximumDepth: 16,
maximumEntryFactBytes: 2 * 1024 * 1024 + 4 * 1024,
maximumObjectKeys: 256,
maximumRawBytes: 8 * 1024 * 1024,
maximumRecords: 16,
});
const operations = Object.freeze([
'inspect',
'output',
'run_list',
'run_read',
'run_event_list',
'run_step_list',
'task_list',
'task_read',
'workflow_list',
'workflow_run_list',
'workflow_run_read',
'workflow_event_list',
'workflow_step_list',
]);
const operationSet = new Set(operations);
const requestFields = Object.freeze({
inspect: ['projectId', 'requestId', 'sourceRunId'],
output: ['projectId', 'requestId', 'sourceRunId'],
run_list: [
'afterCreatedAtMs',
'afterRunId',
'limit',
'projectId',
'requestId',
],
run_read: ['projectId', 'requestId', 'runId'],
run_event_list: [
'afterSequence',
'limit',
'projectId',
'requestId',
'runId',
],
run_step_list: [
'afterStepKey',
'afterStepRunId',
'limit',
'projectId',
'requestId',
'runId',
],
task_list: ['afterTaskId', 'limit', 'projectId', 'requestId'],
task_read: ['projectId', 'requestId', 'taskId'],
workflow_list: ['packageName', 'projectId', 'requestId'],
workflow_run_list: [
'afterAdmittedAtMs',
'afterRunId',
'limit',
'packageName',
'projectId',
'requestId',
'workflowId',
],
workflow_run_read: [
'packageName',
'projectId',
'requestId',
'runId',
'workflowId',
],
workflow_event_list: [
'afterSequence',
'limit',
'packageName',
'projectId',
'requestId',
'runId',
'workflowId',
],
workflow_step_list: [
'afterStepKey',
'afterStepRunId',
'limit',
'packageName',
'projectId',
'requestId',
'runId',
'workflowId',
],
});
const identifierDomains = Object.freeze({
afterRunId: 'run',
afterStepKey: 'step',
afterStepRunId: 'step',
afterTaskId: 'task',
artifactId: 'artifact',
contentDigest: 'digest',
diagnosisRunId: 'run',
executionId: 'execution',
id: 'identifier',
modelId: 'model',
outputRef: 'artifact',
packageName: 'package',
projectId: 'project',
providerId: 'provider',
requestId: 'request',
runId: 'run',
sourceRunId: 'run',
stepKey: 'step',
stepRunId: 'step',
taskId: 'task',
triggerId: 'trigger',
workflowId: 'workflow',
workerId: 'worker',
});
const safeContainers = new Set([
'attempts',
'counts',
'events',
'items',
'metadata',
'next',
'reference',
'run',
'runs',
'source',
'step',
'steps',
'summary',
'target',
'task',
'tasks',
'usage',
'workflow',
'workflows',
]);
const safeBooleans = new Set([
'active',
'archived',
'available',
'cancelRequested',
'enabled',
'hasMore',
'outputAvailable',
'ready',
'replayed',
'tailComplete',
'terminal',
'truncated',
]);
const safeEnumKeys = new Set([
'finishReason',
'kind',
'operation',
'outcome',
'stage',
'status',
]);
const safeEnumValues = new Set([
'accepted',
'active',
'admission',
'available',
'blocked',
'cancelled',
'completed',
'completion',
'dispatch',
'dispatching',
'disabled',
'enabled',
'execution',
'failed',
'finalization',
'installed',
'local',
'lost',
'missing',
'model',
'not_found',
'pending',
'post_model',
'pre_model',
'prompt',
'quarantined',
'queued',
'ready',
'recovery',
'rejected',
'remote',
'retained',
'retired',
'retry_wait',
'run',
'running',
'skipped',
'staged',
'staging',
'step',
'stop',
'succeeded',
'system',
'task',
'terminal',
'timed_out',
'tool',
'trigger',
'unknown',
'unavailable',
'workflow',
]);
const sensitiveKey =
/credential|token|authorization|secret|session|password|cookie|private|keyring/iu;
const freeTextKey =
/text|content|stdout|stderr|command|input|output|environment|reason|error|message|description|name|path|url|uri|host|endpoint/iu;
const numericKey =
/^(?:schemaVersion|version|revision|sequence|attempt|priority|limit|offset|size|total|count|[A-Za-z0-9_]*(?:AtMs|TimeMs|DurationMs|Bytes|Tokens|Micros|Sequence|Version|Count|Limit|Offset|Size|Total))$/u;
const schemaValue = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
class ClusterConsoleEvidenceBundleError extends TypeError {
constructor() {
super('Cluster Console evidence bundle input is invalid');
this.name = 'ClusterConsoleEvidenceBundleError';
this.code = 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID';
}
}
const invalid = function () {
throw new ClusterConsoleEvidenceBundleError();
};
const plainObject = function (value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
const safeInteger = function (value) {
return Number.isSafeInteger(value) && value >= 0;
};
const canonicalValue = function (value, depth, stack) {
if (depth > limits.maximumDepth) return invalid();
if (value === null) return 'null';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'number') {
if (!Number.isFinite(value)) return invalid();
return JSON.stringify(Object.is(value, -0) ? 0 : value);
}
if (typeof value === 'string') return JSON.stringify(value);
if (!value || typeof value !== 'object') return invalid();
if (stack.has(value)) return invalid();
stack.add(value);
try {
if (Array.isArray(value)) {
if (value.length > limits.maximumArrayItems) return invalid();
return (
'[' +
value
.map(function (item) {
return canonicalValue(item, depth + 1, stack);
})
.join(',') +
']'
);
}
if (!plainObject(value)) return invalid();
const keys = Object.keys(value).sort();
if (keys.length > limits.maximumObjectKeys) return invalid();
return (
'{' +
keys
.map(function (key) {
return (
JSON.stringify(key) +
':' +
canonicalValue(value[key], depth + 1, stack)
);
})
.join(',') +
'}'
);
} finally {
stack.delete(value);
}
};
const canonicalize = function (value) {
return canonicalValue(value, 0, new WeakSet());
};
const utf8Bytes = function (value) {
return new TextEncoder().encode(value);
};
const sha256 = async function (value, cryptography) {
if (
!cryptography ||
!cryptography.subtle ||
typeof cryptography.subtle.digest !== 'function'
) {
return invalid();
}
const bytes = utf8Bytes(value);
const digest = await cryptography.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), function (byte) {
return byte.toString(16).padStart(2, '0');
}).join('');
};
const exactKeys = function (value, expected) {
if (!plainObject(value)) return false;
const actual = Object.keys(value).sort();
const normalized = expected.slice().sort();
return (
actual.length === normalized.length &&
actual.every(function (key, index) {
return key === normalized[index];
})
);
};
const validateRecord = function (record) {
if (
!exactKeys(record, ['fact', 'observedAtMs', 'operation', 'request']) ||
!operationSet.has(record.operation) ||
!safeInteger(record.observedAtMs) ||
!plainObject(record.fact)
) {
return invalid();
}
const fields = requestFields[record.operation];
if (
!exactKeys(record.request, ['operation', 'schema'].concat(fields)) ||
record.request.schema !== requestSchema ||
record.request.operation !== record.operation
) {
return invalid();
}
canonicalize(record.request);
const factCanonical = canonicalize(record.fact);
const factBytes = utf8Bytes(factCanonical).byteLength;
if (factBytes < 2 || factBytes > limits.maximumEntryFactBytes) {
return invalid();
}
return Object.freeze({ factBytes, factCanonical });
};
const createAliaser = function () {
const counters = new Map();
const values = new Map();
return function (domain, value) {
if (value === null) return null;
if (typeof value !== 'string' || value.length < 1 || value.length > 512) {
return invalid();
}
const identity = domain + '\0' + value;
const existing = values.get(identity);
if (existing) return existing;
const next = (counters.get(domain) || 0) + 1;
counters.set(domain, next);
const alias = domain + '-' + String(next).padStart(3, '0');
values.set(identity, alias);
return alias;
};
};
const sanitizeValue = function (value, operation, alias, state, depth) {
if (depth > limits.maximumDepth) return invalid();
if (Array.isArray(value)) {
return value.slice(0, limits.maximumArrayItems).map(function (item) {
if (plainObject(item) || Array.isArray(item)) {
return sanitizeValue(item, operation, alias, state, depth + 1);
}
state.omittedFieldCount += 1;
return null;
});
}
if (!plainObject(value)) return invalid();
const result = {};
for (const key of Object.keys(value).sort()) {
const candidate = value[key];
const domain = identifierDomains[key];
if (sensitiveKey.test(key)) {
state.omittedFieldCount += 1;
continue;
}
if (domain) {
if (candidate === null || typeof candidate === 'string') {
result[key] = alias(domain, candidate);
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (/Digest$/u.test(key)) {
if (candidate === null || typeof candidate === 'string') {
result[key] = alias('digest', candidate);
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (key === 'schema') {
if (typeof candidate === 'string' && schemaValue.test(candidate)) {
result[key] = candidate;
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (safeEnumKeys.has(key)) {
if (candidate === null) {
result[key] = null;
} else if (
typeof candidate === 'string' &&
(key === 'operation'
? operationSet.has(candidate)
: safeEnumValues.has(candidate))
) {
result[key] = candidate;
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (safeBooleans.has(key)) {
if (typeof candidate === 'boolean' || candidate === null) {
result[key] = candidate;
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (numericKey.test(key)) {
if (safeInteger(candidate) || candidate === null) {
result[key] = candidate;
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (freeTextKey.test(key) || key === 'result') {
state.omittedFieldCount += 1;
continue;
}
if (safeContainers.has(key)) {
if (plainObject(candidate) || Array.isArray(candidate)) {
result[key] = sanitizeValue(
candidate,
operation,
alias,
state,
depth + 1,
);
} else if (candidate === null) {
result[key] = null;
} else {
state.omittedFieldCount += 1;
}
continue;
}
state.omittedFieldCount += 1;
}
return result;
};
const deepFreeze = function (value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
};
const measureClusterConsoleEvidenceRecord = function (record) {
return validateRecord(record).factBytes;
};
const createClusterConsoleEvidenceBundle = async function (
records,
generatedAtMs,
cryptography,
) {
if (
!Array.isArray(records) ||
records.length < 1 ||
records.length > limits.maximumRecords ||
!safeInteger(generatedAtMs)
) {
return invalid();
}
const cryptoProvider = cryptography || globalThis.crypto;
const alias = createAliaser();
let totalRawBytes = 0;
const entries = [];
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
const validated = validateRecord(record);
totalRawBytes += validated.factBytes;
if (totalRawBytes > limits.maximumRawBytes) return invalid();
const state = { omittedFieldCount: 0 };
const target = sanitizeValue(
record.request,
record.operation,
alias,
state,
0,
);
const fact = sanitizeValue(
record.fact,
record.operation,
alias,
state,
0,
);
entries.push({
sequence: index + 1,
observedAtMs: record.observedAtMs,
operation: record.operation,
target,
fact,
rawFact: {
canonicalBytes: validated.factBytes,
sha256: await sha256(validated.factCanonical, cryptoProvider),
},
sanitizer: {
omittedFieldCount: state.omittedFieldCount,
rawContentIncluded: false,
},
});
}
const unsigned = {
schema,
classification: 'low_sensitive_redacted',
generatedAtMs,
generatedBy: 'browser_local',
actionAuthority: 'none',
attestation: 'none',
source: {
surface: 'cluster_field_ledger',
collection: 'explicit_user_reads_only',
entryCount: entries.length,
totalRawCanonicalBytes: totalRawBytes,
},
redaction: {
policy: 'fixed_allowlist_v1',
identifiers: 'per_bundle_typed_alias_without_mapping',
freeTextIncluded: false,
copilotOutputIncluded: false,
unknownFieldsIncluded: false,
},
integrity: {
algorithm: 'sha256',
scope: 'canonical_bundle_without_contentDigest',
serverSignature: false,
durableAudit: false,
},
entries,
};
const contentDigest = await sha256(canonicalize(unsigned), cryptoProvider);
const bundle = { ...unsigned, contentDigest };
const encoded = JSON.stringify(bundle, null, 2) + '\n';
if (utf8Bytes(encoded).byteLength > limits.maximumBundleBytes) {
return invalid();
}
return deepFreeze(bundle);
};
const serializeClusterConsoleEvidenceBundle = function (bundle) {
if (
!plainObject(bundle) ||
bundle.schema !== schema ||
typeof bundle.contentDigest !== 'string' ||
!/^[0-9a-f]{64}$/u.test(bundle.contentDigest)
) {
return invalid();
}
const encoded = JSON.stringify(bundle, null, 2) + '\n';
if (utf8Bytes(encoded).byteLength > limits.maximumBundleBytes) {
return invalid();
}
return encoded;
};
const verifyClusterConsoleEvidenceBundle = async function (
bundle,
cryptography,
) {
if (!plainObject(bundle) || typeof bundle.contentDigest !== 'string') {
return false;
}
const unsigned = {};
for (const key of Object.keys(bundle)) {
if (key !== 'contentDigest') unsigned[key] = bundle[key];
}
try {
return (
/^[0-9a-f]{64}$/u.test(bundle.contentDigest) &&
(await sha256(
canonicalize(unsigned),
cryptography || globalThis.crypto,
)) === bundle.contentDigest
);
} catch {
return false;
}
};
return Object.freeze({
ClusterConsoleEvidenceBundleError,
createClusterConsoleEvidenceBundle,
limits,
measureClusterConsoleEvidenceRecord,
operations,
schema,
serializeClusterConsoleEvidenceBundle,
verifyClusterConsoleEvidenceBundle,
});
});
@@ -7,6 +7,7 @@
<meta name="description" content="QingLong 3.0 Cluster 只读现场记录台" />
<title>QingLong 3.0 · Cluster 现场记录台</title>
<link rel="stylesheet" href="/app.css" />
<script src="/evidence-bundle.js" defer></script>
<script src="/app.js" defer></script>
</head>
<body>
@@ -109,7 +110,7 @@
<aside class="trust-note">
<span>Authority boundary</span>
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。</p>
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。脱敏导出只处理本页已读事实,不补读或上传。</p>
</aside>
</aside>
@@ -119,12 +120,19 @@
<p class="eyebrow">Explicit read ledger</p>
<h2 id="evidence-title">本页证据账本</h2>
</div>
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
<div class="evidence-actions">
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
<span id="ledger-meta" class="ledger-meta">0/16 条 · 0 KiB 原始事实</span>
<div class="evidence-buttons">
<button id="export-evidence" type="button" class="primary" disabled>导出脱敏包</button>
<button id="clear-evidence" type="button" disabled>清空本页</button>
</div>
</div>
</div>
<div id="empty-state" class="empty-state">
<div class="evidence-rail" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
<h3>一次读取,一条可复核事实</h3>
<p>先解锁页面并选择坐标。返回值按读取顺序保留在当前内存,reload 后清空。</p>
<p>先解锁页面并选择坐标。最近 16 条、最多 8 MiB 原始事实只保留在当前内存,reload 后清空。</p>
</div>
<ol id="ledger" class="ledger" aria-live="polite" hidden></ol>
<div id="message" class="message" role="status" aria-live="polite"></div>
@@ -133,7 +141,7 @@
<footer>
<span>Loopback only · explicit reads · zero polling</span>
<span>QingLong 3.0 incubation / D-329</span>
<span>QingLong 3.0 incubation / D-330</span>
</footer>
</div>
</body>
@@ -6,6 +6,7 @@ import { TextDecoder } from 'node:util';
export interface ClusterCopilotConsoleAssets {
readonly html: string;
readonly css: string;
readonly evidenceBundle: string;
readonly javascript: string;
}
@@ -23,19 +24,25 @@ const ASSETS = Object.freeze([
name: 'index.html',
field: 'html',
maximumBytes: 32 * 1024,
digest: 'ed8db5c26dec23e7a5237ef1cd4f5f9c3fc9f5a04a4751b7a3e0ed22dac54c42',
digest: '5d452c947a9f1266e4920cf48e7d5116b3f5ef8f9120f681124ed61f0217f5ff',
}),
Object.freeze({
name: 'app.css',
field: 'css',
maximumBytes: 64 * 1024,
digest: '54234cbba7e110de2f68fad2abd657c334b7e3e80c5d9b4f59bda7e122b4b62f',
digest: '5cf82b0a88920d106530603a7d407f852312138e5b7af5c422b3bccee785f144',
}),
Object.freeze({
name: 'evidence-bundle.js',
field: 'evidenceBundle',
maximumBytes: 32 * 1024,
digest: '6ecb14d2f59d872b889bb42c22bf0c0d2c150c90ea708fb1662d47f17f2e2095',
}),
Object.freeze({
name: 'app.js',
field: 'javascript',
maximumBytes: 32 * 1024,
digest: '61811eac6a89b097b67823ccf49b0736af6494be7b187dbdcbecfc59adb3fce0',
digest: 'f109c5b0491ba9a473e3129e35773edf38ac745b403e1f547f8252aa2932cdff',
}),
] as const);
@@ -131,6 +138,7 @@ export function loadClusterCopilotConsoleAssets(
return Object.freeze({
html: result.html!,
css: result.css!,
evidenceBundle: result.evidenceBundle!,
javascript: result.javascript!,
});
}
@@ -321,7 +321,12 @@ export async function startClusterCopilotConsoleServer(
optionKeys.push('networkBoundary');
}
const record = exactObject(options, optionKeys);
const assets = exactObject(record.assets, ['css', 'html', 'javascript']);
const assets = exactObject(record.assets, [
'css',
'evidenceBundle',
'html',
'javascript',
]);
const networkBoundary =
record.networkBoundary === undefined
? 'host-loopback'
@@ -331,6 +336,8 @@ export async function startClusterCopilotConsoleServer(
assets.html.length < 1 ||
typeof assets.css !== 'string' ||
assets.css.length < 1 ||
typeof assets.evidenceBundle !== 'string' ||
assets.evidenceBundle.length < 1 ||
typeof assets.javascript !== 'string' ||
assets.javascript.length < 1 ||
!record.executor ||
@@ -379,6 +386,15 @@ export async function startClusterCopilotConsoleServer(
);
return;
}
if (request.url === '/evidence-bundle.js') {
send(
response,
200,
'text/javascript; charset=utf-8',
assets.evidenceBundle as string,
);
return;
}
}
const operation = targetPath(request);
@@ -374,8 +374,14 @@ test('loads only digest-bound packaged assets and rejects drift', async (t) => {
const assets = loadClusterCopilotConsoleAssets(moduleDirectory);
assert.match(assets.html, /沿着证据读,不替集群做决定/);
assert.match(assets.css, /prefers-reduced-motion/);
assert.match(
assets.evidenceBundle,
/qinglong\/cluster-console-redacted-evidence-bundle@v1/,
);
assert.match(assets.evidenceBundle, /createClusterConsoleEvidenceBundle/);
assert.match(assets.javascript, /output\.textContent = JSON\.stringify/);
assert.match(assets.javascript, /run_event_list/);
assert.match(assets.javascript, /createClusterConsoleEvidenceBundle/);
assert.doesNotMatch(
assets.javascript,
/localStorage|sessionStorage|innerHTML/,
@@ -412,9 +418,21 @@ test('serves an immutable same-origin shell with a closed browser policy', async
assert.match(html.text, /Cluster field ledger/);
const css = await request(server.origin, { path: '/app.css' });
const evidenceBundle = await request(server.origin, {
path: '/evidence-bundle.js',
});
const javascript = await request(server.origin, { path: '/app.js' });
assert.equal(css.statusCode, 200);
assert.equal(evidenceBundle.statusCode, 200);
assert.equal(javascript.statusCode, 200);
assert.equal(
evidenceBundle.headers['content-type'],
'text/javascript; charset=utf-8',
);
assert.match(
evidenceBundle.text,
/qinglong\/cluster-console-redacted-evidence-bundle@v1/,
);
assert.equal(
javascript.headers['content-type'],
'text/javascript; charset=utf-8',
@@ -0,0 +1,256 @@
'use strict';
const assert = require('node:assert/strict');
const { webcrypto } = require('node:crypto');
const test = require('node:test');
const {
ClusterConsoleEvidenceBundleError,
createClusterConsoleEvidenceBundle,
limits,
measureClusterConsoleEvidenceRecord,
schema,
serializeClusterConsoleEvidenceBundle,
verifyClusterConsoleEvidenceBundle,
} = require('../assets/copilot-console/evidence-bundle.js');
const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1';
function runRecord(overrides = {}) {
return {
operation: 'run_read',
observedAtMs: 1_700_000_000_000,
request: {
schema: requestSchema,
operation: 'run_read',
projectId: 'project-customer-production',
requestId: 'console-request-sensitive',
runId: 'run-customer-production',
},
fact: {
schema: 'qinglong/bounded-run-projection@v1',
schemaVersion: 1,
status: 'succeeded',
projectId: 'project-customer-production',
runId: 'run-customer-production',
createdAtMs: 1_700_000_000_000,
finalizedAtMs: 1_700_000_001_000,
outputAvailable: true,
name: 'customer-production-nightly',
message: 'ql3c_console_do_not_export',
path: '/private/customer/run.log',
unknownField: '<img src=x onerror=alert(1)>',
},
...overrides,
};
}
function outputRecord() {
return {
operation: 'output',
observedAtMs: 1_700_000_002_000,
request: {
schema: requestSchema,
operation: 'output',
projectId: 'project-customer-production',
requestId: 'diagnosis-request-sensitive',
sourceRunId: 'run-customer-production',
},
fact: {
schema:
'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
schemaVersion: 1,
status: 'available',
projectId: 'project-customer-production',
sourceRunId: 'run-customer-production',
diagnosisRunId: 'diagnosis-run-sensitive',
reference: {
artifactId: 'artifact-sensitive',
artifactDigest: 'a'.repeat(64),
contentDigest: 'b'.repeat(64),
outputBytes: 71,
sealedAtMs: 1_700_000_001_000,
},
result: {
text: '<script>steal(credential)</script>',
command: 'curl https://attacker.invalid',
token: 'ql3c_console_do_not_export',
finishReason: 'stop',
},
usage: {
inputTokens: 20,
outputTokens: 10,
totalTokens: 30,
costMicros: 42,
},
},
};
}
test('creates one self-verifiable redacted bundle with per-bundle correlated aliases', async () => {
const records = [runRecord(), outputRecord()];
assert.ok(measureClusterConsoleEvidenceRecord(records[0]) > 1);
const bundle = await createClusterConsoleEvidenceBundle(
records,
1_700_000_003_000,
webcrypto,
);
const encoded = serializeClusterConsoleEvidenceBundle(bundle);
const parsed = JSON.parse(encoded);
assert.equal(bundle.schema, schema);
assert.equal(bundle.generatedBy, 'browser_local');
assert.equal(bundle.actionAuthority, 'none');
assert.equal(bundle.attestation, 'none');
assert.equal(bundle.source.entryCount, 2);
assert.equal(bundle.redaction.freeTextIncluded, false);
assert.equal(bundle.redaction.copilotOutputIncluded, false);
assert.equal(bundle.entries[0].target.projectId, 'project-001');
assert.equal(bundle.entries[1].target.projectId, 'project-001');
assert.equal(bundle.entries[0].target.runId, 'run-001');
assert.equal(bundle.entries[1].target.sourceRunId, 'run-001');
assert.equal(bundle.entries[0].fact.runId, 'run-001');
assert.equal(bundle.entries[1].fact.sourceRunId, 'run-001');
assert.equal(bundle.entries[1].fact.result, undefined);
assert.equal(bundle.entries[1].fact.reference.artifactId, 'artifact-001');
assert.match(bundle.entries[1].rawFact.sha256, /^[0-9a-f]{64}$/);
assert.match(bundle.contentDigest, /^[0-9a-f]{64}$/);
assert.equal(
await verifyClusterConsoleEvidenceBundle(parsed, webcrypto),
true,
);
for (const forbidden of [
'project-customer-production',
'run-customer-production',
'diagnosis-run-sensitive',
'artifact-sensitive',
'customer-production-nightly',
'ql3c_console_do_not_export',
'/private/customer/run.log',
'<img src=x onerror=alert(1)>',
'<script>steal(credential)</script>',
'attacker.invalid',
]) {
assert.doesNotMatch(encoded, new RegExp(forbidden.replaceAll('/', '\\/')));
}
parsed.entries[0].fact.status = 'failed';
assert.equal(
await verifyClusterConsoleEvidenceBundle(parsed, webcrypto),
false,
);
});
test('resets the undisclosed alias table for every bundle', async () => {
const first = await createClusterConsoleEvidenceBundle(
[runRecord()],
1_700_000_003_000,
webcrypto,
);
const second = await createClusterConsoleEvidenceBundle(
[
runRecord({
request: {
schema: requestSchema,
operation: 'run_read',
projectId: 'another-project',
requestId: 'another-request',
runId: 'another-run',
},
}),
],
1_700_000_004_000,
webcrypto,
);
assert.equal(first.entries[0].target.runId, 'run-001');
assert.equal(second.entries[0].target.runId, 'run-001');
assert.notEqual(first.contentDigest, second.contentDigest);
assert.equal(JSON.stringify(first).includes('another-run'), false);
assert.equal(
JSON.stringify(second).includes('run-customer-production'),
false,
);
});
test('fails closed on widened records, unsafe JSON and every capacity ceiling', async () => {
const error = { code: 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID' };
assert.throws(() => measureClusterConsoleEvidenceRecord(null), error);
assert.throws(
() => measureClusterConsoleEvidenceRecord({ ...runRecord(), session: 'x' }),
error,
);
assert.throws(
() =>
measureClusterConsoleEvidenceRecord({
...runRecord(),
operation: 'cancel',
}),
error,
);
assert.throws(
() =>
measureClusterConsoleEvidenceRecord({
...runRecord(),
request: { ...runRecord().request, endpoint: 'https://example.test' },
}),
error,
);
assert.throws(
() =>
measureClusterConsoleEvidenceRecord({
...runRecord(),
fact: { items: Array.from({ length: 65 }, () => ({})) },
}),
error,
);
const cyclic = runRecord();
cyclic.fact.loop = cyclic.fact;
assert.throws(() => measureClusterConsoleEvidenceRecord(cyclic), error);
assert.throws(
() =>
measureClusterConsoleEvidenceRecord({
...runRecord(),
fact: { message: 'x'.repeat(limits.maximumEntryFactBytes + 1) },
}),
error,
);
await assert.rejects(
createClusterConsoleEvidenceBundle([], 1_700_000_003_000, webcrypto),
ClusterConsoleEvidenceBundleError,
);
await assert.rejects(
createClusterConsoleEvidenceBundle(
Array.from({ length: limits.maximumRecords + 1 }, () => runRecord()),
1_700_000_003_000,
webcrypto,
),
ClusterConsoleEvidenceBundleError,
);
const largeRecords = Array.from({ length: 5 }, (_, index) =>
runRecord({
observedAtMs: 1_700_000_000_000 + index,
fact: { message: String(index) + 'x'.repeat(2 * 1024 * 1024 - 64) },
}),
);
await assert.rejects(
createClusterConsoleEvidenceBundle(
largeRecords,
1_700_000_003_000,
webcrypto,
),
ClusterConsoleEvidenceBundleError,
);
assert.throws(
() =>
serializeClusterConsoleEvidenceBundle({
schema,
contentDigest: 'a'.repeat(64),
padding: 'x'.repeat(limits.maximumBundleBytes),
}),
error,
);
});