Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions GUARDRAILS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
9 changes: 8 additions & 1 deletion gitnexus/src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions gitnexus/src/cli/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
141 changes: 116 additions & 25 deletions gitnexus/src/mcp/repository-policy.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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. */
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
function isAbsolutePath(value: string): boolean {
return path.isAbsolute(value) || path.win32.isAbsolute(value);
}
Expand All @@ -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' };
Expand All @@ -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<string>;
private readonly runtimePathAliases: ReadonlyMap<string, ResolvedRepository>;
private readonly defaultRepo?: ResolvedRepository;
private readonly uniqueAllowedContextNames: ReadonlySet<string>;

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<string, ResolvedRepository> | 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<string, ResolvedRepository>()),
]);
this.defaultRepo = defaultRepo;
this.configurationError = configurationError;
this.rejectedEntries = rejectedEntries;

const registryNameCounts = new Map<string, number>();
for (const repo of registry) {
Expand All @@ -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<string, unknown> | undefined): ResolvedRepository | undefined {
Expand Down Expand Up @@ -261,6 +299,12 @@ export class McpRepositoryPolicy {
hasMore,
...(hasMore && { nextOffset: offset + returned }),
},
...(this.rejectedEntries.length > 0 && {
repositoryPolicy: {
status: 'degraded',
rejectedEntries: this.rejectedEntries,
},
}),
};
}

Expand Down Expand Up @@ -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 },
};
}
Expand Down Expand Up @@ -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<string, ResolvedRepository>();
const rejectedEntries: McpRepositoryPolicyRejection[] = [];
if (raw.allowed) {
const byPath = new Map<string, ResolvedRepository>();
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;
Expand All @@ -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;
Expand All @@ -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 =
Expand All @@ -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;
Expand All @@ -469,7 +553,14 @@ export async function preflightMcpRepositoryPolicy(
env: NodeJS.ProcessEnv = process.env,
): Promise<McpRepositoryPolicyPreflightResult> {
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;
Expand Down
42 changes: 42 additions & 0 deletions gitnexus/test/unit/doctor-readonly-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading