diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 8283def8c1..a6297f4f43 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -57,11 +57,11 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m - **Do:** Call `list_repos`, then pass `repo` on subsequent tools. - **Why:** Default target is ambiguous when multiple repos are registered. -### MCP repository policy is blocked +### MCP repository policy is degraded or blocked -- **Trigger:** Stdio MCP tools are present but their descriptions or calls report `MCP repository policy is blocked`. -- **Do:** Repair the named environment key. For an ambiguous allowlist entry, replace the bare repository name with its unique absolute indexed path, then restart the MCP client. Do not remove the allowlist or choose a worktree arbitrarily. -- **Why:** Stdio MCP stays protocol-visible so agents receive the sanitized configuration error, but every repository tool and resource remains fail-closed until restart. Standalone HTTP still refuses to bind when its repository policy is invalid. +- **Trigger:** MCP tool descriptions or `list_repos` report a degraded repository policy, or calls report `MCP repository policy is blocked`. +- **Do:** Run `gitnexus doctor --mcp-config --json` and repair the named environment key/entry. For an ambiguous allowlist entry, replace the bare repository name with its unique absolute indexed path. Restart the MCP client after changing its environment. Do not remove the allowlist or choose a worktree arbitrarily. +- **Why:** Each rejected allowlist entry grants no access. Other successfully resolved entries remain available and agent-visible diagnostics identify the rejected positions without exposing configured values. Stdio and HTTP remain fully blocked when a configured allowlist resolves no repositories, or when the configured default is invalid or outside the successfully resolved allowlist. ### LadybugDB lock / "database busy" diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index a955e8d914..218bc66126 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -281,6 +281,13 @@ export const doctorCommand = async ( const report = await buildMcpConfigDoctorReport(); if (options.json) { console.log(JSON.stringify(report, null, 2)); + } else if (report.valid && report.degraded) { + console.log('MCP repository policy: degraded (read-only preflight)'); + for (const rejected of report.rejectedEntries) { + console.log(` environment: ${rejected.environmentKey}`); + console.log(` entry: ${rejected.entryPosition}`); + console.log(` failure: ${rejected.failureClass}`); + } } else if (report.valid) { console.log('MCP repository policy: valid (read-only preflight)'); } else if ('failureClass' in report) { @@ -289,7 +296,7 @@ export const doctorCommand = async ( console.log(` entry: ${report.entryPosition}`); console.log(` failure: ${report.failureClass}`); } - if (!report.valid) process.exitCode = 1; + if (!report.valid || report.degraded) process.exitCode = 1; return; } diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index a16ea339ce..e4bd9f267a 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -90,6 +90,15 @@ export const mcpCommand = async (options?: { }); if (!repositoryPolicy.configurationError) { + if (repositoryPolicy.rejectedEntries.length > 0) { + logger.warn( + { + mode: 'degraded', + rejectedEntries: repositoryPolicy.rejectedEntries, + }, + 'MCP repository policy rejected configured allowlist entries; valid entries remain available and rejected entries grant no access.', + ); + } const repos = await repositoryPolicy.scopeBackend(backend).listRepos(); if (repos.length === 0) { // Operator-actionable but the server still starts and serves; warn-level, diff --git a/gitnexus/src/mcp/repository-policy.ts b/gitnexus/src/mcp/repository-policy.ts index 97d6945459..72163bcfe2 100644 --- a/gitnexus/src/mcp/repository-policy.ts +++ b/gitnexus/src/mcp/repository-policy.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import type { LocalBackend, RepoListing } from './local/local-backend.js'; import { parseListReposPagination } from './local/local-backend.js'; @@ -22,6 +23,13 @@ interface ResolvedRepository { name: string; path: string; pathKey: string; + filesystemIdentity?: string; +} + +export interface McpRepositoryPolicyRejection { + environmentKey: string; + entryPosition: number; + failureClass: 'invalid' | 'ambiguous'; } /** Minimal read-only surface needed by the production policy resolver. */ @@ -77,14 +85,9 @@ function parseRepositoryPolicy(env: NodeJS.ProcessEnv): RawRepositoryPolicy { let allowed: RawRepositoryPolicy['allowed']; if (allowedRaw) { - allowed = allowedRaw.value.split(',').map((entry, index) => { - const value = entry.trim(); - const entryPosition = index + 1; - if (!value) { - throw new McpRepositoryPolicyConfigurationError(allowedRaw.key, 'blank', entryPosition); - } - return { value, entryPosition }; - }); + allowed = allowedRaw.value + .split(',') + .map((entry, index) => ({ value: entry.trim(), entryPosition: index + 1 })); } let defaultRepo: string | undefined; @@ -108,6 +111,17 @@ function normalizedPath(value: string): string { return process.platform === 'win32' ? resolved.toLowerCase() : resolved; } +function existingFilesystemIdentity(value: string): string | undefined { + try { + const resolved = fs.realpathSync.native(path.resolve(value)); + const stats = fs.statSync(resolved, { bigint: true }); + if (stats.ino !== 0n) return `${stats.dev}:${stats.ino}`; + return `path:${normalizedPath(resolved)}`; + } catch { + return undefined; + } +} + function isAbsolutePath(value: string): boolean { return path.isAbsolute(value) || path.win32.isAbsolute(value); } @@ -117,9 +131,18 @@ function resolveSpecifier( registry: readonly ResolvedRepository[], ): { repo?: ResolvedRepository; reason?: 'invalid' | 'ambiguous' } { const trimmed = specifier.trim(); - const matches = isAbsolutePath(trimmed) - ? registry.filter((repo) => repo.pathKey === normalizedPath(trimmed)) - : registry.filter((repo) => repo.name.toLowerCase() === trimmed.toLowerCase()); + let matches: readonly ResolvedRepository[]; + if (isAbsolutePath(trimmed)) { + matches = registry.filter((repo) => repo.pathKey === normalizedPath(trimmed)); + if (matches.length === 0) { + const identity = existingFilesystemIdentity(trimmed); + if (identity) { + matches = registry.filter((repo) => repo.filesystemIdentity === identity); + } + } + } else { + matches = registry.filter((repo) => repo.name.toLowerCase() === trimmed.toLowerCase()); + } if (matches.length === 0) return { reason: 'invalid' }; if (matches.length > 1) return { reason: 'ambiguous' }; @@ -134,34 +157,43 @@ export class McpRepositoryPolicy { readonly restricted: boolean; readonly configured: boolean; readonly configurationError?: McpRepositoryPolicyConfigurationError; + readonly rejectedEntries: readonly McpRepositoryPolicyRejection[]; private readonly registry: readonly ResolvedRepository[]; private readonly allowed: readonly ResolvedRepository[]; private readonly allowedPathKeys: ReadonlySet; + private readonly runtimePathAliases: ReadonlyMap; private readonly defaultRepo?: ResolvedRepository; private readonly uniqueAllowedContextNames: ReadonlySet; static unrestricted(): McpRepositoryPolicy { - return new McpRepositoryPolicy([], undefined, undefined); + return new McpRepositoryPolicy([], undefined, undefined, undefined); } static blocked(error: McpRepositoryPolicyConfigurationError): McpRepositoryPolicy { - return new McpRepositoryPolicy([], [], undefined, error); + return new McpRepositoryPolicy([], [], undefined, undefined, error); } constructor( registry: readonly ResolvedRepository[], allowed: readonly ResolvedRepository[] | undefined, defaultRepo: ResolvedRepository | undefined, + runtimePathAliases: ReadonlyMap | undefined, configurationError?: McpRepositoryPolicyConfigurationError, + rejectedEntries: readonly McpRepositoryPolicyRejection[] = [], ) { this.registry = registry; this.restricted = allowed !== undefined; this.configured = this.restricted || defaultRepo !== undefined; this.allowed = allowed ?? registry; this.allowedPathKeys = new Set(this.allowed.map((repo) => repo.pathKey)); + this.runtimePathAliases = new Map([ + ...registry.map((repo) => [repo.pathKey, repo] as const), + ...(runtimePathAliases ?? new Map()), + ]); this.defaultRepo = defaultRepo; this.configurationError = configurationError; + this.rejectedEntries = rejectedEntries; const registryNameCounts = new Map(); for (const repo of registry) { @@ -176,11 +208,17 @@ export class McpRepositoryPolicy { } private resolveRuntimeRepo(specifier: string): ResolvedRepository { - const result = resolveSpecifier(specifier, this.registry); - if (!result.repo || (this.restricted && !this.allowedPathKeys.has(result.repo.pathKey))) { + const trimmed = specifier.trim(); + const matches = isAbsolutePath(trimmed) + ? [this.runtimePathAliases.get(normalizedPath(trimmed))].filter( + (repo): repo is ResolvedRepository => repo !== undefined, + ) + : this.registry.filter((repo) => repo.name.toLowerCase() === trimmed.toLowerCase()); + const repo = matches.length === 1 ? matches[0] : undefined; + if (!repo || (this.restricted && !this.allowedPathKeys.has(repo.pathKey))) { throw unavailableRepositoryError(); } - return result.repo; + return repo; } private repoForArgs(args: Record | undefined): ResolvedRepository | undefined { @@ -261,6 +299,12 @@ export class McpRepositoryPolicy { hasMore, ...(hasMore && { nextOffset: offset + returned }), }, + ...(this.rejectedEntries.length > 0 && { + repositoryPolicy: { + status: 'degraded', + rejectedEntries: this.rejectedEntries, + }, + }), }; } @@ -333,11 +377,15 @@ export class McpRepositoryPolicy { .replace(/\nGROUP MODE:[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, '') .replace(/\nCROSS-REPO \(experimental\):[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, '') .replace(/\nDESTINATION TRACE \(cross-repo\):[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, ''); + const degradedNotice = + this.rejectedEntries.length > 0 + ? `DEGRADED: MCP repository policy rejected ${this.rejectedEntries.length} configured allowlist ${this.rejectedEntries.length === 1 ? 'entry' : 'entries'}; valid entries remain available and rejected entries grant no access. Run \`gitnexus doctor --mcp-config --json\` for sanitized coordinates.\n\n` + : ''; return { ...tool, description: this.configurationError ? `BLOCKED: ${this.configurationError.message}\n\n${description}` - : description, + : `${degradedNotice}${description}`, inputSchema: { ...tool.inputSchema, properties }, }; } @@ -400,23 +448,41 @@ export async function createMcpRepositoryPolicy( name: repo.name, path: repo.path, pathKey: normalizedPath(repo.path), + filesystemIdentity: existingFilesystemIdentity(repo.path), })); let allowed: ResolvedRepository[] | undefined; + const runtimePathAliases = new Map(); + const rejectedEntries: McpRepositoryPolicyRejection[] = []; if (raw.allowed) { const byPath = new Map(); for (const specifier of raw.allowed) { const result = resolveSpecifier(specifier.value, registry); if (!result.repo) { - throw new McpRepositoryPolicyConfigurationError( - raw.allowedKey ?? CANONICAL_ALLOWED, - result.reason ?? 'invalid', - specifier.entryPosition, - ); + rejectedEntries.push({ + environmentKey: raw.allowedKey ?? CANONICAL_ALLOWED, + entryPosition: specifier.entryPosition, + failureClass: result.reason === 'ambiguous' ? 'ambiguous' : 'invalid', + }); + continue; } byPath.set(result.repo.pathKey, result.repo); + if (isAbsolutePath(specifier.value)) { + runtimePathAliases.set(normalizedPath(specifier.value), result.repo); + } } allowed = [...byPath.values()]; + if (allowed.length === 0) { + const first = rejectedEntries[0]; + const firstConfiguredEntry = raw.allowed.find( + (entry) => entry.entryPosition === first?.entryPosition, + ); + throw new McpRepositoryPolicyConfigurationError( + first?.environmentKey ?? raw.allowedKey ?? CANONICAL_ALLOWED, + firstConfiguredEntry?.value ? (first?.failureClass ?? 'invalid') : 'blank', + first?.entryPosition ?? 1, + ); + } } let defaultRepo: ResolvedRepository | undefined; @@ -430,6 +496,9 @@ export async function createMcpRepositoryPolicy( ); } defaultRepo = result.repo; + if (isAbsolutePath(raw.defaultRepo)) { + runtimePathAliases.set(normalizedPath(raw.defaultRepo), result.repo); + } } const defaultPathKey = defaultRepo?.pathKey; @@ -441,7 +510,14 @@ export async function createMcpRepositoryPolicy( ); } - return new McpRepositoryPolicy(registry, allowed, defaultRepo); + return new McpRepositoryPolicy( + registry, + allowed, + defaultRepo, + runtimePathAliases, + undefined, + rejectedEntries, + ); } export type McpRepositoryPolicyPreflightFailureClass = @@ -450,7 +526,15 @@ export type McpRepositoryPolicyPreflightFailureClass = | 'default-outside-allowlist'; export type McpRepositoryPolicyPreflightResult = - | { valid: true } + | { + valid: true; + degraded?: false; + } + | { + valid: true; + degraded: true; + rejectedEntries: readonly McpRepositoryPolicyRejection[]; + } | { valid: false; environmentKey: string; @@ -469,7 +553,14 @@ export async function preflightMcpRepositoryPolicy( env: NodeJS.ProcessEnv = process.env, ): Promise { try { - await createMcpRepositoryPolicy(backend, env); + const policy = await createMcpRepositoryPolicy(backend, env); + if (policy.rejectedEntries.length > 0) { + return { + valid: true, + degraded: true, + rejectedEntries: policy.rejectedEntries, + }; + } return { valid: true }; } catch (error) { if (!(error instanceof McpRepositoryPolicyConfigurationError)) throw error; diff --git a/gitnexus/test/unit/doctor-readonly-cli.test.ts b/gitnexus/test/unit/doctor-readonly-cli.test.ts index 849bfffb79..d5705d62fa 100644 --- a/gitnexus/test/unit/doctor-readonly-cli.test.ts +++ b/gitnexus/test/unit/doctor-readonly-cli.test.ts @@ -59,6 +59,48 @@ describe('read-only doctor CLI modes (#127, #133)', () => { expect(`${result.stdout}${result.stderr}`).not.toContain('KnownSecretAlias'); }); + it('exits nonzero with sanitized coordinates when MCP policy is degraded', async () => { + const secretPath = path.join(home.dbPath, 'secret-registry-repo'); + const configuredSecret = 'MissingConfiguredSecret'; + await fs.writeFile( + path.join(home.dbPath, 'registry.json'), + JSON.stringify([ + { + name: 'KnownSecretAlias', + path: secretPath, + storagePath: path.join(secretPath, '.gitnexus'), + indexedAt: '2026-07-20T00:00:00.000Z', + lastCommit: 'a'.repeat(40), + }, + ]), + ); + + const result = runDoctor(['--mcp-config', '--json'], { + GITNEXUS_MCP_ALLOWED_REPOS: `KnownSecretAlias,${configuredSecret}`, + GITNEXUS_MCP_DEFAULT_REPO: undefined, + OPENCLAW_CODE_INDEX_ALLOWED_REPOS: undefined, + OPENCLAW_CODE_INDEX_DEFAULT_REPO: undefined, + }); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + mode: 'mcp-config', + readOnly: true, + valid: true, + degraded: true, + rejectedEntries: [ + { + environmentKey: 'GITNEXUS_MCP_ALLOWED_REPOS', + entryPosition: 2, + failureClass: 'invalid', + }, + ], + }); + expect(`${result.stdout}${result.stderr}`).not.toContain(secretPath); + expect(`${result.stdout}${result.stderr}`).not.toContain(configuredSecret); + expect(`${result.stdout}${result.stderr}`).not.toContain('KnownSecretAlias'); + }); + it('hides registry paths by default and reveals them only with --show-paths', async () => { const secretPath = path.join(home.dbPath, 'secret-registry-repo'); await fs.writeFile( diff --git a/gitnexus/test/unit/mcp-config-doctor.test.ts b/gitnexus/test/unit/mcp-config-doctor.test.ts index 881fa01705..1c5ef68e88 100644 --- a/gitnexus/test/unit/mcp-config-doctor.test.ts +++ b/gitnexus/test/unit/mcp-config-doctor.test.ts @@ -33,24 +33,30 @@ describe('doctor --mcp-config preflight (#127)', () => { }); it.each([ - [ - { GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,MissingConfiguredSecret' }, - 'GITNEXUS_MCP_ALLOWED_REPOS', - 2, - 'invalid', - ], - [ - { GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,,MissingConfiguredSecret' }, - 'GITNEXUS_MCP_ALLOWED_REPOS', - 2, - 'invalid', - ], - [ - { GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,Alpha,MissingConfiguredSecret' }, - 'GITNEXUS_MCP_ALLOWED_REPOS', - 3, - 'invalid', - ], + [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,MissingConfiguredSecret' }, [2]], + [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,,MissingConfiguredSecret' }, [2, 3]], + [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,Alpha,MissingConfiguredSecret' }, [3]], + ])('reports sanitized degraded coordinates for %#', async (env, entryPositions) => { + const report = await buildMcpConfigDoctorReport(env, REGISTRY); + expect(report).toEqual({ + mode: 'mcp-config', + readOnly: true, + valid: true, + degraded: true, + rejectedEntries: entryPositions.map((entryPosition) => ({ + environmentKey: 'GITNEXUS_MCP_ALLOWED_REPOS', + entryPosition, + failureClass: 'invalid', + })), + }); + const serialized = JSON.stringify(report); + expect(serialized).not.toContain('/secret/registry/'); + expect(serialized).not.toContain('MissingConfiguredSecret'); + expect(serialized).not.toContain('Alpha'); + expect(serialized).not.toContain('Duplicate'); + }); + + it.each([ [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Duplicate' }, 'GITNEXUS_MCP_ALLOWED_REPOS', 1, 'ambiguous'], [ { GITNEXUS_MCP_DEFAULT_REPO: 'MissingConfiguredSecret' }, @@ -74,7 +80,7 @@ describe('doctor --mcp-config preflight (#127)', () => { 'invalid', ], ])( - 'returns only sanitized operator coordinates for %#', + 'returns only sanitized blocking coordinates for %#', async (env, environmentKey, entryPosition, failureClass) => { const report = await buildMcpConfigDoctorReport(env, REGISTRY); expect(report).toEqual({ diff --git a/gitnexus/test/unit/mcp-repository-policy.test.ts b/gitnexus/test/unit/mcp-repository-policy.test.ts index ee6230a792..de11a019ce 100644 --- a/gitnexus/test/unit/mcp-repository-policy.test.ts +++ b/gitnexus/test/unit/mcp-repository-policy.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import fsSync from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import type { LocalBackend, RepoListing } from '../../src/mcp/local/local-backend.js'; import { createMcpRepositoryPolicy, @@ -170,27 +174,22 @@ describe('MCP repository policy', () => { expect(message).not.toContain('Beta'); }); - it('keeps MCP discovery agent-visible while a failed policy remains fail-closed', async () => { + it('keeps valid repositories available while rejected entries remain agent-visible', async () => { const backend = createBackend(); - let configurationError: McpRepositoryPolicyConfigurationError | undefined; - try { - await createMcpRepositoryPolicy(backend, { - GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,Duplicate', - }); - } catch (error) { - if (error instanceof McpRepositoryPolicyConfigurationError) configurationError = error; - } - - expect(configurationError).toMatchObject({ - key: 'GITNEXUS_MCP_ALLOWED_REPOS', - reason: 'ambiguous', - entryPosition: 2, + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,Duplicate', }); + expect(policy.rejectedEntries).toEqual([ + { + environmentKey: 'GITNEXUS_MCP_ALLOWED_REPOS', + failureClass: 'ambiguous', + entryPosition: 2, + }, + ]); vi.clearAllMocks(); - const policy = McpRepositoryPolicy.blocked(configurationError!); const server = createMCPServer(backend, { repositoryPolicy: policy }); - const client = new Client({ name: 'blocked-policy-client', version: '0.0.0' }); + const client = new Client({ name: 'degraded-policy-client', version: '0.0.0' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { @@ -200,34 +199,121 @@ describe('MCP repository policy', () => { expect(tools.tools.length).toBeGreaterThan(0); for (const tool of tools.tools) { expect(tool.description).toMatch( - /BLOCKED.*ambiguous.*GITNEXUS_MCP_ALLOWED_REPOS entry 2/is, + /DEGRADED.*rejected 1 configured allowlist entry.*valid entries remain available/is, ); expect(tool.description).not.toContain('/repos/'); expect(tool.description).not.toContain('Duplicate'); } const call = await client.callTool({ name: 'list_repos', arguments: {} }); - expect(call.isError).toBe(true); + expect(call.isError).not.toBe(true); const callText = (call.content[0] as { text: string }).text; - expect(callText).toMatch(/ambiguous.*GITNEXUS_MCP_ALLOWED_REPOS entry 2/is); - expect(callText).not.toContain('/repos/'); + expect(callText).toContain('Alpha'); + expect(callText).toMatch(/repositoryPolicy.*degraded/is); + expect(callText).toMatch(/GITNEXUS_MCP_ALLOWED_REPOS.*entryPosition.*2.*ambiguous/is); expect(callText).not.toContain('Duplicate'); - const resource = await client.readResource({ uri: 'gitnexus://repos' }); - const resourceText = (resource.contents[0] as { text: string }).text; - expect(resourceText).toMatch(/ambiguous.*GITNEXUS_MCP_ALLOWED_REPOS entry 2/is); - expect(resourceText).not.toContain('/repos/'); - expect(resourceText).not.toContain('Duplicate'); + const query = await client.callTool({ + name: 'query', + arguments: { search_query: 'auth', repo: 'Alpha' }, + }); + expect(query.isError).not.toBe(true); + + const rejected = await client.callTool({ + name: 'query', + arguments: { search_query: 'auth', repo: 'Duplicate' }, + }); + expect(rejected.isError).toBe(true); + expect((rejected.content[0] as { text: string }).text).toMatch(/not available/i); + expect(backend.listRepos).toHaveBeenCalled(); + expect(backend.callTool).toHaveBeenCalled(); + expect(backend.resolveRepo).not.toHaveBeenCalled(); + } finally { + await client.close(); + await server.close(); + } + }); + + it('keeps stdio blocked when a configured allowlist resolves no repositories', async () => { + const backend = createBackend(); + let configurationError: McpRepositoryPolicyConfigurationError | undefined; + try { + await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Missing,Duplicate', + }); + } catch (error) { + if (error instanceof McpRepositoryPolicyConfigurationError) configurationError = error; + } + + expect(configurationError).toMatchObject({ + key: 'GITNEXUS_MCP_ALLOWED_REPOS', + reason: 'invalid', + entryPosition: 1, + }); + vi.clearAllMocks(); + if (!configurationError) throw new Error('Expected blocked policy configuration error'); + + const policy = McpRepositoryPolicy.blocked(configurationError); + const server = createMCPServer(backend, { repositoryPolicy: policy }); + const client = new Client({ name: 'blocked-policy-client', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + const call = await client.callTool({ name: 'list_repos', arguments: {} }); + expect(call.isError).toBe(true); + expect((call.content[0] as { text: string }).text).toMatch( + /invalid.*GITNEXUS_MCP_ALLOWED_REPOS entry 1/is, + ); expect(backend.listRepos).not.toHaveBeenCalled(); expect(backend.callTool).not.toHaveBeenCalled(); - expect(backend.resolveRepo).not.toHaveBeenCalled(); } finally { await client.close(); await server.close(); } }); + it('accepts an absolute-path alias only when it resolves to the registered filesystem object', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-policy-identity-')); + const canonical = path.join(tempRoot, 'evaos-hive'); + const alternateCase = path.join(tempRoot, 'evaOS-Hive'); + await fs.mkdir(canonical); + try { + await fs.stat(alternateCase); + } catch { + await fs.symlink(canonical, alternateCase, 'dir'); + } + + try { + const backend = createBackend([ + { + name: 'evaOS-Hive', + path: canonical, + indexedAt: '2026-07-30', + lastCommit: 'e'.repeat(40), + }, + ]); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: alternateCase, + }); + expect(policy.rejectedEntries).toEqual([]); + const syncRealpath = vi.spyOn(fsSync, 'realpathSync'); + const syncStat = vi.spyOn(fsSync, 'statSync'); + await policy + .scopeBackend(backend) + .callTool('query', { search_query: 'agents', repo: alternateCase }); + expect(backend.callTool).toHaveBeenCalledWith('query', { + search_query: 'agents', + repo: canonical, + }); + expect(syncRealpath).not.toHaveBeenCalled(); + expect(syncStat).not.toHaveBeenCalled(); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + it('allows a duplicate-name repository when configured by its unique path', async () => { const backend = createBackend(); const policy = await createMcpRepositoryPolicy(backend, { @@ -298,18 +384,14 @@ describe('MCP repository policy', () => { }); it.each([ - ['Alpha,,Beta', 2], - ['Alpha,,Missing', 2], - ['Alpha,Beta,Missing', 3], - ])('preserves raw allowlist coordinates for %s', async (configured, entryPosition) => { - await expect( - createMcpRepositoryPolicy(createBackend(), { - GITNEXUS_MCP_ALLOWED_REPOS: configured, - }), - ).rejects.toMatchObject({ - key: 'GITNEXUS_MCP_ALLOWED_REPOS', - entryPosition, + ['Alpha,,Beta', [2]], + ['Alpha,,Missing', [2, 3]], + ['Alpha,Beta,Missing', [3]], + ])('preserves rejected allowlist coordinates for %s', async (configured, entryPositions) => { + const policy = await createMcpRepositoryPolicy(createBackend(), { + GITNEXUS_MCP_ALLOWED_REPOS: configured, }); + expect(policy.rejectedEntries.map((entry) => entry.entryPosition)).toEqual(entryPositions); }); it('is transparent when no repository policy is configured', async () => {