mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): verify cluster evidence offline
This commit is contained in:
@@ -27,6 +27,19 @@ 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 same package provides an independently implemented offline verifier:
|
||||
|
||||
```sh
|
||||
ql3-cluster-admin evidence-verify \
|
||||
--bundle=/absolute/qinglong-cluster-evidence.json
|
||||
```
|
||||
|
||||
It performs one read-only, no-follow file read, revalidates the complete fixed
|
||||
schema/redaction/alias contract and recomputes the top-level digest without a
|
||||
network request or file write. A verified result does not prove the server
|
||||
origin, attestation or durable audit, and cannot recompute the per-fact hashes
|
||||
because the deliberately omitted raw facts are not present.
|
||||
|
||||
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
|
||||
|
||||
@@ -407,6 +407,7 @@
|
||||
"ql3-copilot-client": "dist/copilot-client/cli.js",
|
||||
"ql3-copilot-mcp": "dist/copilot-mcp/cli.js",
|
||||
"ql3-copilot-console": "dist/copilot-console/cli.js",
|
||||
"ql3-copilot-evidence-verify": "dist/copilot-console/evidenceVerifierCli.js",
|
||||
"ql3-plugin-package-recover": "dist/plugin-package/recovery/pluginPackageRecoveryCli.js",
|
||||
"ql3-plugin-package-manage": "dist/plugin-package/management/pluginPackageManagementCli.js",
|
||||
"ql3-plugin-package-client": "dist/plugin-package/management/pluginPackageManagementClientCli.js",
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
/** Offline verifier for browser-local redacted Cluster Console evidence. */
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
openSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import { isAbsolute, normalize, parse } from 'node:path';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
export const CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA =
|
||||
'qinglong/cluster-console-redacted-evidence-bundle@v1';
|
||||
export const CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA =
|
||||
'qinglong/cluster-console-evidence-verification@v1';
|
||||
|
||||
const REQUEST_SCHEMA = 'qinglong/cluster-copilot-console-read-request@v1';
|
||||
const MAXIMUM_PATH_BYTES = 4 * 1024;
|
||||
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',
|
||||
] as const);
|
||||
type EvidenceOperation = (typeof OPERATIONS)[number];
|
||||
const OPERATION_SET = new Set<string>(OPERATIONS);
|
||||
const REQUEST_FIELDS: Readonly<Record<EvidenceOperation, readonly string[]>> =
|
||||
Object.freeze({
|
||||
inspect: Object.freeze(['projectId', 'requestId', 'sourceRunId']),
|
||||
output: Object.freeze(['projectId', 'requestId', 'sourceRunId']),
|
||||
run_list: Object.freeze([
|
||||
'afterCreatedAtMs',
|
||||
'afterRunId',
|
||||
'limit',
|
||||
'projectId',
|
||||
'requestId',
|
||||
]),
|
||||
run_read: Object.freeze(['projectId', 'requestId', 'runId']),
|
||||
run_event_list: Object.freeze([
|
||||
'afterSequence',
|
||||
'limit',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
]),
|
||||
run_step_list: Object.freeze([
|
||||
'afterStepKey',
|
||||
'afterStepRunId',
|
||||
'limit',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
]),
|
||||
task_list: Object.freeze([
|
||||
'afterTaskId',
|
||||
'limit',
|
||||
'projectId',
|
||||
'requestId',
|
||||
]),
|
||||
task_read: Object.freeze(['projectId', 'requestId', 'taskId']),
|
||||
workflow_list: Object.freeze(['packageName', 'projectId', 'requestId']),
|
||||
workflow_run_list: Object.freeze([
|
||||
'afterAdmittedAtMs',
|
||||
'afterRunId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'workflowId',
|
||||
]),
|
||||
workflow_run_read: Object.freeze([
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
]),
|
||||
workflow_event_list: Object.freeze([
|
||||
'afterSequence',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
]),
|
||||
workflow_step_list: Object.freeze([
|
||||
'afterStepKey',
|
||||
'afterStepRunId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
]),
|
||||
});
|
||||
const IDENTIFIER_DOMAINS: Readonly<Record<string, string>> = 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 SAFE_CONTAINERS = new Set([
|
||||
'attempts',
|
||||
'counts',
|
||||
'events',
|
||||
'items',
|
||||
'metadata',
|
||||
'next',
|
||||
'reference',
|
||||
'run',
|
||||
'runs',
|
||||
'source',
|
||||
'step',
|
||||
'steps',
|
||||
'summary',
|
||||
'target',
|
||||
'task',
|
||||
'tasks',
|
||||
'usage',
|
||||
'workflow',
|
||||
'workflows',
|
||||
]);
|
||||
const SAFE_BOOLEANS = new Set([
|
||||
'active',
|
||||
'archived',
|
||||
'available',
|
||||
'cancelRequested',
|
||||
'enabled',
|
||||
'hasMore',
|
||||
'outputAvailable',
|
||||
'ready',
|
||||
'replayed',
|
||||
'tailComplete',
|
||||
'terminal',
|
||||
'truncated',
|
||||
]);
|
||||
const SAFE_ENUM_KEYS = new Set([
|
||||
'finishReason',
|
||||
'kind',
|
||||
'operation',
|
||||
'outcome',
|
||||
'stage',
|
||||
'status',
|
||||
]);
|
||||
const SAFE_ENUM_VALUES = 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 NUMERIC_KEY =
|
||||
/^(?: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 SCHEMA_VALUE = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
|
||||
const SHA256 = /^[0-9a-f]{64}$/u;
|
||||
const CONTROL = /[\0-\x1f\x7f]/u;
|
||||
|
||||
export interface ClusterConsoleEvidenceVerification {
|
||||
readonly schema: typeof CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA;
|
||||
readonly status: 'verified';
|
||||
readonly bundle: Readonly<{
|
||||
schema: typeof CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA;
|
||||
contentDigest: string;
|
||||
entryCount: number;
|
||||
totalRawCanonicalBytes: number;
|
||||
}>;
|
||||
readonly integrity: Readonly<{
|
||||
bundleDigest: 'verified';
|
||||
rawFactDigests: 'not_recomputed_without_raw_facts';
|
||||
}>;
|
||||
readonly claims: Readonly<{
|
||||
serverSignature: 'not_verified';
|
||||
attestation: 'not_verified';
|
||||
durableAudit: 'not_verified';
|
||||
actionAuthority: 'none';
|
||||
}>;
|
||||
readonly execution: Readonly<{
|
||||
networkAccess: false;
|
||||
mutation: false;
|
||||
fileWrites: false;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class ClusterConsoleEvidenceVerificationError extends TypeError {
|
||||
readonly code = 'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster Console evidence bundle verification failed');
|
||||
this.name = 'ClusterConsoleEvidenceVerificationError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new ClusterConsoleEvidenceVerificationError();
|
||||
}
|
||||
|
||||
function plainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: unknown,
|
||||
expected: readonly string[],
|
||||
): value is Record<string, unknown> {
|
||||
if (!plainObject(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function sortedKeys(value: Readonly<Record<string, unknown>>): boolean {
|
||||
const actual = Object.keys(value);
|
||||
const sorted = [...actual].sort();
|
||||
return actual.every((key, index) => key === sorted[index]);
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function canonicalValue(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
stack: WeakSet<object>,
|
||||
): string {
|
||||
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 === null || 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((item) => 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(
|
||||
(key) =>
|
||||
`${JSON.stringify(key)}:${canonicalValue(
|
||||
value[key],
|
||||
depth + 1,
|
||||
stack,
|
||||
)}`,
|
||||
)
|
||||
.join(',')}}`;
|
||||
} finally {
|
||||
stack.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): string {
|
||||
return canonicalValue(value, 0, new WeakSet());
|
||||
}
|
||||
|
||||
interface AliasState {
|
||||
readonly nextByDomain: Map<string, number>;
|
||||
readonly seen: Set<string>;
|
||||
}
|
||||
|
||||
function validateAlias(
|
||||
domain: string,
|
||||
value: unknown,
|
||||
aliases: AliasState,
|
||||
): void {
|
||||
if (value === null) return;
|
||||
if (typeof value !== 'string') return invalid();
|
||||
const prefix = `${domain}-`;
|
||||
if (!value.startsWith(prefix)) return invalid();
|
||||
const suffix = value.slice(prefix.length);
|
||||
if (!/^[0-9]{3,}$/u.test(suffix)) return invalid();
|
||||
const numeric = Number(suffix);
|
||||
if (
|
||||
!Number.isSafeInteger(numeric) ||
|
||||
numeric < 1 ||
|
||||
String(numeric).padStart(3, '0') !== suffix
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const identity = `${domain}\0${suffix}`;
|
||||
if (aliases.seen.has(identity)) return;
|
||||
const expected = aliases.nextByDomain.get(domain) ?? 1;
|
||||
if (numeric !== expected) return invalid();
|
||||
aliases.seen.add(identity);
|
||||
aliases.nextByDomain.set(domain, expected + 1);
|
||||
}
|
||||
|
||||
function validateSafeValue(
|
||||
value: unknown,
|
||||
aliases: AliasState,
|
||||
depth: number,
|
||||
): void {
|
||||
if (depth > LIMITS.maximumDepth) return invalid();
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > LIMITS.maximumArrayItems) return invalid();
|
||||
for (const item of value) {
|
||||
if (item === null) continue;
|
||||
if (!plainObject(item) && !Array.isArray(item)) return invalid();
|
||||
validateSafeValue(item, aliases, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!plainObject(value) || !sortedKeys(value)) return invalid();
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > LIMITS.maximumObjectKeys) return invalid();
|
||||
for (const key of keys) {
|
||||
const candidate = value[key];
|
||||
const domain = IDENTIFIER_DOMAINS[key];
|
||||
if (domain !== undefined) {
|
||||
validateAlias(domain, candidate, aliases);
|
||||
continue;
|
||||
}
|
||||
if (/Digest$/u.test(key)) {
|
||||
validateAlias('digest', candidate, aliases);
|
||||
continue;
|
||||
}
|
||||
if (key === 'schema') {
|
||||
if (typeof candidate !== 'string' || !SCHEMA_VALUE.test(candidate)) {
|
||||
return invalid();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (SAFE_ENUM_KEYS.has(key)) {
|
||||
if (
|
||||
candidate !== null &&
|
||||
(typeof candidate !== 'string' ||
|
||||
(key === 'operation'
|
||||
? !OPERATION_SET.has(candidate)
|
||||
: !SAFE_ENUM_VALUES.has(candidate)))
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (SAFE_BOOLEANS.has(key)) {
|
||||
if (candidate !== null && typeof candidate !== 'boolean') {
|
||||
return invalid();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (NUMERIC_KEY.test(key)) {
|
||||
if (candidate !== null && !safeInteger(candidate)) return invalid();
|
||||
continue;
|
||||
}
|
||||
if (SAFE_CONTAINERS.has(key)) {
|
||||
if (candidate === null) continue;
|
||||
if (!plainObject(candidate) && !Array.isArray(candidate))
|
||||
return invalid();
|
||||
validateSafeValue(candidate, aliases, depth + 1);
|
||||
continue;
|
||||
}
|
||||
return invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function validateTarget(
|
||||
value: unknown,
|
||||
operation: EvidenceOperation,
|
||||
aliases: AliasState,
|
||||
): void {
|
||||
const fields = REQUEST_FIELDS[operation];
|
||||
if (!exactKeys(value, ['operation', 'schema', ...fields])) return invalid();
|
||||
if (!sortedKeys(value)) return invalid();
|
||||
if (value.schema !== REQUEST_SCHEMA || value.operation !== operation) {
|
||||
return invalid();
|
||||
}
|
||||
for (const field of fields) {
|
||||
const domain = IDENTIFIER_DOMAINS[field];
|
||||
if (domain !== undefined) {
|
||||
validateAlias(domain, value[field], aliases);
|
||||
continue;
|
||||
}
|
||||
if (!NUMERIC_KEY.test(field)) return invalid();
|
||||
const candidate = value[field];
|
||||
if (candidate !== null && !safeInteger(candidate)) return invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function validateBundle(value: unknown): ClusterConsoleEvidenceVerification {
|
||||
if (
|
||||
!exactKeys(value, [
|
||||
'actionAuthority',
|
||||
'attestation',
|
||||
'classification',
|
||||
'contentDigest',
|
||||
'entries',
|
||||
'generatedAtMs',
|
||||
'generatedBy',
|
||||
'integrity',
|
||||
'redaction',
|
||||
'schema',
|
||||
'source',
|
||||
]) ||
|
||||
value.schema !== CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA ||
|
||||
value.classification !== 'low_sensitive_redacted' ||
|
||||
!safeInteger(value.generatedAtMs) ||
|
||||
value.generatedBy !== 'browser_local' ||
|
||||
value.actionAuthority !== 'none' ||
|
||||
value.attestation !== 'none' ||
|
||||
typeof value.contentDigest !== 'string' ||
|
||||
!SHA256.test(value.contentDigest)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!exactKeys(value.source, [
|
||||
'collection',
|
||||
'entryCount',
|
||||
'surface',
|
||||
'totalRawCanonicalBytes',
|
||||
]) ||
|
||||
value.source.surface !== 'cluster_field_ledger' ||
|
||||
value.source.collection !== 'explicit_user_reads_only' ||
|
||||
!safeInteger(value.source.entryCount) ||
|
||||
!safeInteger(value.source.totalRawCanonicalBytes)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!exactKeys(value.redaction, [
|
||||
'copilotOutputIncluded',
|
||||
'freeTextIncluded',
|
||||
'identifiers',
|
||||
'policy',
|
||||
'unknownFieldsIncluded',
|
||||
]) ||
|
||||
value.redaction.policy !== 'fixed_allowlist_v1' ||
|
||||
value.redaction.identifiers !== 'per_bundle_typed_alias_without_mapping' ||
|
||||
value.redaction.freeTextIncluded !== false ||
|
||||
value.redaction.copilotOutputIncluded !== false ||
|
||||
value.redaction.unknownFieldsIncluded !== false
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!exactKeys(value.integrity, [
|
||||
'algorithm',
|
||||
'durableAudit',
|
||||
'scope',
|
||||
'serverSignature',
|
||||
]) ||
|
||||
value.integrity.algorithm !== 'sha256' ||
|
||||
value.integrity.scope !== 'canonical_bundle_without_contentDigest' ||
|
||||
value.integrity.serverSignature !== false ||
|
||||
value.integrity.durableAudit !== false
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value.entries) ||
|
||||
value.entries.length < 1 ||
|
||||
value.entries.length > LIMITS.maximumRecords ||
|
||||
value.source.entryCount !== value.entries.length
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const aliases: AliasState = {
|
||||
nextByDomain: new Map(),
|
||||
seen: new Set(),
|
||||
};
|
||||
let totalRawCanonicalBytes = 0;
|
||||
for (let index = 0; index < value.entries.length; index += 1) {
|
||||
const entry = value.entries[index];
|
||||
if (
|
||||
!exactKeys(entry, [
|
||||
'fact',
|
||||
'observedAtMs',
|
||||
'operation',
|
||||
'rawFact',
|
||||
'sanitizer',
|
||||
'sequence',
|
||||
'target',
|
||||
]) ||
|
||||
entry.sequence !== index + 1 ||
|
||||
!safeInteger(entry.observedAtMs) ||
|
||||
typeof entry.operation !== 'string' ||
|
||||
!OPERATION_SET.has(entry.operation)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const operation = entry.operation as EvidenceOperation;
|
||||
validateTarget(entry.target, operation, aliases);
|
||||
validateSafeValue(entry.fact, aliases, 0);
|
||||
if (
|
||||
!exactKeys(entry.rawFact, ['canonicalBytes', 'sha256']) ||
|
||||
!safeInteger(entry.rawFact.canonicalBytes) ||
|
||||
entry.rawFact.canonicalBytes < 2 ||
|
||||
entry.rawFact.canonicalBytes > LIMITS.maximumEntryFactBytes ||
|
||||
typeof entry.rawFact.sha256 !== 'string' ||
|
||||
!SHA256.test(entry.rawFact.sha256)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
totalRawCanonicalBytes += entry.rawFact.canonicalBytes;
|
||||
if (totalRawCanonicalBytes > LIMITS.maximumRawBytes) return invalid();
|
||||
if (
|
||||
!exactKeys(entry.sanitizer, [
|
||||
'omittedFieldCount',
|
||||
'rawContentIncluded',
|
||||
]) ||
|
||||
!safeInteger(entry.sanitizer.omittedFieldCount) ||
|
||||
entry.sanitizer.rawContentIncluded !== false
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
}
|
||||
if (value.source.totalRawCanonicalBytes !== totalRawCanonicalBytes) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const unsigned: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key !== 'contentDigest') unsigned[key] = value[key];
|
||||
}
|
||||
const computed = createHash('sha256')
|
||||
.update(canonicalize(unsigned), 'utf8')
|
||||
.digest('hex');
|
||||
if (computed !== value.contentDigest) return invalid();
|
||||
|
||||
return Object.freeze({
|
||||
schema: CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA,
|
||||
status: 'verified',
|
||||
bundle: Object.freeze({
|
||||
schema: CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA,
|
||||
contentDigest: value.contentDigest,
|
||||
entryCount: value.entries.length,
|
||||
totalRawCanonicalBytes,
|
||||
}),
|
||||
integrity: Object.freeze({
|
||||
bundleDigest: 'verified',
|
||||
rawFactDigests: 'not_recomputed_without_raw_facts',
|
||||
}),
|
||||
claims: Object.freeze({
|
||||
serverSignature: 'not_verified',
|
||||
attestation: 'not_verified',
|
||||
durableAudit: 'not_verified',
|
||||
actionAuthority: 'none',
|
||||
}),
|
||||
execution: Object.freeze({
|
||||
networkAccess: false,
|
||||
mutation: false,
|
||||
fileWrites: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function readStableBundle(filePath: string): Buffer {
|
||||
if (
|
||||
!isAbsolute(filePath) ||
|
||||
normalize(filePath) !== filePath ||
|
||||
parse(filePath).root === filePath ||
|
||||
Buffer.byteLength(filePath, 'utf8') > MAXIMUM_PATH_BYTES ||
|
||||
CONTROL.test(filePath)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
let descriptor = -1;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
if (realpathSync(filePath) !== filePath) return invalid();
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY |
|
||||
((constants as unknown as Readonly<Record<string, number>>).O_CLOEXEC ??
|
||||
0) |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const before = fstatSync(descriptor);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 2 ||
|
||||
before.size > LIMITS.maximumBundleBytes
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
bytes = Buffer.alloc(before.size);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const read = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (read < 1) return invalid();
|
||||
offset += read;
|
||||
}
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.mode !== before.mode ||
|
||||
after.uid !== before.uid ||
|
||||
after.gid !== before.gid ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs ||
|
||||
after.ctimeMs !== before.ctimeMs
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
bytes?.fill(0);
|
||||
if (error instanceof ClusterConsoleEvidenceVerificationError) throw error;
|
||||
return invalid();
|
||||
} finally {
|
||||
if (descriptor >= 0) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyClusterConsoleEvidenceBundleFile(
|
||||
filePath: string,
|
||||
): ClusterConsoleEvidenceVerification {
|
||||
const bytes = readStableBundle(filePath);
|
||||
try {
|
||||
if (
|
||||
bytes.length >= 3 &&
|
||||
bytes[0] === 0xef &&
|
||||
bytes[1] === 0xbb &&
|
||||
bytes[2] === 0xbf
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return invalid();
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return invalid();
|
||||
}
|
||||
if (`${JSON.stringify(parsed, null, 2)}\n` !== text) return invalid();
|
||||
return validateBundle(parsed);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** Offline, read-only Cluster Console evidence verification entrypoint. */
|
||||
import {
|
||||
ClusterConsoleEvidenceVerificationError,
|
||||
verifyClusterConsoleEvidenceBundleFile,
|
||||
} from './evidenceVerifier';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-copilot-evidence-verify --bundle=/absolute/evidence.json';
|
||||
|
||||
function failure(code: string, message: string): string {
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-console-evidence-verifier',
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
function main(argv: readonly string[]): void {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
argv.length !== 1 ||
|
||||
!argv[0]?.startsWith('--bundle=') ||
|
||||
argv[0] === '--bundle='
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${failure(
|
||||
'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFIER_USAGE_INVALID',
|
||||
USAGE,
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = verifyClusterConsoleEvidenceBundleFile(
|
||||
argv[0].slice('--bundle='.length),
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof ClusterConsoleEvidenceVerificationError
|
||||
? error.code
|
||||
: 'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID';
|
||||
process.stderr.write(
|
||||
`${failure(
|
||||
code,
|
||||
'Cluster Console evidence bundle verification failed',
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 65;
|
||||
}
|
||||
}
|
||||
|
||||
main(process.argv.slice(2));
|
||||
@@ -52,6 +52,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
|
||||
target: 'copilot-console/cli.js',
|
||||
description: 'open the loopback-only read-only Copilot Console',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'evidence-verify',
|
||||
binary: 'ql3-copilot-evidence-verify',
|
||||
target: 'copilot-console/evidenceVerifierCli.js',
|
||||
description: 'verify one redacted Console evidence bundle offline',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package',
|
||||
binary: 'ql3-plugin-package-client',
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createHash, webcrypto } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
createClusterConsoleEvidenceBundle,
|
||||
serializeClusterConsoleEvidenceBundle,
|
||||
} = require('../assets/copilot-console/evidence-bundle.js');
|
||||
const {
|
||||
CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA,
|
||||
CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA,
|
||||
verifyClusterConsoleEvidenceBundleFile,
|
||||
} = require('../dist/copilot-console/evidenceVerifier.js');
|
||||
|
||||
const cliPath = path.resolve(
|
||||
__dirname,
|
||||
'../dist/copilot-console/evidenceVerifierCli.js',
|
||||
);
|
||||
const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1';
|
||||
|
||||
function canonicalize(value) {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(Object.is(value, -0) ? 0 : value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(canonicalize).join(',')}]`;
|
||||
}
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
function resign(bundle) {
|
||||
const unsigned = {};
|
||||
for (const key of Object.keys(bundle)) {
|
||||
if (key !== 'contentDigest') unsigned[key] = bundle[key];
|
||||
}
|
||||
bundle.contentDigest = createHash('sha256')
|
||||
.update(canonicalize(unsigned), 'utf8')
|
||||
.digest('hex');
|
||||
return bundle;
|
||||
}
|
||||
|
||||
async function validBundle() {
|
||||
return createClusterConsoleEvidenceBundle(
|
||||
[
|
||||
{
|
||||
operation: 'run_read',
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
request: {
|
||||
schema: requestSchema,
|
||||
operation: 'run_read',
|
||||
projectId: 'private-project',
|
||||
requestId: 'private-request',
|
||||
runId: 'private-run',
|
||||
},
|
||||
fact: {
|
||||
schema: 'qinglong/bounded-run-projection@v1',
|
||||
schemaVersion: 1,
|
||||
status: 'succeeded',
|
||||
projectId: 'private-project',
|
||||
runId: 'private-run',
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
outputAvailable: true,
|
||||
message: 'must-never-survive-redaction',
|
||||
},
|
||||
},
|
||||
],
|
||||
1_700_000_001_000,
|
||||
webcrypto,
|
||||
);
|
||||
}
|
||||
|
||||
function fixture(t, encoded, name = 'evidence.json') {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-evidence-verifier-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, name);
|
||||
fs.writeFileSync(filePath, encoded, { mode: 0o644 });
|
||||
return { directory, filePath };
|
||||
}
|
||||
|
||||
test('independently verifies the browser generator output without authority claims', async (t) => {
|
||||
const bundle = await validBundle();
|
||||
const encoded = serializeClusterConsoleEvidenceBundle(bundle);
|
||||
const { filePath } = fixture(t, encoded);
|
||||
|
||||
const result = verifyClusterConsoleEvidenceBundleFile(filePath);
|
||||
assert.deepEqual(result, {
|
||||
schema: CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA,
|
||||
status: 'verified',
|
||||
bundle: {
|
||||
schema: CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA,
|
||||
contentDigest: bundle.contentDigest,
|
||||
entryCount: 1,
|
||||
totalRawCanonicalBytes: bundle.source.totalRawCanonicalBytes,
|
||||
},
|
||||
integrity: {
|
||||
bundleDigest: 'verified',
|
||||
rawFactDigests: 'not_recomputed_without_raw_facts',
|
||||
},
|
||||
claims: {
|
||||
serverSignature: 'not_verified',
|
||||
attestation: 'not_verified',
|
||||
durableAudit: 'not_verified',
|
||||
actionAuthority: 'none',
|
||||
},
|
||||
execution: { networkAccess: false, mutation: false, fileWrites: false },
|
||||
});
|
||||
assert.equal(Object.isFrozen(result), true);
|
||||
assert.equal(encoded.includes('must-never-survive-redaction'), false);
|
||||
});
|
||||
|
||||
test('cross-verifies every fixed Console read operation', async (t) => {
|
||||
const requests = {
|
||||
inspect: { projectId: 'p-1', requestId: 'q-1', sourceRunId: 'r-1' },
|
||||
output: { projectId: 'p-2', requestId: 'q-2', sourceRunId: 'r-2' },
|
||||
run_list: {
|
||||
afterCreatedAtMs: null,
|
||||
afterRunId: null,
|
||||
limit: 32,
|
||||
projectId: 'p-3',
|
||||
requestId: 'q-3',
|
||||
},
|
||||
run_read: { projectId: 'p-4', requestId: 'q-4', runId: 'r-4' },
|
||||
run_event_list: {
|
||||
afterSequence: null,
|
||||
limit: 32,
|
||||
projectId: 'p-5',
|
||||
requestId: 'q-5',
|
||||
runId: 'r-5',
|
||||
},
|
||||
run_step_list: {
|
||||
afterStepKey: null,
|
||||
afterStepRunId: null,
|
||||
limit: 32,
|
||||
projectId: 'p-6',
|
||||
requestId: 'q-6',
|
||||
runId: 'r-6',
|
||||
},
|
||||
task_list: {
|
||||
afterTaskId: null,
|
||||
limit: 32,
|
||||
projectId: 'p-7',
|
||||
requestId: 'q-7',
|
||||
},
|
||||
task_read: { projectId: 'p-8', requestId: 'q-8', taskId: 't-8' },
|
||||
workflow_list: {
|
||||
packageName: 'pkg-9',
|
||||
projectId: 'p-9',
|
||||
requestId: 'q-9',
|
||||
},
|
||||
workflow_run_list: {
|
||||
afterAdmittedAtMs: null,
|
||||
afterRunId: null,
|
||||
limit: 32,
|
||||
packageName: 'pkg-10',
|
||||
projectId: 'p-10',
|
||||
requestId: 'q-10',
|
||||
workflowId: 'w-10',
|
||||
},
|
||||
workflow_run_read: {
|
||||
packageName: 'pkg-11',
|
||||
projectId: 'p-11',
|
||||
requestId: 'q-11',
|
||||
runId: 'r-11',
|
||||
workflowId: 'w-11',
|
||||
},
|
||||
workflow_event_list: {
|
||||
afterSequence: null,
|
||||
limit: 32,
|
||||
packageName: 'pkg-12',
|
||||
projectId: 'p-12',
|
||||
requestId: 'q-12',
|
||||
runId: 'r-12',
|
||||
workflowId: 'w-12',
|
||||
},
|
||||
workflow_step_list: {
|
||||
afterStepKey: null,
|
||||
afterStepRunId: null,
|
||||
limit: 32,
|
||||
packageName: 'pkg-13',
|
||||
projectId: 'p-13',
|
||||
requestId: 'q-13',
|
||||
runId: 'r-13',
|
||||
workflowId: 'w-13',
|
||||
},
|
||||
};
|
||||
const records = Object.entries(requests).map(
|
||||
([operation, request], index) => ({
|
||||
operation,
|
||||
observedAtMs: 1_700_000_000_000 + index,
|
||||
request: { schema: requestSchema, operation, ...request },
|
||||
fact: {
|
||||
schema: 'qinglong/test-fact@v1',
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
status: 'succeeded',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const bundle = await createClusterConsoleEvidenceBundle(
|
||||
records,
|
||||
1_700_000_001_000,
|
||||
webcrypto,
|
||||
);
|
||||
const { filePath } = fixture(
|
||||
t,
|
||||
serializeClusterConsoleEvidenceBundle(bundle),
|
||||
);
|
||||
const result = verifyClusterConsoleEvidenceBundleFile(filePath);
|
||||
assert.equal(result.status, 'verified');
|
||||
assert.equal(result.bundle.entryCount, 13);
|
||||
});
|
||||
|
||||
test('CLI is secret-free on success, invalid input and usage errors', async (t) => {
|
||||
const bundle = await validBundle();
|
||||
const { filePath } = fixture(
|
||||
t,
|
||||
serializeClusterConsoleEvidenceBundle(bundle),
|
||||
'private-customer-run.json',
|
||||
);
|
||||
const success = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, `--bundle=${filePath}`],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(success.status, 0);
|
||||
assert.equal(JSON.parse(success.stdout).status, 'verified');
|
||||
assert.equal(success.stderr, '');
|
||||
assert.equal(success.stdout.includes(filePath), false);
|
||||
assert.equal(success.stdout.includes('private-customer-run'), false);
|
||||
|
||||
const tampered = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
tampered.entries[0].fact.status = 'failed';
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(tampered, null, 2)}\n`);
|
||||
const invalid = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, `--bundle=${filePath}`],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(invalid.status, 65);
|
||||
assert.equal(invalid.stdout, '');
|
||||
assert.equal(
|
||||
JSON.parse(invalid.stderr).code,
|
||||
'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID',
|
||||
);
|
||||
assert.equal(invalid.stderr.includes(filePath), false);
|
||||
|
||||
const usage = spawnSync(process.execPath, [cliPath], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 64);
|
||||
assert.equal(
|
||||
JSON.parse(usage.stderr).code,
|
||||
'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFIER_USAGE_INVALID',
|
||||
);
|
||||
const help = spawnSync(process.execPath, [cliPath, '--help'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /^Usage: ql3-copilot-evidence-verify/);
|
||||
});
|
||||
|
||||
test('rejects re-signed structural widening, alias gaps and false proof claims', async (t) => {
|
||||
const baseline = JSON.parse(
|
||||
serializeClusterConsoleEvidenceBundle(await validBundle()),
|
||||
);
|
||||
const mutations = [
|
||||
(bundle) => {
|
||||
bundle.entries[0].fact.message = 'unsafe free text';
|
||||
},
|
||||
(bundle) => {
|
||||
bundle.entries[0].target.runId = 'run-002';
|
||||
},
|
||||
(bundle) => {
|
||||
bundle.integrity.serverSignature = true;
|
||||
},
|
||||
(bundle) => {
|
||||
bundle.source.totalRawCanonicalBytes += 1;
|
||||
},
|
||||
(bundle) => {
|
||||
bundle.entries[0].sequence = 2;
|
||||
},
|
||||
];
|
||||
for (const [index, mutate] of mutations.entries()) {
|
||||
const candidate = structuredClone(baseline);
|
||||
mutate(candidate);
|
||||
resign(candidate);
|
||||
const { filePath } = fixture(
|
||||
t,
|
||||
`${JSON.stringify(candidate, null, 2)}\n`,
|
||||
`invalid-${index}.json`,
|
||||
);
|
||||
assert.throws(() => verifyClusterConsoleEvidenceBundleFile(filePath), {
|
||||
code: 'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-canonical JSON, links, relative paths and oversized files', async (t) => {
|
||||
const encoded = serializeClusterConsoleEvidenceBundle(await validBundle());
|
||||
for (const [name, contents] of [
|
||||
['minified.json', JSON.stringify(JSON.parse(encoded))],
|
||||
['bom.json', `\ufeff${encoded}`],
|
||||
['crlf.json', encoded.replaceAll('\n', '\r\n')],
|
||||
[
|
||||
'duplicate.json',
|
||||
encoded.replace(
|
||||
'{\n',
|
||||
`{\n "schema": "${CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA}",\n`,
|
||||
),
|
||||
],
|
||||
]) {
|
||||
const { filePath } = fixture(t, contents, name);
|
||||
assert.throws(
|
||||
() => verifyClusterConsoleEvidenceBundleFile(filePath),
|
||||
undefined,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
const { directory, filePath } = fixture(t, encoded, 'source.json');
|
||||
const linkPath = path.join(directory, 'link.json');
|
||||
fs.symlinkSync(filePath, linkPath);
|
||||
assert.throws(() => verifyClusterConsoleEvidenceBundleFile(linkPath));
|
||||
assert.throws(() => verifyClusterConsoleEvidenceBundleFile('source.json'));
|
||||
|
||||
const oversized = path.join(directory, 'oversized.json');
|
||||
fs.writeFileSync(oversized, 'x'.repeat(512 * 1024 + 1));
|
||||
assert.throws(() => verifyClusterConsoleEvidenceBundleFile(oversized));
|
||||
});
|
||||
@@ -117,9 +117,7 @@ async function startReadinessServer(status) {
|
||||
port: server.address().port,
|
||||
close: () =>
|
||||
new Promise((resolvePromise, reject) => {
|
||||
server.close((error) =>
|
||||
error ? reject(error) : resolvePromise(),
|
||||
);
|
||||
server.close((error) => (error ? reject(error) : resolvePromise()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -152,9 +150,7 @@ async function startCopilotReadinessServer(status) {
|
||||
port: server.address().port,
|
||||
close: () =>
|
||||
new Promise((resolvePromise, reject) => {
|
||||
server.close((error) =>
|
||||
error ? reject(error) : resolvePromise(),
|
||||
);
|
||||
server.close((error) => (error ? reject(error) : resolvePromise()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -333,7 +329,7 @@ function validContextFixture(t) {
|
||||
|
||||
test('catalog exposes only reviewed product entrypoints from the same package', () => {
|
||||
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 10);
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 11);
|
||||
assert.equal(
|
||||
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
||||
@@ -352,7 +348,8 @@ test('catalog exposes only reviewed product entrypoints from the same package',
|
||||
assert.equal(
|
||||
command.binary.includes('-client') ||
|
||||
command.binary === 'ql3-copilot-mcp' ||
|
||||
command.binary === 'ql3-copilot-console',
|
||||
command.binary === 'ql3-copilot-console' ||
|
||||
command.binary === 'ql3-copilot-evidence-verify',
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -382,6 +379,10 @@ test('help and version are bounded installation-derived product facts', () => {
|
||||
assert.match(help, /\n copilot\s+diagnose, inspect, read or cancel Runs/);
|
||||
assert.match(help, /\n copilot-mcp\s+serve the bounded Cluster Copilot MCP/);
|
||||
assert.match(help, /\n copilot-console\s+open the loopback-only read-only/);
|
||||
assert.match(
|
||||
help,
|
||||
/\n evidence-verify\s+verify one redacted Console evidence/,
|
||||
);
|
||||
assert.match(help, /Server, migration, recovery, executor and key-custody/);
|
||||
assert.equal(help.includes('plugin-package-manage'), false);
|
||||
assert.equal(
|
||||
|
||||
Reference in New Issue
Block a user