fix(mcp-config-validator): stop flagging auto-injected + POSIX env vars
Clears false positives on valid .mcp.json (gap matrix, Batch 1):
- ${CLAUDE_PROJECT_DIR} is auto-injected at runtime (CC 2.1.139) and never
needs an env block — now allowlisted.
- POSIX expansions like ${VAR%pattern} / ${VAR:-default} are resolved by
Claude Code (CC 2.1.142); the env-var regex now matches only bare
${IDENTIFIER}, so operator expressions are skipped.
Genuine bare unreferenced vars are still flagged (broken-project regression
intact). The MCP `trust` field is untouched — it is verify-first (point 4),
not part of Batch 1.
Tests: hermetic runtime temp-fixture; 23/23 MCP green, both directions covered.
Ref: docs/cc-2.1.x-gap-matrix.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
parent
98ddd777fb
commit
4b94da0f11
2 changed files with 64 additions and 3 deletions
|
|
@ -18,7 +18,13 @@ const VALID_SERVER_FIELDS = new Set([
|
||||||
'type', 'command', 'args', 'env', 'url', 'headers', 'timeout', 'trust',
|
'type', 'command', 'args', 'env', 'url', 'headers', 'timeout', 'trust',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
|
// Match only bare ${IDENTIFIER} references. POSIX expansions like ${VAR%pattern}
|
||||||
|
// or ${VAR:-default} contain operators and are skipped — Claude Code resolves
|
||||||
|
// them at launch (CC 2.1.142), so they are not config-defined env references.
|
||||||
|
const ENV_VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
||||||
|
|
||||||
|
// Auto-injected by Claude Code at runtime — never require an env block (CC 2.1.139).
|
||||||
|
const AUTO_INJECTED_ENV_VARS = new Set(['CLAUDE_PROJECT_DIR']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all .mcp.json files discovered.
|
* Scan all .mcp.json files discovered.
|
||||||
|
|
@ -116,6 +122,7 @@ export async function scan(targetPath, discovery) {
|
||||||
ENV_VAR_PATTERN.lastIndex = 0;
|
ENV_VAR_PATTERN.lastIndex = 0;
|
||||||
while ((match = ENV_VAR_PATTERN.exec(arg)) !== null) {
|
while ((match = ENV_VAR_PATTERN.exec(arg)) !== null) {
|
||||||
const varName = match[1];
|
const varName = match[1];
|
||||||
|
if (AUTO_INJECTED_ENV_VARS.has(varName)) continue;
|
||||||
const hasEnvBlock = config.env && typeof config.env === 'object' && varName in config.env;
|
const hasEnvBlock = config.env && typeof config.env === 'object' && varName in config.env;
|
||||||
if (!hasEnvBlock) {
|
if (!hasEnvBlock) {
|
||||||
findings.push(finding({
|
findings.push(finding({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { describe, it, beforeEach } from 'node:test';
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { resolve } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||||
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||||
import { scan } from '../../scanners/mcp-config-validator.mjs';
|
import { scan } from '../../scanners/mcp-config-validator.mjs';
|
||||||
|
|
@ -101,6 +103,58 @@ describe('MCP scanner — broken project', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MCP scanner — env-var false positives (CC 2.1.139/2.1.142, Batch 1)', () => {
|
||||||
|
let tmpRoot;
|
||||||
|
let envFindings;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetCounter();
|
||||||
|
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-mcp-env-'));
|
||||||
|
const mcp = {
|
||||||
|
mcpServers: {
|
||||||
|
proj: {
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
// CLAUDE_PROJECT_DIR is auto-injected (CC 2.1.139); ${VAR%…}/${VAR:-…}
|
||||||
|
// are POSIX expansions CC resolves (2.1.142) — none need an env block.
|
||||||
|
args: ['${CLAUDE_PROJECT_DIR}/server.mjs', '--root', '${HOME%/}', '--cfg', '${CONFIG_DIR:-/etc}'],
|
||||||
|
trust: 'workspace',
|
||||||
|
},
|
||||||
|
legacy: {
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['${REAL_MISSING}'],
|
||||||
|
trust: 'workspace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await writeFile(join(tmpRoot, '.mcp.json'), JSON.stringify(mcp, null, 2) + '\n', 'utf8');
|
||||||
|
const discovery = await discoverConfigFiles(tmpRoot);
|
||||||
|
const result = await scan(tmpRoot, discovery);
|
||||||
|
envFindings = result.findings.filter(f => f.title === 'Unreferenced env var in args');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT flag ${CLAUDE_PROJECT_DIR} (auto-injected runtime var)', () => {
|
||||||
|
const f = envFindings.find(x => /CLAUDE_PROJECT_DIR/.test(x.description || ''));
|
||||||
|
assert.equal(f, undefined, 'CLAUDE_PROJECT_DIR must be allowlisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT flag POSIX expansions ${VAR%…} / ${VAR:-…}', () => {
|
||||||
|
const f = envFindings.find(x => /HOME|CONFIG_DIR/.test(x.description || ''));
|
||||||
|
assert.equal(f, undefined, 'POSIX operator expansions are not env-var references');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('STILL flags a genuine bare unreferenced var', () => {
|
||||||
|
assert.equal(envFindings.length, 1,
|
||||||
|
`expected exactly REAL_MISSING; got: ${envFindings.map(f => f.description).join(' | ')}`);
|
||||||
|
assert.match(envFindings[0].description || '', /REAL_MISSING/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('MCP scanner — empty project', () => {
|
describe('MCP scanner — empty project', () => {
|
||||||
let result;
|
let result;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue