@viberqc/mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +73 -0
- package/dist/client/api.js +135 -0
- package/dist/contracts/repository.js +1 -0
- package/dist/contracts/tools.js +212 -0
- package/dist/index.js +18 -0
- package/dist/repository/git-files.js +77 -0
- package/dist/repository/identity.js +72 -0
- package/dist/repository/inventory.js +91 -0
- package/dist/repository/viberqc-ignore.js +94 -0
- package/dist/security/content-policy.js +147 -0
- package/dist/security/path-policy.js +167 -0
- package/dist/security/redaction.js +63 -0
- package/dist/tools/register.js +100 -0
- package/dist/tools/repository-inspection.js +223 -0
- package/dist/tools/server.js +43 -0
- package/package.json +60 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { DEFAULT_REPOSITORY_SCOPE, } from '../contracts/repository.js';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
export class RepositoryGitError extends Error {
|
|
6
|
+
code = 'GIT_FILE_LIST_FAILED';
|
|
7
|
+
constructor() {
|
|
8
|
+
super('Git could not provide a safe repository file list.');
|
|
9
|
+
this.name = 'RepositoryGitError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function runGitNullList(root, args) {
|
|
13
|
+
let result;
|
|
14
|
+
try {
|
|
15
|
+
result = await execFileAsync('git', ['-C', root.absolutePath, ...args], {
|
|
16
|
+
encoding: 'buffer',
|
|
17
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new RepositoryGitError();
|
|
22
|
+
}
|
|
23
|
+
return result.stdout
|
|
24
|
+
.toString('utf8')
|
|
25
|
+
.split('\0')
|
|
26
|
+
.filter((entry) => entry.length > 0);
|
|
27
|
+
}
|
|
28
|
+
function addStates(files, paths, state) {
|
|
29
|
+
for (const relativePath of paths) {
|
|
30
|
+
const states = files.get(relativePath) ?? new Set();
|
|
31
|
+
states.add(state);
|
|
32
|
+
files.set(relativePath, states);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const STATE_ORDER = [
|
|
36
|
+
'tracked',
|
|
37
|
+
'staged',
|
|
38
|
+
'unstaged',
|
|
39
|
+
'untracked',
|
|
40
|
+
];
|
|
41
|
+
export async function listRepositoryCandidates(root, scope = DEFAULT_REPOSITORY_SCOPE) {
|
|
42
|
+
const [tracked, staged, unstaged, untracked] = await Promise.all([
|
|
43
|
+
runGitNullList(root, ['ls-files', '-z', '--cached']),
|
|
44
|
+
runGitNullList(root, [
|
|
45
|
+
'diff',
|
|
46
|
+
'--cached',
|
|
47
|
+
'--name-only',
|
|
48
|
+
'--diff-filter=ACMRTUXB',
|
|
49
|
+
'-z',
|
|
50
|
+
]),
|
|
51
|
+
runGitNullList(root, [
|
|
52
|
+
'diff',
|
|
53
|
+
'--name-only',
|
|
54
|
+
'--diff-filter=ACMRTUXB',
|
|
55
|
+
'-z',
|
|
56
|
+
]),
|
|
57
|
+
runGitNullList(root, [
|
|
58
|
+
'ls-files',
|
|
59
|
+
'-z',
|
|
60
|
+
'--others',
|
|
61
|
+
'--exclude-standard',
|
|
62
|
+
]),
|
|
63
|
+
]);
|
|
64
|
+
const files = new Map();
|
|
65
|
+
if (scope === 'full') {
|
|
66
|
+
addStates(files, tracked, 'tracked');
|
|
67
|
+
}
|
|
68
|
+
addStates(files, staged, 'staged');
|
|
69
|
+
addStates(files, unstaged, 'unstaged');
|
|
70
|
+
addStates(files, untracked, 'untracked');
|
|
71
|
+
return [...files.entries()]
|
|
72
|
+
.map(([relativePath, states]) => ({
|
|
73
|
+
relativePath,
|
|
74
|
+
states: STATE_ORDER.filter((state) => states.has(state)),
|
|
75
|
+
}))
|
|
76
|
+
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
77
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export class RepositoryIdentityError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code) {
|
|
7
|
+
super(code === 'REPOSITORY_NOT_AUTHORIZED'
|
|
8
|
+
? 'The repository does not match the Project Token.'
|
|
9
|
+
: 'A safe repository identity could not be established.');
|
|
10
|
+
this.name = 'RepositoryIdentityError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function normalizedHostPath(host, repositoryPath) {
|
|
15
|
+
const cleanPath = repositoryPath
|
|
16
|
+
.replace(/^\/+/, '')
|
|
17
|
+
.replace(/\/+$/, '')
|
|
18
|
+
.replace(/\.git$/i, '');
|
|
19
|
+
if (!host || !cleanPath || cleanPath.includes('..')) {
|
|
20
|
+
throw new RepositoryIdentityError('REMOTE_INVALID');
|
|
21
|
+
}
|
|
22
|
+
return `${host.toLowerCase()}/${cleanPath.toLowerCase()}`;
|
|
23
|
+
}
|
|
24
|
+
export function normalizeGitRemote(remote) {
|
|
25
|
+
const candidate = remote.trim();
|
|
26
|
+
const scpStyle = candidate.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/);
|
|
27
|
+
if (scpStyle && !candidate.includes('://')) {
|
|
28
|
+
return normalizedHostPath(scpStyle[1], scpStyle[2]);
|
|
29
|
+
}
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(candidate);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new RepositoryIdentityError('REMOTE_INVALID');
|
|
36
|
+
}
|
|
37
|
+
if (!['git:', 'http:', 'https:', 'ssh:'].includes(parsed.protocol)) {
|
|
38
|
+
throw new RepositoryIdentityError('REMOTE_INVALID');
|
|
39
|
+
}
|
|
40
|
+
return normalizedHostPath(parsed.host, parsed.pathname);
|
|
41
|
+
}
|
|
42
|
+
async function readPrimaryRemote(root) {
|
|
43
|
+
try {
|
|
44
|
+
const result = await execFileAsync('git', ['-C', root.absolutePath, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8' });
|
|
45
|
+
const remote = result.stdout.trim();
|
|
46
|
+
return remote.length > 0 ? remote : null;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (typeof error === 'object' &&
|
|
50
|
+
error !== null &&
|
|
51
|
+
'code' in error &&
|
|
52
|
+
error.code === 1) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
throw new RepositoryIdentityError('REMOTE_INVALID');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function resolveRepositoryIdentity(root, localRepositoryId) {
|
|
59
|
+
const remote = await readPrimaryRemote(root);
|
|
60
|
+
if (remote) {
|
|
61
|
+
return { kind: 'remote', value: normalizeGitRemote(remote) };
|
|
62
|
+
}
|
|
63
|
+
if (!localRepositoryId || !/^vqc_local_[A-Za-z0-9_-]{16,}$/.test(localRepositoryId)) {
|
|
64
|
+
throw new RepositoryIdentityError('LOCAL_ID_REQUIRED');
|
|
65
|
+
}
|
|
66
|
+
return { kind: 'local', value: localRepositoryId };
|
|
67
|
+
}
|
|
68
|
+
export function assertRepositoryAuthorized(actual, allowed) {
|
|
69
|
+
if (actual.kind !== allowed.kind || actual.value !== allowed.value) {
|
|
70
|
+
throw new RepositoryIdentityError('REPOSITORY_NOT_AUTHORIZED');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { DEFAULT_REPOSITORY_SCOPE, } from '../contracts/repository.js';
|
|
2
|
+
import { classifyPathByCentralPolicy, fileLooksBinary, } from '../security/content-policy.js';
|
|
3
|
+
import { RepositoryBoundaryError, validateRepositoryFile, } from '../security/path-policy.js';
|
|
4
|
+
import { assertExternalPayloadSafe } from '../security/redaction.js';
|
|
5
|
+
import { listRepositoryCandidates } from './git-files.js';
|
|
6
|
+
import { isViberQcIgnored, loadViberQcIgnoreRules, } from './viberqc-ignore.js';
|
|
7
|
+
function boundaryErrorToSkipReason(error) {
|
|
8
|
+
if (error.code === 'PATH_SYMLINK') {
|
|
9
|
+
return 'symlink';
|
|
10
|
+
}
|
|
11
|
+
if (error.code === 'PATH_MISSING') {
|
|
12
|
+
return 'missing';
|
|
13
|
+
}
|
|
14
|
+
if (error.code === 'PATH_NOT_FILE') {
|
|
15
|
+
return 'not-file';
|
|
16
|
+
}
|
|
17
|
+
return 'central-policy';
|
|
18
|
+
}
|
|
19
|
+
function countSkipReason(counts, reason) {
|
|
20
|
+
counts[reason] = (counts[reason] ?? 0) + 1;
|
|
21
|
+
}
|
|
22
|
+
export async function buildRepositoryInventory(root, options = {}) {
|
|
23
|
+
const scope = options.scope ?? DEFAULT_REPOSITORY_SCOPE;
|
|
24
|
+
const [candidates, ignoreRules] = await Promise.all([
|
|
25
|
+
listRepositoryCandidates(root, scope),
|
|
26
|
+
loadViberQcIgnoreRules(root),
|
|
27
|
+
]);
|
|
28
|
+
const files = [];
|
|
29
|
+
const skipped = [];
|
|
30
|
+
const skipReasons = {};
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
let reason = classifyPathByCentralPolicy(candidate.relativePath);
|
|
33
|
+
if (!reason && options.organizationPolicy) {
|
|
34
|
+
try {
|
|
35
|
+
if (options.organizationPolicy(candidate.relativePath)) {
|
|
36
|
+
reason = 'organization-policy';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
reason = 'organization-policy';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!reason && isViberQcIgnored(candidate.relativePath, ignoreRules)) {
|
|
44
|
+
reason = 'viberqcignore';
|
|
45
|
+
}
|
|
46
|
+
if (reason) {
|
|
47
|
+
skipped.push({ relativePath: candidate.relativePath, reason });
|
|
48
|
+
countSkipReason(skipReasons, reason);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const file = await validateRepositoryFile(root, candidate.relativePath);
|
|
53
|
+
if (await fileLooksBinary(file.absolutePath)) {
|
|
54
|
+
reason = 'binary';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
reason =
|
|
59
|
+
error instanceof RepositoryBoundaryError
|
|
60
|
+
? boundaryErrorToSkipReason(error)
|
|
61
|
+
: 'central-policy';
|
|
62
|
+
}
|
|
63
|
+
if (reason) {
|
|
64
|
+
skipped.push({ relativePath: candidate.relativePath, reason });
|
|
65
|
+
countSkipReason(skipReasons, reason);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
files.push(candidate);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
scope,
|
|
72
|
+
files,
|
|
73
|
+
skipped,
|
|
74
|
+
totals: {
|
|
75
|
+
candidates: candidates.length,
|
|
76
|
+
included: files.length,
|
|
77
|
+
skipped: skipped.length,
|
|
78
|
+
},
|
|
79
|
+
skipReasons,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function buildExternalRepositorySummary(inventory) {
|
|
83
|
+
const summary = {
|
|
84
|
+
scope: inventory.scope,
|
|
85
|
+
fileCount: inventory.totals.included,
|
|
86
|
+
skippedCount: inventory.totals.skipped,
|
|
87
|
+
skipReasons: { ...inventory.skipReasons },
|
|
88
|
+
};
|
|
89
|
+
assertExternalPayloadSafe(summary);
|
|
90
|
+
return summary;
|
|
91
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_IGNORE_BYTES = 64 * 1024;
|
|
4
|
+
const MAX_IGNORE_RULES = 1_024;
|
|
5
|
+
const MAX_PATTERN_LENGTH = 512;
|
|
6
|
+
export class ViberQcIgnoreError extends Error {
|
|
7
|
+
code = 'VIBERQC_IGNORE_UNAVAILABLE';
|
|
8
|
+
constructor() {
|
|
9
|
+
super('The repository ignore policy could not be applied safely.');
|
|
10
|
+
this.name = 'ViberQcIgnoreError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function escapeRegexCharacter(character) {
|
|
14
|
+
return /[\\^$.*+?()[\]{}|]/.test(character)
|
|
15
|
+
? `\\${character}`
|
|
16
|
+
: character;
|
|
17
|
+
}
|
|
18
|
+
function globToRegex(pattern) {
|
|
19
|
+
let source = '';
|
|
20
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
21
|
+
const character = pattern[index];
|
|
22
|
+
const next = pattern[index + 1];
|
|
23
|
+
if (character === '*' && next === '*') {
|
|
24
|
+
source += '.*';
|
|
25
|
+
index += 1;
|
|
26
|
+
}
|
|
27
|
+
else if (character === '*') {
|
|
28
|
+
source += '[^/]*';
|
|
29
|
+
}
|
|
30
|
+
else if (character === '?') {
|
|
31
|
+
source += '[^/]';
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
source += escapeRegexCharacter(character);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return new RegExp(`^${source}$`);
|
|
38
|
+
}
|
|
39
|
+
export async function loadViberQcIgnoreRules(root) {
|
|
40
|
+
const ignorePath = path.join(root.absolutePath, '.viberqcignore');
|
|
41
|
+
try {
|
|
42
|
+
const info = await lstat(ignorePath);
|
|
43
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_IGNORE_BYTES) {
|
|
44
|
+
throw new ViberQcIgnoreError();
|
|
45
|
+
}
|
|
46
|
+
const contents = await readFile(ignorePath, 'utf8');
|
|
47
|
+
return contents
|
|
48
|
+
.split(/\r?\n/)
|
|
49
|
+
.map((line) => line.trim())
|
|
50
|
+
.filter((line) => line.length > 0 &&
|
|
51
|
+
line.length <= MAX_PATTERN_LENGTH &&
|
|
52
|
+
!line.startsWith('#') &&
|
|
53
|
+
!line.startsWith('!'))
|
|
54
|
+
.slice(0, MAX_IGNORE_RULES)
|
|
55
|
+
.map((line) => {
|
|
56
|
+
const directory = line.endsWith('/');
|
|
57
|
+
const withoutDirectoryMarker = directory ? line.slice(0, -1) : line;
|
|
58
|
+
const normalized = withoutDirectoryMarker.replace(/^\/+/, '');
|
|
59
|
+
return {
|
|
60
|
+
directory,
|
|
61
|
+
hasSlash: normalized.includes('/'),
|
|
62
|
+
regex: globToRegex(normalized),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (typeof error === 'object' &&
|
|
68
|
+
error !== null &&
|
|
69
|
+
'code' in error &&
|
|
70
|
+
error.code === 'ENOENT') {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
if (error instanceof ViberQcIgnoreError) {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
throw new ViberQcIgnoreError();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function isViberQcIgnored(relativePath, rules) {
|
|
80
|
+
const portable = relativePath.replaceAll('\\', '/');
|
|
81
|
+
const segments = portable.split('/');
|
|
82
|
+
return rules.some((rule) => {
|
|
83
|
+
if (rule.hasSlash) {
|
|
84
|
+
if (rule.regex.test(portable)) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (rule.directory) {
|
|
88
|
+
return segments.some((_, index) => rule.regex.test(segments.slice(0, index + 1).join('/')));
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return segments.some((segment) => rule.regex.test(segment));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const BLOCKED_DIRECTORIES = new Map([
|
|
5
|
+
['.aws', 'secret'],
|
|
6
|
+
['.azure', 'secret'],
|
|
7
|
+
['.gcloud', 'secret'],
|
|
8
|
+
['.git', 'git-metadata'],
|
|
9
|
+
['.kube', 'secret'],
|
|
10
|
+
['.next', 'build-output'],
|
|
11
|
+
['.nuxt', 'build-output'],
|
|
12
|
+
['.output', 'build-output'],
|
|
13
|
+
['.parcel-cache', 'build-output'],
|
|
14
|
+
['.ssh', 'secret'],
|
|
15
|
+
['.turbo', 'build-output'],
|
|
16
|
+
['build', 'build-output'],
|
|
17
|
+
['coverage', 'build-output'],
|
|
18
|
+
['dist', 'build-output'],
|
|
19
|
+
['node_modules', 'dependency'],
|
|
20
|
+
['out', 'build-output'],
|
|
21
|
+
['target', 'build-output'],
|
|
22
|
+
]);
|
|
23
|
+
const BINARY_EXTENSIONS = new Set([
|
|
24
|
+
'.7z',
|
|
25
|
+
'.a',
|
|
26
|
+
'.avi',
|
|
27
|
+
'.bin',
|
|
28
|
+
'.bmp',
|
|
29
|
+
'.class',
|
|
30
|
+
'.dmg',
|
|
31
|
+
'.doc',
|
|
32
|
+
'.docx',
|
|
33
|
+
'.eot',
|
|
34
|
+
'.exe',
|
|
35
|
+
'.gif',
|
|
36
|
+
'.gz',
|
|
37
|
+
'.ico',
|
|
38
|
+
'.jar',
|
|
39
|
+
'.jpeg',
|
|
40
|
+
'.jpg',
|
|
41
|
+
'.mov',
|
|
42
|
+
'.mp3',
|
|
43
|
+
'.mp4',
|
|
44
|
+
'.o',
|
|
45
|
+
'.otf',
|
|
46
|
+
'.pdf',
|
|
47
|
+
'.png',
|
|
48
|
+
'.ppt',
|
|
49
|
+
'.pptx',
|
|
50
|
+
'.pyc',
|
|
51
|
+
'.so',
|
|
52
|
+
'.tar',
|
|
53
|
+
'.tiff',
|
|
54
|
+
'.ttf',
|
|
55
|
+
'.wasm',
|
|
56
|
+
'.webm',
|
|
57
|
+
'.webp',
|
|
58
|
+
'.woff',
|
|
59
|
+
'.woff2',
|
|
60
|
+
'.xls',
|
|
61
|
+
'.xlsx',
|
|
62
|
+
'.zip',
|
|
63
|
+
]);
|
|
64
|
+
const SECRET_EXTENSIONS = new Set([
|
|
65
|
+
'.der',
|
|
66
|
+
'.jks',
|
|
67
|
+
'.key',
|
|
68
|
+
'.p12',
|
|
69
|
+
'.pfx',
|
|
70
|
+
'.pkcs12',
|
|
71
|
+
'.ppk',
|
|
72
|
+
]);
|
|
73
|
+
const SECRET_FILE_NAMES = new Set([
|
|
74
|
+
'.dockerconfigjson',
|
|
75
|
+
'.git-credentials',
|
|
76
|
+
'.netrc',
|
|
77
|
+
'.npmrc',
|
|
78
|
+
'.pypirc',
|
|
79
|
+
'credentials',
|
|
80
|
+
'credentials.json',
|
|
81
|
+
'id_dsa',
|
|
82
|
+
'id_ed25519',
|
|
83
|
+
'id_ecdsa',
|
|
84
|
+
'id_rsa',
|
|
85
|
+
'kubeconfig',
|
|
86
|
+
'service-account.json',
|
|
87
|
+
]);
|
|
88
|
+
export function classifyPathByCentralPolicy(relativePath) {
|
|
89
|
+
const portable = relativePath.replaceAll('\\', '/');
|
|
90
|
+
const segments = portable.split('/');
|
|
91
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
92
|
+
for (const segment of lowerSegments.slice(0, -1)) {
|
|
93
|
+
const blocked = BLOCKED_DIRECTORIES.get(segment);
|
|
94
|
+
if (blocked) {
|
|
95
|
+
return blocked;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const fileName = lowerSegments.at(-1) ?? '';
|
|
99
|
+
if (fileName === '.viberqcignore') {
|
|
100
|
+
return 'central-policy';
|
|
101
|
+
}
|
|
102
|
+
if (fileName === '.env' || fileName.startsWith('.env.')) {
|
|
103
|
+
return 'secret';
|
|
104
|
+
}
|
|
105
|
+
const extension = path.posix.extname(fileName);
|
|
106
|
+
if (SECRET_EXTENSIONS.has(extension) ||
|
|
107
|
+
SECRET_FILE_NAMES.has(fileName) ||
|
|
108
|
+
fileName.endsWith('.credentials.json') ||
|
|
109
|
+
/^service-account(?:[._-].+)?\.json$/.test(fileName)) {
|
|
110
|
+
return 'secret';
|
|
111
|
+
}
|
|
112
|
+
if (extension === '.pem' || fileName.endsWith('.private-key')) {
|
|
113
|
+
return 'secret';
|
|
114
|
+
}
|
|
115
|
+
if (BINARY_EXTENSIONS.has(extension)) {
|
|
116
|
+
return 'binary';
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
export function bufferLooksBinary(buffer) {
|
|
121
|
+
if (buffer.length === 0) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
let suspiciousBytes = 0;
|
|
125
|
+
for (const byte of buffer) {
|
|
126
|
+
if (byte === 0) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (byte < 7 ||
|
|
130
|
+
(byte > 13 && byte < 32) ||
|
|
131
|
+
byte === 127) {
|
|
132
|
+
suspiciousBytes += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return suspiciousBytes / buffer.length > 0.3;
|
|
136
|
+
}
|
|
137
|
+
export async function fileLooksBinary(absolutePath, maxBytes = 8_192) {
|
|
138
|
+
const handle = await open(absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
139
|
+
try {
|
|
140
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
141
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
142
|
+
return bufferLooksBinary(buffer.subarray(0, bytesRead));
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await handle.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { lstat, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const SAFE_MESSAGES = {
|
|
8
|
+
PATH_ABSOLUTE: 'Absolute candidate paths are not allowed.',
|
|
9
|
+
PATH_MISSING: 'The candidate file is unavailable.',
|
|
10
|
+
PATH_NOT_FILE: 'The candidate is not a regular file.',
|
|
11
|
+
PATH_OUTSIDE_ROOT: 'The candidate is outside the authorized repository.',
|
|
12
|
+
PATH_SYMLINK: 'Symbolic links are not followed.',
|
|
13
|
+
PATH_TRAVERSAL: 'Parent path traversal is not allowed.',
|
|
14
|
+
REPO_ROOT_AMBIGUOUS: 'More than one trusted repository root was supplied.',
|
|
15
|
+
REPO_ROOT_FORBIDDEN: 'This directory cannot be used as a repository root.',
|
|
16
|
+
REPO_ROOT_MISSING: 'No trusted repository root was supplied.',
|
|
17
|
+
REPO_ROOT_NOT_DIRECTORY: 'The repository root is not a directory.',
|
|
18
|
+
REPO_ROOT_NOT_GIT: 'The repository root is not a Git work tree.',
|
|
19
|
+
REPO_ROOT_NOT_TOPLEVEL: 'The repository root must be the Git top-level directory.',
|
|
20
|
+
};
|
|
21
|
+
export class RepositoryBoundaryError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
constructor(code) {
|
|
24
|
+
super(SAFE_MESSAGES[code]);
|
|
25
|
+
this.name = 'RepositoryBoundaryError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function chooseTrustedRoot(sources) {
|
|
30
|
+
const mcpRoots = (sources.mcpRoots ?? []).filter((candidate) => candidate.trim().length > 0);
|
|
31
|
+
if (mcpRoots.length > 1) {
|
|
32
|
+
throw new RepositoryBoundaryError('REPO_ROOT_AMBIGUOUS');
|
|
33
|
+
}
|
|
34
|
+
const selected = mcpRoots[0] ?? sources.configuredRoot?.trim();
|
|
35
|
+
if (!selected) {
|
|
36
|
+
throw new RepositoryBoundaryError('REPO_ROOT_MISSING');
|
|
37
|
+
}
|
|
38
|
+
return selected;
|
|
39
|
+
}
|
|
40
|
+
function isInsideRoot(root, candidate) {
|
|
41
|
+
const relative = path.relative(root, candidate);
|
|
42
|
+
return (relative.length > 0 &&
|
|
43
|
+
relative !== '..' &&
|
|
44
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
45
|
+
!path.isAbsolute(relative));
|
|
46
|
+
}
|
|
47
|
+
function normalizeCandidatePath(candidate) {
|
|
48
|
+
const portable = candidate.replaceAll('\\', '/');
|
|
49
|
+
if (path.posix.isAbsolute(portable) ||
|
|
50
|
+
path.win32.isAbsolute(candidate) ||
|
|
51
|
+
/^[A-Za-z]:/.test(candidate) ||
|
|
52
|
+
portable.split('/').includes('..')) {
|
|
53
|
+
throw new RepositoryBoundaryError(path.posix.isAbsolute(portable) ||
|
|
54
|
+
path.win32.isAbsolute(candidate) ||
|
|
55
|
+
/^[A-Za-z]:/.test(candidate)
|
|
56
|
+
? 'PATH_ABSOLUTE'
|
|
57
|
+
: 'PATH_TRAVERSAL');
|
|
58
|
+
}
|
|
59
|
+
const normalized = path.posix.normalize(portable);
|
|
60
|
+
if (normalized.length === 0 ||
|
|
61
|
+
normalized === '.' ||
|
|
62
|
+
normalized === '..' ||
|
|
63
|
+
normalized.startsWith('../')) {
|
|
64
|
+
throw new RepositoryBoundaryError('PATH_TRAVERSAL');
|
|
65
|
+
}
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
async function assertNoSymlinkSegments(root, relativePath) {
|
|
69
|
+
let current = root;
|
|
70
|
+
for (const segment of relativePath.split('/')) {
|
|
71
|
+
current = path.join(current, segment);
|
|
72
|
+
try {
|
|
73
|
+
const info = await lstat(current);
|
|
74
|
+
if (info.isSymbolicLink()) {
|
|
75
|
+
throw new RepositoryBoundaryError('PATH_SYMLINK');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof RepositoryBoundaryError) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
throw new RepositoryBoundaryError('PATH_MISSING');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function validateRepositoryRoot(sources) {
|
|
87
|
+
const selected = chooseTrustedRoot(sources);
|
|
88
|
+
let canonicalRoot;
|
|
89
|
+
try {
|
|
90
|
+
canonicalRoot = await realpath(selected);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new RepositoryBoundaryError('REPO_ROOT_MISSING');
|
|
94
|
+
}
|
|
95
|
+
let rootInfo;
|
|
96
|
+
try {
|
|
97
|
+
rootInfo = await stat(canonicalRoot);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new RepositoryBoundaryError('REPO_ROOT_MISSING');
|
|
101
|
+
}
|
|
102
|
+
if (!rootInfo.isDirectory()) {
|
|
103
|
+
throw new RepositoryBoundaryError('REPO_ROOT_NOT_DIRECTORY');
|
|
104
|
+
}
|
|
105
|
+
let canonicalHome = homedir();
|
|
106
|
+
try {
|
|
107
|
+
canonicalHome = await realpath(canonicalHome);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Comparing against the unresolved home path is safer than allowing it.
|
|
111
|
+
}
|
|
112
|
+
if (canonicalRoot === path.parse(canonicalRoot).root ||
|
|
113
|
+
canonicalRoot === canonicalHome) {
|
|
114
|
+
throw new RepositoryBoundaryError('REPO_ROOT_FORBIDDEN');
|
|
115
|
+
}
|
|
116
|
+
let gitTopLevel;
|
|
117
|
+
try {
|
|
118
|
+
const result = await execFileAsync('git', ['-C', canonicalRoot, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
119
|
+
gitTopLevel = result.stdout.trim();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new RepositoryBoundaryError('REPO_ROOT_NOT_GIT');
|
|
123
|
+
}
|
|
124
|
+
let canonicalGitTopLevel;
|
|
125
|
+
try {
|
|
126
|
+
canonicalGitTopLevel = await realpath(gitTopLevel);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new RepositoryBoundaryError('REPO_ROOT_NOT_GIT');
|
|
130
|
+
}
|
|
131
|
+
if (canonicalGitTopLevel !== canonicalRoot) {
|
|
132
|
+
throw new RepositoryBoundaryError('REPO_ROOT_NOT_TOPLEVEL');
|
|
133
|
+
}
|
|
134
|
+
return Object.freeze({ absolutePath: canonicalRoot });
|
|
135
|
+
}
|
|
136
|
+
export async function validateRepositoryFile(root, candidate) {
|
|
137
|
+
const relativePath = normalizeCandidatePath(candidate);
|
|
138
|
+
await assertNoSymlinkSegments(root.absolutePath, relativePath);
|
|
139
|
+
const lexicalPath = path.resolve(root.absolutePath, ...relativePath.split('/'));
|
|
140
|
+
if (!isInsideRoot(root.absolutePath, lexicalPath)) {
|
|
141
|
+
throw new RepositoryBoundaryError('PATH_OUTSIDE_ROOT');
|
|
142
|
+
}
|
|
143
|
+
let canonicalPath;
|
|
144
|
+
try {
|
|
145
|
+
canonicalPath = await realpath(lexicalPath);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new RepositoryBoundaryError('PATH_MISSING');
|
|
149
|
+
}
|
|
150
|
+
if (!isInsideRoot(root.absolutePath, canonicalPath)) {
|
|
151
|
+
throw new RepositoryBoundaryError('PATH_OUTSIDE_ROOT');
|
|
152
|
+
}
|
|
153
|
+
let fileInfo;
|
|
154
|
+
try {
|
|
155
|
+
fileInfo = await stat(canonicalPath);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new RepositoryBoundaryError('PATH_MISSING');
|
|
159
|
+
}
|
|
160
|
+
if (!fileInfo.isFile()) {
|
|
161
|
+
throw new RepositoryBoundaryError('PATH_NOT_FILE');
|
|
162
|
+
}
|
|
163
|
+
return Object.freeze({
|
|
164
|
+
absolutePath: canonicalPath,
|
|
165
|
+
relativePath,
|
|
166
|
+
});
|
|
167
|
+
}
|