From c1601317fd9d8a69d950c020fe4b7257160e117a Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Tue, 7 Apr 2026 09:05:29 -0500 Subject: [PATCH 1/5] fix: address security items 1-4 from pre-merge review 1. pathPolicy: resolve symlinks via realpathSync to prevent containment bypass - A symlink inside an allowed dir pointing outside could bypass the check - Falls back to lexical resolution for non-existent (output) paths - Resolves allowedPaths through realpathSync too 2. automationTools: assertPathAllowed on properties_path in config.load - properties_path was passed directly to sf CLI with no path policy check - Thread ServerConfig through registerAutomationConfigLoad and registerAllAutomationTools - PathPolicyError surfaced as structured isError response 3. mcp-smoke.cjs: remove || 'true' license bypass fallback - Changed to || '' so smoke test fails if PROVAR_DEV_WHITELIST_KEYS is unset - Prevents silent license bypass in forks/local runs without the secret 4. algasClient: fix 401 retry reusing expired AbortController - Original controller timer may have nearly elapsed before 401 response arrives - Retry now uses a fresh AbortController with a full TIMEOUT_MS budget Tests: add symlink containment test to pathPolicy.test.ts add path policy enforcement tests to automationTools.test.ts Co-Authored-By: Claude Sonnet 4.6 --- scripts/mcp-smoke.cjs | 2 +- src/mcp/licensing/algasClient.ts | 18 +++++++---- src/mcp/security/pathPolicy.ts | 32 ++++++++++++++++++-- src/mcp/server.ts | 2 +- src/mcp/tools/automationTools.ts | 12 ++++++-- test/unit/mcp/automationTools.test.ts | 42 +++++++++++++++++++++++++- test/unit/mcp/pathPolicy.test.ts | 43 ++++++++++++++++++++++++++- 7 files changed, 137 insertions(+), 14 deletions(-) diff --git a/scripts/mcp-smoke.cjs b/scripts/mcp-smoke.cjs index f4cc1346..670bb498 100644 --- a/scripts/mcp-smoke.cjs +++ b/scripts/mcp-smoke.cjs @@ -30,7 +30,7 @@ const server = spawn('sf', ['provar', 'mcp', 'start', '--allowed-paths', TMP], { shell: true, env: { ...process.env, - PROVAR_DEV_WHITELIST_KEYS: process.env.PROVAR_DEV_WHITELIST_KEYS || 'true', + PROVAR_DEV_WHITELIST_KEYS: process.env.PROVAR_DEV_WHITELIST_KEYS || '', }, }); diff --git a/src/mcp/licensing/algasClient.ts b/src/mcp/licensing/algasClient.ts index 83e6bcd0..68f5cafc 100644 --- a/src/mcp/licensing/algasClient.ts +++ b/src/mcp/licensing/algasClient.ts @@ -127,14 +127,22 @@ export async function validateKeyWithAlgas(licenseKey: string): Promise retryController.abort(), TIMEOUT_MS); + try { + const refreshedToken = await getCognitoToken(); + res = await fetch(`${TAG_BASE_URL}/${encodeURIComponent(licenseKey)}`, { + headers: { Authorization: `Bearer ${refreshedToken}` }, + signal: retryController.signal, + }); + } finally { + clearTimeout(retryTimeout); + } } if (res.status === 404) { diff --git a/src/mcp/security/pathPolicy.ts b/src/mcp/security/pathPolicy.ts index 9e34d32b..00eeef1e 100644 --- a/src/mcp/security/pathPolicy.ts +++ b/src/mcp/security/pathPolicy.ts @@ -5,6 +5,7 @@ * For full license text, see LICENSE.md file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import fs from 'node:fs'; import path from 'node:path'; export class PathPolicyError extends Error { @@ -24,6 +25,11 @@ export class PathPolicyError extends Error { * - PATH_NOT_ALLOWED — resolved path is outside all allowed roots * * When allowedPaths is empty, all paths are permitted (unrestricted mode). + * + * Symlinks are resolved via fs.realpathSync so that a symlink inside an allowed + * directory pointing to a location outside it cannot bypass containment. + * If the path does not yet exist (e.g. an output file to be created), the parent + * directory is resolved instead and the basename re-attached. */ export function assertPathAllowed(filePath: string, allowedPaths: string[]): void { // Check the original path for `..` segments before any normalization resolves them away @@ -31,8 +37,30 @@ export function assertPathAllowed(filePath: string, allowedPaths: string[]): voi if (rawSegments.some((s) => s === '..')) { throw new PathPolicyError('PATH_TRAVERSAL', `Path traversal detected: ${filePath}`); } - const resolved = path.resolve(filePath); - const resolvedAllowed = allowedPaths.map((p) => path.resolve(path.normalize(p))); + + // Resolve symlinks so a symlink inside an allowed dir that points outside cannot bypass + // the containment check. Fall back to lexical resolution when the path doesn't exist yet. + let resolved: string; + try { + resolved = fs.realpathSync(filePath); + } catch { + // Path doesn't exist — resolve the parent (which should exist) to catch symlinks there + const parent = path.dirname(path.resolve(filePath)); + try { + resolved = path.join(fs.realpathSync(parent), path.basename(filePath)); + } catch { + resolved = path.resolve(filePath); + } + } + + const resolvedAllowed = allowedPaths.map((p) => { + try { + return fs.realpathSync(p); + } catch { + return path.resolve(path.normalize(p)); + } + }); + if ( resolvedAllowed.length > 0 && !resolvedAllowed.some((base) => resolved === base || resolved.startsWith(base + path.sep)) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0b29a542..3762140b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -63,7 +63,7 @@ export function createProvarMcpServer(config: ServerConfig): McpServer { registerProjectValidateFromPath(server, config); registerAllPropertiesTools(server, config); registerAllQualityHubTools(server); - registerAllAutomationTools(server); + registerAllAutomationTools(server, config); registerAllDefectTools(server); registerAllAntTools(server, config); registerAllRcaTools(server); diff --git a/src/mcp/tools/automationTools.ts b/src/mcp/tools/automationTools.ts index ff9a8258..014b7fd6 100644 --- a/src/mcp/tools/automationTools.ts +++ b/src/mcp/tools/automationTools.ts @@ -14,6 +14,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { makeError, makeRequestId } from '../schemas/common.js'; import { log } from '../logging/logger.js'; import { sfSpawnHelper } from './sfSpawn.js'; +import { assertPathAllowed, PathPolicyError } from '../security/pathPolicy.js'; +import type { ServerConfig } from '../server.js'; // ── SF CLI discovery ────────────────────────────────────────────────────────── @@ -135,7 +137,7 @@ function handleSpawnError(err: unknown, requestId: string, toolName: string): { // ── Tool: provar.automation.config.load ────────────────────────────────────── -export function registerAutomationConfigLoad(server: McpServer): void { +export function registerAutomationConfigLoad(server: McpServer, config: ServerConfig): void { server.tool( 'provar.automation.config.load', [ @@ -153,6 +155,7 @@ export function registerAutomationConfigLoad(server: McpServer): void { log('info', 'provar.automation.config.load', { requestId, properties_path }); try { + assertPathAllowed(properties_path, config.allowedPaths); const result = runSfCommand(['provar', 'automation', 'config', 'load', '--properties-file', properties_path], sf_path); const response = { requestId, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, properties_path }; @@ -162,6 +165,9 @@ export function registerAutomationConfigLoad(server: McpServer): void { return { content: [{ type: 'text' as const, text: JSON.stringify(response) }], structuredContent: response }; } catch (err) { + if (err instanceof PathPolicyError) { + return { isError: true as const, content: [{ type: 'text' as const, text: JSON.stringify(makeError(err.code, err.message, requestId, false)) }] }; + } return handleSpawnError(err, requestId, 'provar.automation.config.load'); } } @@ -458,9 +464,9 @@ export function registerAutomationSetup(server: McpServer): void { // ── Bulk registration ───────────────────────────────────────────────────────── -export function registerAllAutomationTools(server: McpServer): void { +export function registerAllAutomationTools(server: McpServer, config: ServerConfig): void { registerAutomationSetup(server); - registerAutomationConfigLoad(server); + registerAutomationConfigLoad(server, config); registerAutomationTestRun(server); registerAutomationCompile(server); registerAutomationMetadataDownload(server); diff --git a/test/unit/mcp/automationTools.test.ts b/test/unit/mcp/automationTools.test.ts index 3c71c0af..66a8acdf 100644 --- a/test/unit/mcp/automationTools.test.ts +++ b/test/unit/mcp/automationTools.test.ts @@ -66,7 +66,8 @@ describe('automationTools', () => { const { registerAllAutomationTools, setSfPathCacheForTesting } = await import('../../../src/mcp/tools/automationTools.js'); setSfPathCacheForTesting('sf'); // bypass probe spawn; tests control spawnStub directly - registerAllAutomationTools(server as unknown as McpServer); + // allowedPaths: [] means unrestricted — existing tests use arbitrary paths + registerAllAutomationTools(server as unknown as McpServer, { allowedPaths: [] }); }); afterEach(() => { @@ -461,6 +462,45 @@ describe('automationTools', () => { const [cmd] = spawnStub.firstCall.args as [string, string[]]; assert.equal(cmd, '/custom/bin/sf'); }); + + describe('path policy enforcement', () => { + let restrictedServer: MockMcpServer; + const allowedDir = os.tmpdir(); + + beforeEach(async () => { + restrictedServer = new MockMcpServer(); + const { registerAutomationConfigLoad, setSfPathCacheForTesting } = await import('../../../src/mcp/tools/automationTools.js'); + setSfPathCacheForTesting('sf'); + registerAutomationConfigLoad(restrictedServer as unknown as McpServer, { allowedPaths: [allowedDir] }); + }); + + it('rejects properties_path outside allowed paths', () => { + const result = restrictedServer.call('provar.automation.config.load', { + properties_path: '/etc/passwd', + }); + assert.ok(isError(result)); + assert.equal(parseBody(result).error_code, 'PATH_NOT_ALLOWED'); + assert.ok(!spawnStub.called, 'sf should not be spawned for a rejected path'); + }); + + it('rejects properties_path with .. traversal', () => { + const result = restrictedServer.call('provar.automation.config.load', { + properties_path: path.join(allowedDir, '..', 'etc', 'passwd'), + }); + assert.ok(isError(result)); + assert.equal(parseBody(result).error_code, 'PATH_TRAVERSAL'); + assert.ok(!spawnStub.called, 'sf should not be spawned for a path traversal'); + }); + + it('allows properties_path within allowed paths', () => { + spawnStub.returns(makeSpawnResult('', '', 0)); + const allowed = path.join(allowedDir, 'provardx-properties.json'); + const result = restrictedServer.call('provar.automation.config.load', { + properties_path: allowed, + }); + assert.ok(!isError(result)); + }); + }); }); // ── sf_path threading ───────────────────────────────────────────────────── diff --git a/test/unit/mcp/pathPolicy.test.ts b/test/unit/mcp/pathPolicy.test.ts index 1ca686b7..fe3b6535 100644 --- a/test/unit/mcp/pathPolicy.test.ts +++ b/test/unit/mcp/pathPolicy.test.ts @@ -1,7 +1,8 @@ import { strict as assert } from 'node:assert'; +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { describe, it } from 'mocha'; +import { describe, it, afterEach } from 'mocha'; import { assertPathAllowed, PathPolicyError } from '../../../src/mcp/security/pathPolicy.js'; const tmp = os.tmpdir(); @@ -56,4 +57,44 @@ describe('pathPolicy', () => { assert.equal(e.code, 'PATH_NOT_ALLOWED'); } }); + + describe('symlink containment', () => { + let symlinkDir: string; + + afterEach(() => { + if (symlinkDir) { + try { fs.rmSync(symlinkDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); + + it('rejects a symlink inside an allowed dir that points outside it', function () { + if (process.platform === 'win32') { + // Symlink creation on Windows requires elevated privileges in most CI environments + this.skip(); + return; + } + symlinkDir = fs.mkdtempSync(path.join(tmp, 'pathpolicy-sym-')); + const target = path.join(tmp, 'outside-secret.txt'); + fs.writeFileSync(target, 'secret'); + const link = path.join(symlinkDir, 'evil-link'); + fs.symlinkSync(target, link); + + try { + assertPathAllowed(link, [symlinkDir]); + assert.fail('Expected PathPolicyError to be thrown'); + } catch (e) { + assert.ok(e instanceof PathPolicyError, 'Expected PathPolicyError'); + assert.equal(e.code, 'PATH_NOT_ALLOWED'); + } finally { + fs.unlinkSync(target); + } + }); + + it('allows a real path inside an allowed dir (not a symlink)', () => { + symlinkDir = fs.mkdtempSync(path.join(tmp, 'pathpolicy-real-')); + const real = path.join(symlinkDir, 'real-file.txt'); + fs.writeFileSync(real, 'content'); + assert.doesNotThrow(() => assertPathAllowed(real, [symlinkDir])); + }); + }); }); From 4a7d1c14e352df9e05bf9fc8257fc7da99f9128c Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Tue, 7 Apr 2026 09:08:30 -0500 Subject: [PATCH 2/5] fix: address security/correctness items 5-10 from pre-merge review 5. CI_Execution.yml: remove NODE_ENV=test from MCP smoke step - NODE_ENV=test bypassed license validation entirely via the test shortcut - PROVAR_DEV_WHITELIST_KEYS secret now exercises the whitelist code path 6. antTools: assertPathAllowed for all path inputs in provar.ant.generate - provar_home, project_path, results_path, license_path, smtp_path, project_cache_path now validated before being embedded in ANT XML - Prevents LLM from embedding out-of-bounds paths in generated build.xml 7. antTools: add single-quote escape to escapeXmlAttr - ' was missing from the escape set, creating an XML injection vector - Added .replace(/'/g, ''') 8. licenseCache: write cache file with mode 0o600 - Default umask (644) allowed any local user to read/modify the cache - With 0o600 only the owning user can read or write it 9. ideDetection: remove findLicenseByDecryptedKey and AES decryption code - Function is dead code (never called by licenseValidator) - Removing it eliminates the hardcoded AES-128-ECB key from the public package - Remove createDecipheriv import, AES_KEY constant, decryptLicenseKeyField - Remove corresponding tests from licenseValidator.test.ts 10. CI_Execution.yml: restore macos-latest to default CI matrix - Only ubuntu-latest was being tested by default after the matrix fix - os.homedir() and path behaviour differ on macOS; primary user OS Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/CI_Execution.yml | 3 +- src/mcp/licensing/ideDetection.ts | 73 ------------------ src/mcp/licensing/licenseCache.ts | 2 +- src/mcp/tools/antTools.ts | 10 +++ .../mcp/licensing/licenseValidator.test.ts | 76 ------------------- 5 files changed, 12 insertions(+), 152 deletions(-) diff --git a/.github/workflows/CI_Execution.yml b/.github/workflows/CI_Execution.yml index 53429420..c5d03246 100644 --- a/.github/workflows/CI_Execution.yml +++ b/.github/workflows/CI_Execution.yml @@ -22,7 +22,7 @@ jobs: provardx-ci-execution: strategy: matrix: - os: ${{ fromJSON(inputs.OS && format('[{0}]', inputs.OS) || '["ubuntu-latest"]') }} + os: ${{ fromJSON(inputs.OS && format('[{0}]', inputs.OS) || '["ubuntu-latest", "macos-latest"]') }} nodeversion: [20] runs-on: ${{ matrix.os }} steps: @@ -102,7 +102,6 @@ jobs: - name: MCP smoke test timeout-minutes: 5 env: - NODE_ENV: test PROVAR_DEV_WHITELIST_KEYS: ${{ secrets.PROVAR_DEV_WHITELIST_KEYS }} run: node scripts/mcp-smoke.cjs - name: Check out Regression repo diff --git a/src/mcp/licensing/ideDetection.ts b/src/mcp/licensing/ideDetection.ts index 5afe8735..6a6416dd 100644 --- a/src/mcp/licensing/ideDetection.ts +++ b/src/mcp/licensing/ideDetection.ts @@ -14,36 +14,13 @@ * - licenseStatus — Activated | NotActivated | Expired | Invalid | QuotaReached * - licenseType — Fixed Seat | Floating | Trial | None * - lastOnlineAvailabilityCheckUtc — epoch ms of last ALGAS check by the IDE - * - licenseKey — AES-128-ECB encrypted with key "provarautomation", Base64-encoded - * - * The licenseKey field is AES-128-ECB encrypted (LicenseSupport.KEY = "provarautomation"). - * We decrypt it to allow cross-referencing an explicitly supplied --license-key against - * the IDE's already-validated license state without re-calling the licensing API. */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { createDecipheriv } from 'node:crypto'; import type { LicenseType } from './licenseCache.js'; -/** AES-128-ECB key used by Provar IDE to encrypt the licenseKey field. */ -const AES_KEY = Buffer.from('provarautomation'); // 16 bytes ASCII - -/** - * Decrypt an AES-128-ECB + PKCS5 encrypted, Base64-encoded licenseKey field. - * Returns the plaintext string, or null if decryption fails (wrong key / corrupt). - */ -function decryptLicenseKeyField(encryptedBase64: string): string | null { - try { - const decipher = createDecipheriv('aes-128-ecb', AES_KEY, null); - const buf = Buffer.from(encryptedBase64, 'base64'); - return Buffer.concat([decipher.update(buf), decipher.final()]).toString('utf-8'); - } catch { - return null; - } -} - /** Mirrors License4J's DEFAULT_LICENSE_FOLDER_PATH + PROVAR_USER_HOME. */ function provarLicensesDir(): string { const provarHome = process.env['PROVAR_HOME'] ?? path.join(os.homedir(), 'Provar'); @@ -142,53 +119,3 @@ export function findActivatedIdeLicense(): IdeLicenseState | null { return activated.sort((a, b) => b.lastOnlineCheckMs - a.lastOnlineCheckMs)[0]; } -/** - * Search all IDE .properties files for one whose decrypted licenseKey matches - * the supplied raw key string. - * - * Used to cross-reference an explicit --license-key against the IDE's already- - * validated activation state without making a fresh API call. - * - * Returns the matching IdeLicenseState (which may have activated=false if the - * IDE recorded the key but it isn't currently activated), or null when no file - * decrypts to the given key. - */ -export function findLicenseByDecryptedKey(rawKey: string): IdeLicenseState | null { - const dir = provarLicensesDir(); - if (!fs.existsSync(dir)) return null; - - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return null; - } - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.properties')) continue; - try { - const content = fs.readFileSync(path.join(dir, entry.name), 'utf-8'); - const props = parseProperties(content); - - const encryptedKey = props.get('licenseKey'); - if (!encryptedKey) continue; - - const decrypted = decryptLicenseKeyField(encryptedKey); - if (decrypted !== rawKey) continue; - - const licenseStatus = props.get('licenseStatus') ?? ''; - const licenseType = parseLicenseType(props.get('licenseType') ?? ''); - const lastCheck = parseInt(props.get('lastOnlineAvailabilityCheckUtc') ?? '0', 10); - - return { - name: entry.name.slice(0, -'.properties'.length), - licenseType, - activated: licenseStatus === 'Activated', - lastOnlineCheckMs: isNaN(lastCheck) ? 0 : lastCheck, - }; - } catch { - // Unreadable or corrupt file — skip - } - } - return null; -} diff --git a/src/mcp/licensing/licenseCache.ts b/src/mcp/licensing/licenseCache.ts index dd0dce3e..94b1d03e 100644 --- a/src/mcp/licensing/licenseCache.ts +++ b/src/mcp/licensing/licenseCache.ts @@ -78,7 +78,7 @@ export function writeCacheEntry(entry: CacheEntry): void { } } cache[entry.keyHash] = entry; - fs.writeFileSync(file, JSON.stringify(cache, null, 2), 'utf-8'); + fs.writeFileSync(file, JSON.stringify(cache, null, 2), { encoding: 'utf-8', mode: 0o600 }); } catch { // Cache write failure is non-fatal; validation result still returned to caller. } diff --git a/src/mcp/tools/antTools.ts b/src/mcp/tools/antTools.ts index 1ed4553a..056facbe 100644 --- a/src/mcp/tools/antTools.ts +++ b/src/mcp/tools/antTools.ts @@ -251,6 +251,15 @@ export function registerAntGenerate(server: McpServer, config: ServerConfig): vo }); try { + // Validate all path inputs before writing anything — these get embedded in the + // generated ANT build.xml and would be accessed by ANT at execution time. + assertPathAllowed(input.provar_home, config.allowedPaths); + assertPathAllowed(input.project_path, config.allowedPaths); + assertPathAllowed(input.results_path, config.allowedPaths); + if (input.license_path) assertPathAllowed(input.license_path, config.allowedPaths); + if (input.smtp_path) assertPathAllowed(input.smtp_path, config.allowedPaths); + if (input.project_cache_path) assertPathAllowed(input.project_cache_path, config.allowedPaths); + const xmlContent = buildAntXml(input); const filePath = input.output_path ? path.resolve(input.output_path) : undefined; let written = false; @@ -559,6 +568,7 @@ function escapeXmlAttr(value: string): string { return value .replace(/&/g, '&') .replace(/"/g, '"') + .replace(/'/g, ''') .replace(//g, '>'); } diff --git a/test/unit/mcp/licensing/licenseValidator.test.ts b/test/unit/mcp/licensing/licenseValidator.test.ts index 795619a0..1ce01544 100644 --- a/test/unit/mcp/licensing/licenseValidator.test.ts +++ b/test/unit/mcp/licensing/licenseValidator.test.ts @@ -1,7 +1,6 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; -import { createCipheriv } from 'node:crypto'; import { strict as assert } from 'node:assert'; import { describe, it, beforeEach, afterEach } from 'mocha'; import { LicenseError } from '../../../../src/mcp/licensing/licenseError.js'; @@ -18,15 +17,8 @@ import { validateLicense } from '../../../../src/mcp/licensing/licenseValidator. import { readIdeLicenses, findActivatedIdeLicense, - findLicenseByDecryptedKey, } from '../../../../src/mcp/licensing/ideDetection.js'; -/** Encrypt a raw license key with AES-128-ECB + PKCS5 to mimic IDE storage. */ -function encryptKey(rawKey: string): string { - const cipher = createCipheriv('aes-128-ecb', Buffer.from('provarautomation'), null); - return Buffer.concat([cipher.update(Buffer.from(rawKey, 'utf-8')), cipher.final()]).toString('base64'); -} - // ── Helpers ───────────────────────────────────────────────────────────────── /** Make a fake CacheEntry. checkedAt defaults to now. */ @@ -301,74 +293,6 @@ describe('ideDetection', () => { assert.equal(best?.name, 'Recent'); }); - it('findLicenseByDecryptedKey returns null when no .licenses folder', () => { - assert.equal(findLicenseByDecryptedKey('any-key'), null); - }); - - it('findLicenseByDecryptedKey returns null when no file matches the key', () => { - writeLicenseFile('LicA', { - licenseKey: encryptKey('OTHER-KEY-111'), - licenseStatus: 'Activated', - licenseType: 'Floating', - lastOnlineAvailabilityCheckUtc: String(Date.now()), - }); - assert.equal(findLicenseByDecryptedKey('AAAAA-BBBBB-CCCCC-DDDDD-EEEEE'), null); - }); - - it('findLicenseByDecryptedKey returns matching state when key decrypts correctly', () => { - const rawKey = 'AAAAA-BBBBB-CCCCC-DDDDD-EEEEE'; - writeLicenseFile('FloatingLic', { - licenseKey: encryptKey(rawKey), - licenseStatus: 'Activated', - licenseType: 'Floating', - lastOnlineAvailabilityCheckUtc: String(Date.now()), - }); - const state = findLicenseByDecryptedKey(rawKey); - assert.ok(state !== null); - assert.equal(state?.name, 'FloatingLic'); - assert.equal(state?.licenseType, 'Floating'); - assert.equal(state?.activated, true); - }); - - it('findLicenseByDecryptedKey returns state even when not Activated', () => { - const rawKey = 'SOME-KEY-NOT-ACTIVE'; - writeLicenseFile('NotActive', { - licenseKey: encryptKey(rawKey), - licenseStatus: 'NotActivated', - licenseType: 'FixedSeat', - lastOnlineAvailabilityCheckUtc: String(Date.now()), - }); - const state = findLicenseByDecryptedKey(rawKey); - assert.ok(state !== null); - assert.equal(state?.activated, false); - }); - - it('findLicenseByDecryptedKey returns null when licenseKey field is absent', () => { - writeLicenseFile('NoKey', { - licenseStatus: 'Activated', - licenseType: 'FixedSeat', - lastOnlineAvailabilityCheckUtc: String(Date.now()), - }); - assert.equal(findLicenseByDecryptedKey('any'), null); - }); - - it('findLicenseByDecryptedKey matches across multiple files', () => { - const rawKey = 'MULTI-FILE-MATCH-KEY'; - writeLicenseFile('Unrelated', { - licenseKey: encryptKey('DIFFERENT-KEY'), - licenseStatus: 'Activated', - licenseType: 'Trial', - lastOnlineAvailabilityCheckUtc: String(Date.now()), - }); - writeLicenseFile('Target', { - licenseKey: encryptKey(rawKey), - licenseStatus: 'Activated', - licenseType: 'Floating', - lastOnlineAvailabilityCheckUtc: String(Date.now()), - }); - const state = findLicenseByDecryptedKey(rawKey); - assert.equal(state?.name, 'Target'); - }); }); // ── E. licenseValidator — IDE auto-detection (non-test env) ────────────────── From 7e5912fe47e7db2715a56ee7a6f5e8b0995025f9 Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Tue, 7 Apr 2026 09:16:58 -0500 Subject: [PATCH 3/5] fix: correct import order in automationTools.ts (lint) --- src/mcp/tools/automationTools.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/tools/automationTools.ts b/src/mcp/tools/automationTools.ts index 014b7fd6..fefdca8e 100644 --- a/src/mcp/tools/automationTools.ts +++ b/src/mcp/tools/automationTools.ts @@ -13,9 +13,9 @@ import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { makeError, makeRequestId } from '../schemas/common.js'; import { log } from '../logging/logger.js'; -import { sfSpawnHelper } from './sfSpawn.js'; -import { assertPathAllowed, PathPolicyError } from '../security/pathPolicy.js'; import type { ServerConfig } from '../server.js'; +import { assertPathAllowed, PathPolicyError } from '../security/pathPolicy.js'; +import { sfSpawnHelper } from './sfSpawn.js'; // ── SF CLI discovery ────────────────────────────────────────────────────────── From a079487ec58baed0b1ccfe0cd9a31780adb1d7b5 Mon Sep 17 00:00:00 2001 From: Michael Dailey <49916244+mrdailey99@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:20:32 -0500 Subject: [PATCH 4/5] Update src/mcp/licensing/licenseCache.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mcp/licensing/licenseCache.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mcp/licensing/licenseCache.ts b/src/mcp/licensing/licenseCache.ts index 94b1d03e..ecad0eb8 100644 --- a/src/mcp/licensing/licenseCache.ts +++ b/src/mcp/licensing/licenseCache.ts @@ -79,6 +79,11 @@ export function writeCacheEntry(entry: CacheEntry): void { } cache[entry.keyHash] = entry; fs.writeFileSync(file, JSON.stringify(cache, null, 2), { encoding: 'utf-8', mode: 0o600 }); + try { + fs.chmodSync(file, 0o600); + } catch { + // Best-effort permission hardening; cache write itself already succeeded. + } } catch { // Cache write failure is non-fatal; validation result still returned to caller. } From b8b4ce6a759f8bbb0428adee4290281679ee4fab Mon Sep 17 00:00:00 2001 From: Michael Dailey Date: Tue, 7 Apr 2026 09:21:03 -0500 Subject: [PATCH 5/5] fix: initialize symlinkDir as undefined to satisfy strict TS (TS2454) --- test/unit/mcp/pathPolicy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/mcp/pathPolicy.test.ts b/test/unit/mcp/pathPolicy.test.ts index fe3b6535..20afc9fa 100644 --- a/test/unit/mcp/pathPolicy.test.ts +++ b/test/unit/mcp/pathPolicy.test.ts @@ -59,7 +59,7 @@ describe('pathPolicy', () => { }); describe('symlink containment', () => { - let symlinkDir: string; + let symlinkDir: string | undefined; afterEach(() => { if (symlinkDir) {