ssh-mcp 1.5.0 → 2.0.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/README.md +488 -143
- package/build/audit/store.js +178 -0
- package/build/config/credential-resolver.js +107 -0
- package/build/config/loader.js +100 -0
- package/build/config/schema.js +53 -0
- package/build/guard/approval-grants.js +0 -0
- package/build/guard/elicitation.js +38 -0
- package/build/guard/redactor.js +67 -0
- package/build/guard/sanitizer.js +24 -0
- package/build/index.js +183 -628
- package/build/observability/tracer.js +27 -0
- package/build/policy/classifier.js +99 -0
- package/build/policy/engine.js +205 -0
- package/build/policy/quota.js +59 -0
- package/build/ssh/algorithms.js +33 -0
- package/build/ssh/channel-retry.js +15 -0
- package/build/ssh/connection-registry.js +102 -0
- package/build/ssh/connection.js +375 -0
- package/build/ssh/host-key.js +23 -0
- package/build/ssh/session-manager.js +164 -0
- package/build/ssh/session.js +313 -0
- package/build/ssh/sftp.js +130 -0
- package/build/tools/command-tools.js +82 -0
- package/build/tools/descriptions.js +21 -0
- package/build/tools/file-tools.js +36 -0
- package/build/tools/pipeline.js +185 -0
- package/build/tools/registry.js +23 -0
- package/build/tools/resources.js +73 -0
- package/build/tools/results.js +31 -0
- package/build/tools/session-tools.js +97 -0
- package/build/transport/http.js +237 -0
- package/build/types.js +2 -0
- package/build/version.js +9 -0
- package/package.json +28 -7
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { mkdir, stat, rename, chmod, open } from 'fs/promises';
|
|
2
|
+
import { createWriteStream } from 'fs';
|
|
3
|
+
import { homedir, platform } from 'os';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
import { randomUUID, createHash } from 'crypto';
|
|
6
|
+
import { redactText } from '../guard/redactor.js';
|
|
7
|
+
import { tracer } from '../observability/tracer.js';
|
|
8
|
+
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
|
9
|
+
const MAX_FILES = 10;
|
|
10
|
+
export function getAuditLogPath() {
|
|
11
|
+
const home = homedir();
|
|
12
|
+
const p = platform();
|
|
13
|
+
if (p === 'darwin') {
|
|
14
|
+
return join(home, 'Library', 'Logs', 'ssh-mcp', 'audit.log');
|
|
15
|
+
}
|
|
16
|
+
if (p === 'win32') {
|
|
17
|
+
return join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'ssh-mcp', 'audit.log');
|
|
18
|
+
}
|
|
19
|
+
const xdgData = process.env.XDG_DATA_HOME || join(home, '.local', 'share');
|
|
20
|
+
return join(xdgData, 'ssh-mcp', 'audit.log');
|
|
21
|
+
}
|
|
22
|
+
export class AuditStore {
|
|
23
|
+
logPath;
|
|
24
|
+
entropyScan;
|
|
25
|
+
tamperEvident;
|
|
26
|
+
lastHash = '';
|
|
27
|
+
writeStream = null;
|
|
28
|
+
dirEnsured = false;
|
|
29
|
+
recordCount = 0;
|
|
30
|
+
/** Bytes in the open log file; -1 means "read the baseline from disk". */
|
|
31
|
+
bytesWritten = -1;
|
|
32
|
+
writeQueue = Promise.resolve();
|
|
33
|
+
/** Rotation threshold; injectable so tests exercise it without a 100MB file. */
|
|
34
|
+
maxFileSize;
|
|
35
|
+
constructor(logPath, entropyScan = false, tamperEvident = false, maxFileSize = MAX_FILE_SIZE) {
|
|
36
|
+
this.logPath = logPath || getAuditLogPath();
|
|
37
|
+
this.entropyScan = entropyScan;
|
|
38
|
+
this.tamperEvident = tamperEvident;
|
|
39
|
+
this.maxFileSize = maxFileSize;
|
|
40
|
+
}
|
|
41
|
+
async record(entry) {
|
|
42
|
+
this.writeQueue = this.writeQueue
|
|
43
|
+
.catch(() => { })
|
|
44
|
+
.then(() => this._record(entry));
|
|
45
|
+
return this.writeQueue;
|
|
46
|
+
}
|
|
47
|
+
async close() {
|
|
48
|
+
try {
|
|
49
|
+
await this.writeQueue;
|
|
50
|
+
}
|
|
51
|
+
catch { /* queue may be rejected */ }
|
|
52
|
+
if (this.writeStream && !this.writeStream.destroyed) {
|
|
53
|
+
await new Promise((resolve) => this.writeStream.end(() => resolve()));
|
|
54
|
+
this.writeStream = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async _record(entry) {
|
|
58
|
+
const span = tracer.startSpan('audit.record');
|
|
59
|
+
span.setAttribute('audit.tamperEvident', this.tamperEvident);
|
|
60
|
+
try {
|
|
61
|
+
const record = {
|
|
62
|
+
...entry,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
eventId: randomUUID(),
|
|
65
|
+
};
|
|
66
|
+
const redactedCommand = redactText(record.command, { entropyScan: this.entropyScan });
|
|
67
|
+
const redactedError = record.error ? redactText(record.error, { entropyScan: this.entropyScan }) : undefined;
|
|
68
|
+
let lineObj = { ...record, command: redactedCommand, error: redactedError };
|
|
69
|
+
if (this.tamperEvident) {
|
|
70
|
+
if (!this.lastHash) {
|
|
71
|
+
await this.loadLastHash();
|
|
72
|
+
}
|
|
73
|
+
const prevHash = this.lastHash;
|
|
74
|
+
const contentForHash = JSON.stringify(lineObj) + prevHash;
|
|
75
|
+
const selfHash = createHash('sha256').update(contentForHash).digest('hex');
|
|
76
|
+
lineObj = { ...lineObj, prevHash, selfHash };
|
|
77
|
+
this.lastHash = selfHash;
|
|
78
|
+
}
|
|
79
|
+
const line = JSON.stringify(lineObj) + '\n';
|
|
80
|
+
await this.rotateIfNeeded(Buffer.byteLength(line));
|
|
81
|
+
const stream = await this.ensureStream();
|
|
82
|
+
await new Promise((resolve, reject) => {
|
|
83
|
+
stream.write(line, (err) => err ? reject(err) : resolve());
|
|
84
|
+
});
|
|
85
|
+
this.bytesWritten += Buffer.byteLength(line);
|
|
86
|
+
span.setAttribute('audit.bytes', line.length);
|
|
87
|
+
this.recordCount++;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
span.end();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async ensureStream() {
|
|
94
|
+
if (this.writeStream && !this.writeStream.destroyed)
|
|
95
|
+
return this.writeStream;
|
|
96
|
+
if (!this.dirEnsured) {
|
|
97
|
+
await mkdir(dirname(this.logPath), { recursive: true });
|
|
98
|
+
this.dirEnsured = true;
|
|
99
|
+
}
|
|
100
|
+
this.writeStream = createWriteStream(this.logPath, { flags: 'a' });
|
|
101
|
+
// Force a fresh baseline read for the newly opened file.
|
|
102
|
+
this.bytesWritten = -1;
|
|
103
|
+
try {
|
|
104
|
+
await chmod(this.logPath, 0o600);
|
|
105
|
+
}
|
|
106
|
+
catch { /* may not exist yet */ }
|
|
107
|
+
return this.writeStream;
|
|
108
|
+
}
|
|
109
|
+
async loadLastHash() {
|
|
110
|
+
let fd;
|
|
111
|
+
try {
|
|
112
|
+
// Open first, then stat the handle. Stat-then-open is a TOCTOU race
|
|
113
|
+
// (js/file-system-race): the size is used as a read offset, so a file
|
|
114
|
+
// that shrank in between would be read from the wrong position. Statting
|
|
115
|
+
// the open handle describes exactly the file being read.
|
|
116
|
+
fd = await open(this.logPath, 'r');
|
|
117
|
+
const fileStat = await fd.stat();
|
|
118
|
+
const readSize = Math.min(fileStat.size, 8192);
|
|
119
|
+
if (readSize === 0)
|
|
120
|
+
return;
|
|
121
|
+
const buf = Buffer.alloc(readSize);
|
|
122
|
+
await fd.read(buf, 0, readSize, fileStat.size - readSize);
|
|
123
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
124
|
+
if (lines.length === 0)
|
|
125
|
+
return;
|
|
126
|
+
const lastLine = JSON.parse(lines[lines.length - 1]);
|
|
127
|
+
this.lastHash = lastLine.selfHash || '';
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// File doesn't exist or can't be read — start fresh
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
// The old code closed the handle only on the success path, leaking one
|
|
134
|
+
// per failed read.
|
|
135
|
+
await fd?.close().catch(() => { });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Rotate before the incoming record would push the file over the limit.
|
|
140
|
+
*
|
|
141
|
+
* The size is tracked from the open stream instead of stat()ing the file on
|
|
142
|
+
* every record: every tool call awaits this on the shared write queue, so
|
|
143
|
+
* that was a syscall per request to answer a question we already know.
|
|
144
|
+
*/
|
|
145
|
+
async rotateIfNeeded(incomingBytes) {
|
|
146
|
+
try {
|
|
147
|
+
if (this.bytesWritten < 0) {
|
|
148
|
+
// First write on this stream — establish the baseline once.
|
|
149
|
+
this.bytesWritten = await stat(this.logPath).then((s) => s.size).catch(() => 0);
|
|
150
|
+
}
|
|
151
|
+
if (this.bytesWritten + incomingBytes < this.maxFileSize)
|
|
152
|
+
return;
|
|
153
|
+
if (this.writeStream && !this.writeStream.destroyed) {
|
|
154
|
+
await new Promise((resolve) => {
|
|
155
|
+
this.writeStream.end(() => resolve());
|
|
156
|
+
});
|
|
157
|
+
this.writeStream = null;
|
|
158
|
+
}
|
|
159
|
+
for (let i = MAX_FILES - 1; i > 0; i--) {
|
|
160
|
+
const oldPath = `${this.logPath}.${i}`;
|
|
161
|
+
const newPath = `${this.logPath}.${i + 1}`;
|
|
162
|
+
try {
|
|
163
|
+
await rename(oldPath, newPath);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
if (err.code !== 'ENOENT')
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await rename(this.logPath, `${this.logPath}.1`);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
if (err.code === 'ENOENT')
|
|
174
|
+
return;
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
let keyringGet = null;
|
|
4
|
+
let keyringSet = null;
|
|
5
|
+
export function isKeychainAvailable() {
|
|
6
|
+
return keyringGet !== null;
|
|
7
|
+
}
|
|
8
|
+
export async function initKeychain() {
|
|
9
|
+
try {
|
|
10
|
+
// @ts-ignore — optional dependency, may not be installed
|
|
11
|
+
const mod = await import('@napi-rs/keyring');
|
|
12
|
+
keyringGet = async (service, account) => {
|
|
13
|
+
const entry = new mod.Entry(service, account);
|
|
14
|
+
return (await entry.getPassword()) || null;
|
|
15
|
+
};
|
|
16
|
+
keyringSet = async (service, account, password) => {
|
|
17
|
+
const entry = new mod.Entry(service, account);
|
|
18
|
+
await entry.setPassword(password);
|
|
19
|
+
};
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function setKeychainEntry(service, account, password) {
|
|
27
|
+
if (!keyringSet)
|
|
28
|
+
throw new Error('Keychain not initialized. Install @napi-rs/keyring.');
|
|
29
|
+
await keyringSet(service, account, password);
|
|
30
|
+
}
|
|
31
|
+
export async function resolveCredentials(profile) {
|
|
32
|
+
const creds = {};
|
|
33
|
+
const envPrefix = `SSH_MCP_${profile.name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
34
|
+
if (process.env.SSH_AUTH_SOCK && profile.auth === 'agent') {
|
|
35
|
+
creds.agentSocket = process.env.SSH_AUTH_SOCK;
|
|
36
|
+
}
|
|
37
|
+
// Warn rather than silently walking down to a weaker credential source: a
|
|
38
|
+
// profile that asked for the OS keychain and got an env var instead has a
|
|
39
|
+
// different security posture than the operator configured.
|
|
40
|
+
if (profile.auth === 'keychain' && !keyringGet) {
|
|
41
|
+
console.error(`Profile "${profile.name}" requests auth = "keychain" but @napi-rs/keyring is unavailable ` +
|
|
42
|
+
'(it is an optional dependency). Falling back to the remaining credential sources.');
|
|
43
|
+
}
|
|
44
|
+
if (profile.auth === 'keychain' && profile.keychainEntry && keyringGet) {
|
|
45
|
+
try {
|
|
46
|
+
const [service, account] = profile.keychainEntry.split('/');
|
|
47
|
+
const secret = await keyringGet(service || 'ssh-mcp', account || profile.name);
|
|
48
|
+
if (secret) {
|
|
49
|
+
if (profile.keyRef) {
|
|
50
|
+
creds.privateKey = secret;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
creds.password = secret;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error(`Keychain lookup failed for ${profile.keychainEntry}:`, err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (process.env[`${envPrefix}_PASSWORD`]) {
|
|
62
|
+
creds.password = creds.password || process.env[`${envPrefix}_PASSWORD`];
|
|
63
|
+
}
|
|
64
|
+
else if (process.env.SSH_MCP_PASSWORD) {
|
|
65
|
+
creds.password = creds.password || process.env.SSH_MCP_PASSWORD;
|
|
66
|
+
}
|
|
67
|
+
if (process.env[`${envPrefix}_SUDO_PASSWORD`]) {
|
|
68
|
+
creds.sudoPassword = process.env[`${envPrefix}_SUDO_PASSWORD`];
|
|
69
|
+
}
|
|
70
|
+
else if (process.env.SSH_MCP_SUDO_PASSWORD) {
|
|
71
|
+
creds.sudoPassword = process.env.SSH_MCP_SUDO_PASSWORD;
|
|
72
|
+
}
|
|
73
|
+
if (process.env[`${envPrefix}_PASSPHRASE`]) {
|
|
74
|
+
creds.passphrase = process.env[`${envPrefix}_PASSPHRASE`];
|
|
75
|
+
}
|
|
76
|
+
else if (process.env.SSH_MCP_PASSPHRASE) {
|
|
77
|
+
creds.passphrase = process.env.SSH_MCP_PASSPHRASE;
|
|
78
|
+
}
|
|
79
|
+
const keyPath = profile.keyRef || process.env[`${envPrefix}_KEY`] || process.env.SSH_MCP_KEY;
|
|
80
|
+
if (keyPath && !creds.privateKey) {
|
|
81
|
+
try {
|
|
82
|
+
creds.privateKey = await readFile(resolve(keyPath.replace(/^~/, process.env.HOME || '~')), 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (err.code !== 'ENOENT')
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (profile.cert && creds.privateKey) {
|
|
90
|
+
const certPath = process.env[`${envPrefix}_CERT`]
|
|
91
|
+
|| (keyPath ? `${keyPath.replace(/\.pub$/, '')}-cert.pub` : undefined);
|
|
92
|
+
if (certPath) {
|
|
93
|
+
try {
|
|
94
|
+
creds.certificate = await readFile(resolve(certPath.replace(/^~/, process.env.HOME || '~')), 'utf8');
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err.code !== 'ENOENT')
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!creds.password && !creds.privateKey && !creds.agentSocket) {
|
|
103
|
+
throw new Error(`No credentials resolved for profile "${profile.name}". ` +
|
|
104
|
+
`Available methods: SSH agent (SSH_AUTH_SOCK), OS keychain (auth=keychain), env vars (SSH_MCP_PASSWORD), or key file (SSH_MCP_KEY).`);
|
|
105
|
+
}
|
|
106
|
+
return creds;
|
|
107
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readFile, stat } from 'fs/promises';
|
|
2
|
+
import { homedir, platform } from 'os';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { parse as parseTOML } from 'smol-toml';
|
|
5
|
+
import { configSchema } from './schema.js';
|
|
6
|
+
export function getConfigPath(customPath) {
|
|
7
|
+
if (customPath)
|
|
8
|
+
return customPath;
|
|
9
|
+
const home = homedir();
|
|
10
|
+
const p = platform();
|
|
11
|
+
if (p === 'darwin') {
|
|
12
|
+
return join(home, 'Library', 'Application Support', 'ssh-mcp', 'config.toml');
|
|
13
|
+
}
|
|
14
|
+
if (p === 'win32') {
|
|
15
|
+
return join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'ssh-mcp', 'config.toml');
|
|
16
|
+
}
|
|
17
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, '.config');
|
|
18
|
+
return join(xdgConfig, 'ssh-mcp', 'config.toml');
|
|
19
|
+
}
|
|
20
|
+
export async function checkPermissions(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
const fileStat = await stat(filePath);
|
|
23
|
+
const mode = fileStat.mode & 0o777;
|
|
24
|
+
if (mode & 0o077) {
|
|
25
|
+
throw new Error(`Config file ${filePath} is group/world accessible (mode ${mode.toString(8)}). Required: 0600. Run: chmod 600 ${filePath}`);
|
|
26
|
+
}
|
|
27
|
+
const dirMode = (await stat(dirname(filePath))).mode & 0o777;
|
|
28
|
+
if (dirMode & 0o077) {
|
|
29
|
+
throw new Error(`Config directory is group/world accessible (mode ${dirMode.toString(8)}). Required: 0700.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (err.code === 'ENOENT')
|
|
34
|
+
return;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function loadConfig(customPath) {
|
|
39
|
+
const configPath = getConfigPath(customPath);
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = await readFile(configPath, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err.code === 'ENOENT' && !customPath) {
|
|
46
|
+
throw new Error(`No config file found at ${configPath}. Create one or use --config <path>. See documentation for the TOML schema.`);
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
await checkPermissions(configPath);
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = parseTOML(raw);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
throw new Error(`Failed to parse TOML config at ${configPath}: ${err.message}`);
|
|
57
|
+
}
|
|
58
|
+
const result = configSchema.safeParse(parsed);
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
const issues = result.error.issues
|
|
61
|
+
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
|
62
|
+
.join('\n');
|
|
63
|
+
throw new Error(`Config validation error:\n${issues}`);
|
|
64
|
+
}
|
|
65
|
+
return normalizeConfig(result.data);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve every profile against [defaults]. Each of these keys is documented as
|
|
69
|
+
* a default that profiles inherit, so all of them must cascade — a key that is
|
|
70
|
+
* accepted by the schema but never applied silently ignores the operator's
|
|
71
|
+
* configuration (an `approvalMode` that never takes effect is a security
|
|
72
|
+
* downgrade, not just a papercut).
|
|
73
|
+
*/
|
|
74
|
+
function normalizeConfig(raw) {
|
|
75
|
+
const defaults = {
|
|
76
|
+
...raw.defaults,
|
|
77
|
+
};
|
|
78
|
+
const profiles = raw.profiles.map((p) => ({
|
|
79
|
+
...p,
|
|
80
|
+
timeout: p.timeout ?? defaults.commandTimeoutMs,
|
|
81
|
+
maxChars: p.maxChars ?? defaults.commandMaxChars,
|
|
82
|
+
maxOutputBytes: p.maxOutputBytes ?? defaults.commandMaxOutputBytes,
|
|
83
|
+
approvalPolicy: p.approvalPolicy ?? defaults.approvalMode,
|
|
84
|
+
sessionMaxPerConnection: p.sessionMaxPerConnection ?? defaults.sessionMaxPerConnection,
|
|
85
|
+
sessionIdleTimeoutMs: p.sessionIdleTimeoutMs ?? defaults.sessionIdleTimeoutMs,
|
|
86
|
+
sessionBackgroundMaxMs: p.sessionBackgroundMaxMs ?? defaults.sessionBackgroundMaxMs,
|
|
87
|
+
commandQuotaPerDay: p.commandQuotaPerDay ?? defaults.commandQuotaPerDay,
|
|
88
|
+
}));
|
|
89
|
+
return { defaults, profiles };
|
|
90
|
+
}
|
|
91
|
+
export function getProfile(config, name) {
|
|
92
|
+
const targetName = name || config.defaults.defaultProfile;
|
|
93
|
+
if (!targetName) {
|
|
94
|
+
return config.profiles[0];
|
|
95
|
+
}
|
|
96
|
+
const profile = config.profiles.find((p) => p.name === targetName);
|
|
97
|
+
if (!profile)
|
|
98
|
+
throw new Error(`Profile "${targetName}" not found`);
|
|
99
|
+
return profile;
|
|
100
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const authMethodSchema = z.enum(['agent', 'key', 'password', 'keychain']);
|
|
3
|
+
const approvalModeSchema = z.enum(['auto', 'ask-destructive', 'ask-all', 'deny']);
|
|
4
|
+
export const defaultsSchema = z.object({
|
|
5
|
+
defaultProfile: z.string().optional(),
|
|
6
|
+
sessionMaxPerConnection: z.number().int().positive().default(5),
|
|
7
|
+
sessionIdleTimeoutMs: z.number().int().positive().default(600_000),
|
|
8
|
+
sessionBackgroundMaxMs: z.number().int().positive().default(3_600_000),
|
|
9
|
+
commandTimeoutMs: z.number().int().positive().default(60_000),
|
|
10
|
+
commandMaxChars: z.number().int().positive().default(5000),
|
|
11
|
+
commandMaxOutputBytes: z.number().int().positive().default(1_048_576),
|
|
12
|
+
connectionIdleReapMs: z.number().int().positive().default(900_000),
|
|
13
|
+
// 0 = unlimited. A circuit breaker for runaway agents, not a rate limit.
|
|
14
|
+
commandQuotaPerDay: z.number().int().nonnegative().default(0),
|
|
15
|
+
// 0 = every destructive command prompts. Auto-approval weakens the gate, so
|
|
16
|
+
// it is opt-in rather than a default convenience.
|
|
17
|
+
approvalGrantTtlMs: z.number().int().nonnegative().default(0),
|
|
18
|
+
approvalMode: approvalModeSchema.default('ask-destructive'),
|
|
19
|
+
}).strict();
|
|
20
|
+
export const profileSchema = z.object({
|
|
21
|
+
name: z.string().min(1),
|
|
22
|
+
host: z.string().min(1),
|
|
23
|
+
port: z.number().int().min(1).max(65535).default(22),
|
|
24
|
+
user: z.string().min(1),
|
|
25
|
+
auth: authMethodSchema.default('agent'),
|
|
26
|
+
keyRef: z.string().optional(),
|
|
27
|
+
keychainEntry: z.string().optional(),
|
|
28
|
+
via: z.string().optional(),
|
|
29
|
+
// Drives the role-binding tier. Without it the tier is guessed from the
|
|
30
|
+
// profile name, and an unrecognised name resolves to the strictest tier.
|
|
31
|
+
group: z.string().optional(),
|
|
32
|
+
workdir: z.string().optional(),
|
|
33
|
+
trustedHostKey: z.string().optional(),
|
|
34
|
+
tty: z.boolean().default(false),
|
|
35
|
+
role: z.string().default('operator'),
|
|
36
|
+
readOnly: z.boolean().default(false),
|
|
37
|
+
cert: z.boolean().default(false),
|
|
38
|
+
// Left optional on purpose: normalizeConfig() fills these from [defaults], and
|
|
39
|
+
// a schema-level .default() would make "user omitted it" indistinguishable
|
|
40
|
+
// from "user set the default value", silently shadowing [defaults].
|
|
41
|
+
timeout: z.number().int().positive().optional(),
|
|
42
|
+
maxChars: z.number().int().positive().optional(),
|
|
43
|
+
maxOutputBytes: z.number().int().positive().optional(),
|
|
44
|
+
approvalPolicy: approvalModeSchema.optional(),
|
|
45
|
+
sessionMaxPerConnection: z.number().int().positive().optional(),
|
|
46
|
+
sessionIdleTimeoutMs: z.number().int().positive().optional(),
|
|
47
|
+
sessionBackgroundMaxMs: z.number().int().positive().optional(),
|
|
48
|
+
commandQuotaPerDay: z.number().int().nonnegative().optional(),
|
|
49
|
+
}).strict();
|
|
50
|
+
export const configSchema = z.object({
|
|
51
|
+
defaults: defaultsSchema.default({}),
|
|
52
|
+
profiles: z.array(profileSchema).min(1),
|
|
53
|
+
});
|
|
Binary file
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask the MCP client to confirm a destructive or privileged command.
|
|
3
|
+
*
|
|
4
|
+
* Uses the SDK's typed `Server.elicitInput()` rather than a hand-built
|
|
5
|
+
* `elicitation/create` envelope, so a protocol change breaks the build instead
|
|
6
|
+
* of silently disabling the gate that guards every destructive command.
|
|
7
|
+
*
|
|
8
|
+
* Fails closed: any error (client without elicitation support, transport
|
|
9
|
+
* failure, malformed reply) denies the command. The failure is logged, because
|
|
10
|
+
* a gate that always denies for an unnoticed reason is its own kind of outage.
|
|
11
|
+
*/
|
|
12
|
+
export async function requestApproval(server, command, profileName, evaluation) {
|
|
13
|
+
const message = `Confirm ${evaluation.commandClass} command on "${profileName}":\n\n${command}`;
|
|
14
|
+
try {
|
|
15
|
+
const result = await server.server.elicitInput({
|
|
16
|
+
message,
|
|
17
|
+
requestedSchema: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
confirm: {
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
title: 'Approve this command?',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
required: ['confirm'],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
if (result.action === 'accept' && result.content?.confirm === true) {
|
|
29
|
+
return { approved: true, approver: 'mcp-client' };
|
|
30
|
+
}
|
|
31
|
+
return { approved: false };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.error(`Approval request failed for ${evaluation.commandClass} command on "${profileName}" — denying. ` +
|
|
35
|
+
`Does this client support elicitation? Cause: ${err instanceof Error ? err.message : String(err)}`);
|
|
36
|
+
return { approved: false };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const SENSITIVE_FIELDS = new Set([
|
|
2
|
+
'password', 'privateKey', 'sudoPassword', 'suPassword', 'passphrase',
|
|
3
|
+
'token', 'secret', 'apiKey', 'api_key', 'authorization',
|
|
4
|
+
]);
|
|
5
|
+
const REGEX_PATTERNS = [
|
|
6
|
+
{ name: 'aws-access-key', pattern: /AKIA[0-9A-Z]{16}/g },
|
|
7
|
+
{ name: 'github-token', pattern: /gh[pousr]_[A-Za-z0-9]{36,}/g },
|
|
8
|
+
{ name: 'gitlab-token', pattern: /glpat-[A-Za-z0-9_-]{20}/g },
|
|
9
|
+
{ name: 'jwt', pattern: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
|
|
10
|
+
// Body is bounded: an unbounded lazy [\s\S]*? rescans to end-of-input for
|
|
11
|
+
// every BEGIN that has no matching END, so k unmatched markers in a 1MB
|
|
12
|
+
// output cost O(k x 1MB) of synchronous regex time. 64KB comfortably covers
|
|
13
|
+
// any real private key (an 8192-bit RSA key is well under 8KB).
|
|
14
|
+
{ name: 'pem-private-key', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----[\s\S]{0,65536}?-----END (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g },
|
|
15
|
+
{ name: 'bearer-auth', pattern: /[Bb]earer\s+[A-Za-z0-9._~+\/-]+=*/g },
|
|
16
|
+
{ name: 'generic-api-key', pattern: /(?:api[_-]?key|api[_-]?secret)["'\s]*[:=]\s*["']([A-Za-z0-9_\-]{20,})["']/gi },
|
|
17
|
+
];
|
|
18
|
+
function shannonEntropy(str) {
|
|
19
|
+
if (!str || str.length < 20)
|
|
20
|
+
return 0;
|
|
21
|
+
const freq = new Map();
|
|
22
|
+
for (const ch of str) {
|
|
23
|
+
freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
24
|
+
}
|
|
25
|
+
let entropy = 0;
|
|
26
|
+
for (const count of freq.values()) {
|
|
27
|
+
const p = count / str.length;
|
|
28
|
+
entropy -= p * Math.log2(p);
|
|
29
|
+
}
|
|
30
|
+
return entropy;
|
|
31
|
+
}
|
|
32
|
+
export function redactText(text, opts = {}) {
|
|
33
|
+
let result = text;
|
|
34
|
+
for (const { name, pattern } of REGEX_PATTERNS) {
|
|
35
|
+
result = result.replace(pattern, (match) => {
|
|
36
|
+
return `[REDACTED:${name}:${match.length}]`;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (opts.entropyScan) {
|
|
40
|
+
const threshold = opts.entropyThreshold ?? 4.5;
|
|
41
|
+
result = result.replace(/[A-Za-z0-9+/=_-]{20,}/g, (match) => {
|
|
42
|
+
if (shannonEntropy(match) > threshold) {
|
|
43
|
+
return `[REDACTED:entropy:${match.length}]`;
|
|
44
|
+
}
|
|
45
|
+
return match;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
export function redactRecord(obj, opts = {}) {
|
|
51
|
+
const result = {};
|
|
52
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
53
|
+
if (SENSITIVE_FIELDS.has(key) || /(?<![^a-z])(token|secret|key|password)$/i.test(key)) {
|
|
54
|
+
result[key] = '[REDACTED]';
|
|
55
|
+
}
|
|
56
|
+
else if (typeof value === 'string') {
|
|
57
|
+
result[key] = redactText(value, opts);
|
|
58
|
+
}
|
|
59
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
60
|
+
result[key] = redactRecord(value, opts);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
result[key] = value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
const CONTROL_CHARS = /[\r\n\u2028\u2029\x00]/g;
|
|
3
|
+
export function sanitizeCommand(command, maxChars) {
|
|
4
|
+
if (typeof command !== 'string') {
|
|
5
|
+
throw new McpError(ErrorCode.InvalidParams, 'Command must be a string');
|
|
6
|
+
}
|
|
7
|
+
const cleaned = command.replace(CONTROL_CHARS, ' ').trim();
|
|
8
|
+
if (!cleaned) {
|
|
9
|
+
throw new McpError(ErrorCode.InvalidParams, 'Command cannot be empty');
|
|
10
|
+
}
|
|
11
|
+
if (Number.isFinite(maxChars) && cleaned.length > maxChars) {
|
|
12
|
+
throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${maxChars} characters)`);
|
|
13
|
+
}
|
|
14
|
+
return cleaned;
|
|
15
|
+
}
|
|
16
|
+
export function sanitizeSessionName(name) {
|
|
17
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
|
|
18
|
+
throw new McpError(ErrorCode.InvalidParams, 'Session name must be 1-64 chars, alphanumeric/dash/underscore only');
|
|
19
|
+
}
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
export function shellSingleQuote(s) {
|
|
23
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
24
|
+
}
|