feat(ql3): harden copilot mcp host deployment

This commit is contained in:
whyour
2026-08-16 03:41:38 +08:00
parent 58025ede55
commit da4e7cf688
25 changed files with 734 additions and 48 deletions
@@ -9,6 +9,14 @@ const ENTRYPOINT = [
'/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js',
];
const COMMANDS = Object.freeze([
Object.freeze({
name: 'copilot',
usage: 'Usage: ql3-copilot-client ',
}),
Object.freeze({
name: 'copilot-mcp',
usage: 'Usage: ql3-copilot-mcp --config ',
}),
Object.freeze({
name: 'package',
usage: 'Usage: ql3-plugin-package-client ',
@@ -125,7 +133,9 @@ const validated = spawnSync(process.execPath, [facade, 'context', 'validate', '-
let validationFact;
try { validationFact = JSON.parse(validated.stdout); } catch { process.exit(25); }
if (validated.status !== 0 || validated.stderr !== '' || validationFact.event !== 'context_valid' || validationFact.commandCount !== 1 || validationFact.networkAccess !== false || validationFact.mutation !== false || JSON.stringify(validationFact.commands) !== JSON.stringify([{ name: 'package', transport: 'https', clientCertificate: 'forbidden' }]) || validated.stdout.includes('/tmp/') || validated.stdout.includes('manager.example.test')) process.exit(26);
const readinessServer = spawn(process.execPath, ['-e', ${JSON.stringify(readinessServerSource)}], { stdio: 'ignore' });
const readinessServer = spawn(process.execPath, ['-e', ${JSON.stringify(
readinessServerSource,
)}], { stdio: 'ignore' });
const waitArray = new Int32Array(new SharedArrayBuffer(4));
for (let attempt = 0; attempt < 200 && !existsSync('/tmp/readiness-port'); attempt += 1) Atomics.wait(waitArray, 0, 0, 10);
if (!existsSync('/tmp/readiness-port')) process.exit(27);
+106
View File
@@ -131,6 +131,105 @@ function assertClusterAdminImageCommands(readFile, root, findings) {
return references;
}
function assertClusterCopilotMcpHostDeployment(readFile, root, findings) {
const directory = path.join(root, 'deploy/mcp/ql3-cluster-copilot');
const launcher = readFile(path.join(directory, 'docker-stdio.sh'), 'utf8');
const mcpConfig = readJson(
readFile,
path.join(directory, 'mcp-config.example.json'),
);
const clientConfig = readJson(
readFile,
path.join(directory, 'client-config.example.json'),
);
const hostConfig = readJson(
readFile,
path.join(directory, 'mcp-host.example.json'),
);
const host = hostConfig?.mcpServers?.['qinglong-cluster-copilot'];
const expectedHostEnvironment = {
QL3_COPILOT_MCP_IMAGE:
'ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:' + '0'.repeat(64),
QL3_COPILOT_MCP_PRIVATE_ROOT: '/absolute/private/qinglong3-cluster-copilot',
QL3_COPILOT_MCP_NETWORK: 'qinglong3-copilot-egress',
QL3_COPILOT_MCP_RESOURCE_CLASS: 'compact',
};
const requiredLauncherFragments = [
'docker run --rm -i --pull never --init --read-only',
'--network "$network"',
'--cap-drop ALL',
'--security-opt no-new-privileges',
'--user 10001:10001',
'--mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-mcp,readonly"',
'copilot-mcp',
'--check --config /var/run/secrets/qinglong3/copilot-mcp/mcp.json',
'--config /var/run/secrets/qinglong3/copilot-mcp/mcp.json',
'compact)',
'memory=192m',
'concurrency_ceiling=1',
'standard)',
'memory=512m',
'concurrency_ceiling=4',
'dense)',
'memory=1g',
'concurrency_ceiling=16',
];
if (
!launcher.startsWith('#!/bin/sh\n\nset -eu\n') ||
requiredLauncherFragments.some(
(fragment) => !launcher.includes(fragment),
) ||
['--privileged', '--network host', '/var/run/docker.sock'].some(
(fragment) => launcher.includes(fragment),
) ||
JSON.stringify(mcpConfig) !==
JSON.stringify({
schema: 'qinglong/cluster-copilot-mcp-server@v1',
clientConfigFile: '/var/run/secrets/qinglong3/copilot-mcp/client.json',
credentialFile: '/var/run/secrets/qinglong3/copilot-mcp/credential',
maxConcurrentRequests: 1,
}) ||
JSON.stringify(clientConfig) !==
JSON.stringify({
schema: 'qinglong/cluster-copilot-client-config@v1',
endpoint: 'https://replace-cluster-api.example.com:5800/',
servername: 'replace-cluster-api.example.com',
caFile: '/var/run/secrets/qinglong3/copilot-mcp/ca.pem',
requestTimeoutMs: 30_000,
}) ||
host?.command !== '/absolute/path/to/docker-stdio.sh' ||
JSON.stringify(host?.args) !== JSON.stringify(['serve']) ||
JSON.stringify(host?.env) !== JSON.stringify(expectedHostEnvironment) ||
JSON.stringify(Object.keys(hostConfig?.mcpServers ?? {})) !==
JSON.stringify(['qinglong-cluster-copilot']) ||
JSON.stringify(Object.keys(host ?? {}).sort()) !==
JSON.stringify(['args', 'command', 'env'])
) {
findings.push(
finding(
'QL3_CLUSTER_COPILOT_MCP_HOST_CONTRACT_DRIFT',
'Cluster Copilot MCP must remain an explicit digest-pinned, non-root, read-only external stdio host process with bounded resource classes',
),
);
}
for (const filePath of kubernetesYamlFiles(
path.join(root, 'deploy/kubernetes/ql3-cluster'),
)) {
if (readFile(filePath, 'utf8').includes('ql3-copilot-mcp')) {
findings.push(
finding(
'QL3_CLUSTER_COPILOT_MCP_KUBERNETES_RESIDENT',
`${path.relative(
root,
filePath,
)} must not deploy a parentless stdio MCP process`,
),
);
}
}
return 'external-host-stdio';
}
function namedResource(resources, kind, name) {
return resources.find(
(resource) => resource?.kind === kind && resource?.metadata?.name === name,
@@ -5465,6 +5564,7 @@ function auditClusterDeployment(options = {}) {
const readFile = options.readFile ?? fs.readFileSync;
const findings = [];
let clusterAdminImageReferences = 0;
let clusterCopilotMcpHost = 'unavailable';
try {
assertExactExternalClosure(readFile, root, findings);
assertDockerfile(readFile, root, findings);
@@ -5474,6 +5574,11 @@ function auditClusterDeployment(options = {}) {
root,
findings,
);
clusterCopilotMcpHost = assertClusterCopilotMcpHostDeployment(
readFile,
root,
findings,
);
assertClusterAiComponent(readFile, root, findings);
assertPluginPackageManagementDeployment(readFile, root, findings);
assertWorkerCredentialManagementDeployment(readFile, root, findings);
@@ -5526,6 +5631,7 @@ function auditClusterDeployment(options = {}) {
promptOutputKeyRotation: 'caller-driven-staged-material',
clusterAi: 'optional-projected-authority',
clusterAiPromptOutput: 'optional-read-only-projected-keyring',
clusterCopilotMcpHost,
imageReleasePins: 'independent-fail-closed-digests',
clusterAdminImageReferences,
findings: Object.freeze(findings),
+2 -2
View File
@@ -232,7 +232,7 @@ function expectedImageConfig(architecture, revision, image) {
? isControlAi
? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js'
: '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/cli.js'
: '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/recovery/pluginPackageRecoveryCli.js',
: '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js',
],
WorkingDir: '/opt/qinglong',
Labels: {
@@ -240,7 +240,7 @@ function expectedImageConfig(architecture, revision, image) {
? isControlAi
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
: 'QingLong 3.0 PostgreSQL-backed cluster control plane'
: 'QingLong 3.0 short-lived cluster administration jobs',
: 'QingLong 3.0 cluster operations and bounded stdio MCP',
'org.opencontainers.image.licenses': 'Apache-2.0',
'org.opencontainers.image.revision': revision,
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',