feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
/**
|
||||
* Unit tests for the SSH tool dispatcher (engine/tools/ssh.ts).
|
||||
*
|
||||
* Strategy: bring up an in-memory SQLite + real repos (connection / audit /
|
||||
* abuse / grants) so the audit + abuse + access decisions are exercised end-to-
|
||||
* end, but stub the session primitives (sshExec / sshUpload / sshDownload) so
|
||||
* no real SSH server is needed. The Phase 3 ssh-session tests already cover
|
||||
* the session module; here we only verify the 12-step orchestration.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { runMigrations } from '../../db/migrate.js';
|
||||
import { createConnectionRepo, type CreateConnectionInput, type SshConnectionRepo } from '../../ssh/connection-repo.js';
|
||||
import { createGrantsRepo, type SshGrantsRepo } from '../../ssh/grants-repo.js';
|
||||
import { createAuditRepo, type SshAuditRepo } from '../../ssh/audit-repo.js';
|
||||
import { createAbuseRepo, type SshAbuseRepo } from '../../ssh/abuse-repo.js';
|
||||
import { createAccessResolver } from '../../ssh/access.js';
|
||||
import { SshSessionError } from '../../ssh/session.js';
|
||||
import { SSH_DEFAULTS, type SshRuntimeConfig } from '../../ssh/config.js';
|
||||
import type { MaintenanceController } from '../../ssh/maintenance.js';
|
||||
import type { ExecArgs, UploadArgs, DownloadArgs, SessionHooks } from '../../ssh/session.js';
|
||||
import {
|
||||
setSshSubsystem,
|
||||
type SshSubsystem,
|
||||
TOOL_DEFS,
|
||||
} from './ssh.js';
|
||||
import { executeTool } from './index.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
|
||||
const VALID_KEY = 'a'.repeat(64);
|
||||
|
||||
function bootstrapDb(): Database.Database {
|
||||
process.env.MCP_ENCRYPTION_KEY = VALID_KEY;
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, role TEXT);`);
|
||||
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
|
||||
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
|
||||
runMigrations(db);
|
||||
db.prepare(`INSERT INTO users (id, role) VALUES (?, ?), (?, ?), (?, ?)`).run(
|
||||
'alice', 'member',
|
||||
'bob', 'member',
|
||||
'admin1', 'admin',
|
||||
);
|
||||
return db;
|
||||
}
|
||||
|
||||
function makeConfig(overrides: Partial<SshRuntimeConfig> = {}): SshRuntimeConfig {
|
||||
return { ...SSH_DEFAULTS, ...overrides };
|
||||
}
|
||||
|
||||
function makeMaintenance(active = false): MaintenanceController {
|
||||
return {
|
||||
isActive: () => active,
|
||||
snapshot: () => ({ active, reason: active ? 'test' : undefined }),
|
||||
enter: () => undefined,
|
||||
exit: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface Stubs {
|
||||
execImpl?: (args: ExecArgs, hooks: SessionHooks) => Promise<ReturnType<SshSubsystem['sshExec']> extends Promise<infer R> ? R : never>;
|
||||
uploadImpl?: (args: UploadArgs, hooks: SessionHooks) => Promise<ReturnType<SshSubsystem['sshUpload']> extends Promise<infer R> ? R : never>;
|
||||
downloadImpl?: (args: DownloadArgs, hooks: SessionHooks) => Promise<ReturnType<SshSubsystem['sshDownload']> extends Promise<infer R> ? R : never>;
|
||||
}
|
||||
|
||||
function makeSubsystem(opts: {
|
||||
db: Database.Database;
|
||||
config?: SshRuntimeConfig;
|
||||
maintenance?: MaintenanceController;
|
||||
stubs?: Stubs;
|
||||
userAccess?: Record<string, { isAdmin: boolean; orgIds: string[] }>;
|
||||
}): { sub: SshSubsystem; repos: { conn: SshConnectionRepo; audit: SshAuditRepo; abuse: SshAbuseRepo; grants: SshGrantsRepo } } {
|
||||
const conn = createConnectionRepo(opts.db);
|
||||
const grants = createGrantsRepo(opts.db);
|
||||
const audit = createAuditRepo(opts.db);
|
||||
const abuse = createAbuseRepo(opts.db, {
|
||||
windowMinutes: 10,
|
||||
failureThreshold: 5,
|
||||
lockMinutes: 30,
|
||||
});
|
||||
const access = createAccessResolver(grants, { adminBypassesGrants: true });
|
||||
const sub: SshSubsystem = {
|
||||
connectionRepo: conn,
|
||||
auditRepo: audit,
|
||||
abuseRepo: abuse,
|
||||
accessResolver: access,
|
||||
decryptKeyMaterial: (_ownerId, blob) => Buffer.from(blob), // identity (we stub session anyway)
|
||||
decryptPassphrase: (_ownerId, blob) => (blob ? Buffer.from(blob) : null),
|
||||
getUserAccess: (userId) => opts.userAccess?.[userId] ?? { isAdmin: false, orgIds: [] },
|
||||
sshExec: opts.stubs?.execImpl
|
||||
? opts.stubs.execImpl
|
||||
: async () => ({
|
||||
outputJson: JSON.stringify({ stdout: 'ok', exit_code: 0, untrusted: true }),
|
||||
exitCode: 0,
|
||||
durationMs: 12,
|
||||
hostFingerprint: 'SHA256:fake',
|
||||
}),
|
||||
sshUpload: opts.stubs?.uploadImpl
|
||||
? opts.stubs.uploadImpl
|
||||
: async () => ({ bytes: 4, durationMs: 5, hostFingerprint: 'SHA256:fake' }),
|
||||
sshDownload: opts.stubs?.downloadImpl
|
||||
? opts.stubs.downloadImpl
|
||||
: async () => ({ bytes: 4, durationMs: 5, hostFingerprint: 'SHA256:fake' }),
|
||||
maintenance: opts.maintenance ?? makeMaintenance(false),
|
||||
config: opts.config ?? makeConfig(),
|
||||
// Phase 3 (SSH Console) deps — these tests don't exercise console tools,
|
||||
// so a dummy registry + unreachable openShellChannel keep the interface
|
||||
// satisfied without booting the headless terminal.
|
||||
sessionRegistry: {
|
||||
register: () => undefined,
|
||||
get: () => null,
|
||||
listAll: () => [],
|
||||
listForConnection: () => [],
|
||||
closeForTask: async () => undefined,
|
||||
enforceCap: () => [],
|
||||
sweep: async () => undefined,
|
||||
startSweepTimer: () => undefined,
|
||||
stopSweepTimer: () => undefined,
|
||||
shutdown: async () => undefined,
|
||||
} as unknown as SshSubsystem['sessionRegistry'],
|
||||
openShellChannel: async () => {
|
||||
throw new Error('openShellChannel not stubbed in this test');
|
||||
},
|
||||
};
|
||||
return { sub, repos: { conn, audit, abuse, grants } };
|
||||
}
|
||||
|
||||
function baseConnInput(overrides: Partial<CreateConnectionInput> = {}): CreateConnectionInput {
|
||||
return {
|
||||
ownerId: 'alice',
|
||||
label: 'prod-srv',
|
||||
host: 'srv.example.com',
|
||||
port: 22,
|
||||
username: 'deploy',
|
||||
privateKeyEnc: Buffer.from('encrypted-pem'),
|
||||
keyFingerprint: 'SHA256:fp',
|
||||
remotePathPrefix: '/srv/agent',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function ctxWithWorkspace(opts: {
|
||||
workspace?: string;
|
||||
userId?: string;
|
||||
pieceName?: string;
|
||||
allowed?: string[];
|
||||
jobId?: string;
|
||||
}): Promise<ToolContext> {
|
||||
const workspace = opts.workspace ?? (await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-tool-')));
|
||||
return {
|
||||
workspacePath: workspace,
|
||||
editAllowed: true,
|
||||
userId: opts.userId ?? 'alice',
|
||||
ownerId: opts.userId ?? 'alice',
|
||||
pieceName: opts.pieceName ?? 'ops',
|
||||
allowedSshConnections: opts.allowed,
|
||||
jobId: opts.jobId ?? 'job1',
|
||||
};
|
||||
}
|
||||
|
||||
describe('engine/tools/ssh — subsystem gating', () => {
|
||||
beforeEach(() => setSshSubsystem(null));
|
||||
afterEach(() => setSshSubsystem(null));
|
||||
|
||||
it('rejects with "not initialised" when subsystem is null', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'] });
|
||||
const r = await executeTool('SshExec', { connection_id: 'x', command: 'whoami' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/not initialised/);
|
||||
});
|
||||
|
||||
it('rejects with "in maintenance" when maintenance is active', async () => {
|
||||
const db = bootstrapDb();
|
||||
const { sub } = makeSubsystem({ db, maintenance: makeMaintenance(true) });
|
||||
setSshSubsystem(sub);
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'] });
|
||||
const r = await executeTool('SshExec', { connection_id: 'x', command: 'whoami' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/in maintenance/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh — preflight (piece + access + state)', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
let repos: ReturnType<typeof makeSubsystem>['repos'];
|
||||
let connId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = bootstrapDb();
|
||||
({ sub, repos } = makeSubsystem({ db }));
|
||||
setSshSubsystem(sub);
|
||||
const created = repos.conn.create(
|
||||
baseConnInput({
|
||||
ownerId: 'alice',
|
||||
commandDenyPatterns: '^rm -rf /\\b',
|
||||
}),
|
||||
);
|
||||
// Verify the host key so SshExec passes step 6
|
||||
db.prepare(
|
||||
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
|
||||
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
|
||||
connId = created.id;
|
||||
});
|
||||
|
||||
afterEach(() => setSshSubsystem(null));
|
||||
|
||||
it('rejects when allowed_ssh_connections is undefined', async () => {
|
||||
const ctx = await ctxWithWorkspace({}); // allowed: undefined
|
||||
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/does not declare allowed_ssh_connections/);
|
||||
});
|
||||
|
||||
it('rejects when connection_id is not in allowed list', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['00000000-other'] });
|
||||
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/not in this piece's allowed_ssh_connections/);
|
||||
const rows = repos.audit.listForConnection(connId, 10);
|
||||
expect(rows[0].outcome).toBe('denied');
|
||||
expect(rows[0].detail).toMatchObject({ reason: 'piece_not_allowed' });
|
||||
});
|
||||
|
||||
it('honours wildcard "*" in allowed list', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'] });
|
||||
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when connection does not exist', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['ffffffff-aaaa-bbbb-cccc-dddddddddddd'] });
|
||||
const r = await executeTool(
|
||||
'SshExec',
|
||||
{ connection_id: 'ffffffff-aaaa-bbbb-cccc-dddddddddddd', command: 'ls' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/does not exist/);
|
||||
const rows = repos.audit.listPending().concat([]);
|
||||
const denied = db.prepare(`SELECT * FROM ssh_audit_log WHERE outcome = 'denied' ORDER BY id DESC LIMIT 1`).get() as { detail: string };
|
||||
expect(JSON.parse(denied.detail)).toMatchObject({ reason: 'unknown_connection' });
|
||||
});
|
||||
|
||||
it('rejects non-owner without a grant', async () => {
|
||||
const ctx = await ctxWithWorkspace({ userId: 'bob', allowed: [connId] });
|
||||
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/access denied/);
|
||||
const denied = db.prepare(`SELECT * FROM ssh_audit_log WHERE outcome = 'denied' ORDER BY id DESC LIMIT 1`).get() as { detail: string };
|
||||
expect(JSON.parse(denied.detail)).toMatchObject({ reason: 'no_grant' });
|
||||
});
|
||||
|
||||
it('rejects when admin disabled (access resolver catches before state check)', async () => {
|
||||
repos.conn.disableByAdmin(connId, 'security review', 'admin1');
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId] });
|
||||
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
// The access resolver short-circuits on `enabled=false` with reason='disabled' (step 5)
|
||||
// before our explicit disabled_by_admin state check (step 6) runs. Both paths are correct;
|
||||
// the resolver path is what fires.
|
||||
expect(r.output).toMatch(/access denied \(disabled\)/);
|
||||
});
|
||||
|
||||
it('rejects when abuse-locked', async () => {
|
||||
// Saturate the abuse counter to trip the lock.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
repos.abuse.checkAndRecordFailure({
|
||||
connectionId: connId,
|
||||
ownerId: 'alice',
|
||||
userId: 'alice',
|
||||
host: 'srv.example.com',
|
||||
username: 'deploy',
|
||||
});
|
||||
}
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId] });
|
||||
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/temporarily locked/);
|
||||
});
|
||||
|
||||
it('admin user is allowed without an explicit grant (adminBypassesGrants=true)', async () => {
|
||||
const ctxAdmin = await ctxWithWorkspace({ userId: 'admin1', allowed: [connId] });
|
||||
// Stub getUserAccess to mark admin1 as admin
|
||||
setSshSubsystem({
|
||||
...sub,
|
||||
getUserAccess: (uid) => (uid === 'admin1' ? { isAdmin: true, orgIds: [] } : { isAdmin: false, orgIds: [] }),
|
||||
});
|
||||
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctxAdmin);
|
||||
expect(r.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh SshExec', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
let repos: ReturnType<typeof makeSubsystem>['repos'];
|
||||
let connId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = bootstrapDb();
|
||||
({ sub, repos } = makeSubsystem({ db }));
|
||||
setSshSubsystem(sub);
|
||||
const created = repos.conn.create(
|
||||
baseConnInput({
|
||||
commandDenyPatterns: '^rm -rf /\\b\n^dd\\s',
|
||||
commandAllowPatterns: '^ls\\b\n^echo\\s\n^whoami\\b',
|
||||
}),
|
||||
);
|
||||
db.prepare(
|
||||
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
|
||||
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
|
||||
connId = created.id;
|
||||
});
|
||||
|
||||
afterEach(() => setSshSubsystem(null));
|
||||
|
||||
it('rejects a command blocked by built-in deny list', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId] });
|
||||
const r = await executeTool(
|
||||
'SshExec',
|
||||
{ connection_id: connId, command: 'rm -rf /' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/rejected by built-in deny pattern/);
|
||||
});
|
||||
|
||||
it('rejects a command outside the allow list', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId] });
|
||||
const r = await executeTool(
|
||||
'SshExec',
|
||||
{ connection_id: connId, command: 'curl http://x' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/not_in_allowlist/);
|
||||
});
|
||||
|
||||
it('succeeds on an allowed command and records audit success', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId] });
|
||||
const r = await executeTool(
|
||||
'SshExec',
|
||||
{ connection_id: connId, command: 'whoami' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(false);
|
||||
const parsed = JSON.parse(r.output) as { stdout: string; exit_code: number };
|
||||
expect(parsed.exit_code).toBe(0);
|
||||
const rows = repos.audit.listForConnection(connId, 10);
|
||||
expect(rows[0].outcome).toBe('success');
|
||||
expect(rows[0].detail).toMatchObject({ exit_code: 0 });
|
||||
});
|
||||
|
||||
it('records audit "failed" and increments abuse counter on auth_failed', async () => {
|
||||
const failed = makeSubsystem({
|
||||
db,
|
||||
stubs: {
|
||||
execImpl: async () => {
|
||||
throw new SshSessionError('auth_failed', 'bad key');
|
||||
},
|
||||
},
|
||||
});
|
||||
// Reuse the same db & repos so we observe state mutations
|
||||
setSshSubsystem({ ...sub, sshExec: failed.sub.sshExec });
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId] });
|
||||
const r = await executeTool(
|
||||
'SshExec',
|
||||
{ connection_id: connId, command: 'whoami' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/SSH authentication failed/);
|
||||
const rows = repos.audit.listForConnection(connId, 10);
|
||||
expect(rows[0].outcome).toBe('failed');
|
||||
expect(rows[0].detail).toMatchObject({ error: 'auth_failed' });
|
||||
// Abuse counter should have ticked
|
||||
const scope = repos.abuse.getByScopeKey(`conn:${connId}`);
|
||||
expect(scope?.failure_count).toBe(1);
|
||||
});
|
||||
|
||||
it('clears abuse counter on success', async () => {
|
||||
// Pre-seed a failure
|
||||
repos.abuse.checkAndRecordFailure({
|
||||
connectionId: connId,
|
||||
ownerId: 'alice',
|
||||
userId: 'alice',
|
||||
host: 'srv.example.com',
|
||||
username: 'deploy',
|
||||
});
|
||||
expect(repos.abuse.getByScopeKey(`conn:${connId}`)?.failure_count).toBe(1);
|
||||
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId] });
|
||||
const r = await executeTool(
|
||||
'SshExec',
|
||||
{ connection_id: connId, command: 'whoami' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(false);
|
||||
expect(repos.abuse.getByScopeKey(`conn:${connId}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces TOFU first_observe with token to the LLM', async () => {
|
||||
setSshSubsystem({
|
||||
...sub,
|
||||
sshExec: async (_args, hooks) => {
|
||||
const ret = await hooks.onFirstObserve({
|
||||
connectionId: connId,
|
||||
b64: 'observed-key-b64',
|
||||
fingerprint: 'SHA256:newhost',
|
||||
});
|
||||
const err = new SshSessionError('host_key_first_observe', 'new host', {
|
||||
fingerprint: 'SHA256:newhost',
|
||||
token: ret?.token ?? 'no-token',
|
||||
});
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId] });
|
||||
const r = await executeTool(
|
||||
'SshExec',
|
||||
{ connection_id: connId, command: 'whoami' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/Host key first-observe/);
|
||||
// Pending key was persisted on the connection
|
||||
const conn = repos.conn.resolveConnection(connId);
|
||||
expect(conn?.hostKeyPending).toBe(true);
|
||||
expect(conn?.hostKeyPendingFingerprint).toBe('SHA256:newhost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh SshUpload', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
let repos: ReturnType<typeof makeSubsystem>['repos'];
|
||||
let connId: string;
|
||||
let workspace: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = bootstrapDb();
|
||||
({ sub, repos } = makeSubsystem({ db }));
|
||||
setSshSubsystem(sub);
|
||||
const created = repos.conn.create(baseConnInput({ remotePathPrefix: '/srv/agent' }));
|
||||
db.prepare(
|
||||
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
|
||||
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
|
||||
connId = created.id;
|
||||
workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-upload-'));
|
||||
await fsp.writeFile(path.join(workspace, 'hello.txt'), 'hi');
|
||||
});
|
||||
|
||||
afterEach(() => setSshSubsystem(null));
|
||||
|
||||
it('rejects when host key is unverified', async () => {
|
||||
// Clear the host_key_verified_at to simulate an un-verified connection
|
||||
db.prepare(`UPDATE ssh_connections SET host_key_verified_at=NULL WHERE id=?`).run(connId);
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
|
||||
const r = await executeTool(
|
||||
'SshUpload',
|
||||
{ connection_id: connId, local_path: 'hello.txt', remote_path: '/srv/agent/h.txt' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/is not user-verified/);
|
||||
});
|
||||
|
||||
it('rejects when local path escapes the workspace', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
|
||||
const r = await executeTool(
|
||||
'SshUpload',
|
||||
{ connection_id: connId, local_path: '/etc/passwd', remote_path: '/srv/agent/h.txt' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/local path rejected/);
|
||||
});
|
||||
|
||||
it('rejects when remote path is outside the prefix', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
|
||||
const r = await executeTool(
|
||||
'SshUpload',
|
||||
{ connection_id: connId, local_path: 'hello.txt', remote_path: '/etc/passwd' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/remote path rejected/);
|
||||
});
|
||||
|
||||
it('succeeds and records audit success', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
|
||||
const r = await executeTool(
|
||||
'SshUpload',
|
||||
{ connection_id: connId, local_path: 'hello.txt', remote_path: '/srv/agent/h.txt' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(false);
|
||||
const body = JSON.parse(r.output) as { ok: boolean; bytes: number; remote: string };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.remote).toBe('/srv/agent/h.txt');
|
||||
const rows = repos.audit.listForConnection(connId, 10);
|
||||
expect(rows[0].outcome).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh SshDownload', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
let repos: ReturnType<typeof makeSubsystem>['repos'];
|
||||
let connId: string;
|
||||
let workspace: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = bootstrapDb();
|
||||
({ sub, repos } = makeSubsystem({
|
||||
db,
|
||||
stubs: {
|
||||
downloadImpl: async (args) => {
|
||||
// Simulate session writing the file so a subsequent validateLocalPath
|
||||
// wouldn't be needed (but for now we just resolve happily).
|
||||
return { bytes: 7, durationMs: 8, hostFingerprint: 'SHA256:fake' };
|
||||
},
|
||||
},
|
||||
}));
|
||||
setSshSubsystem(sub);
|
||||
const created = repos.conn.create(baseConnInput({ remotePathPrefix: '/srv/agent' }));
|
||||
db.prepare(
|
||||
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
|
||||
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
|
||||
connId = created.id;
|
||||
workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-download-'));
|
||||
});
|
||||
|
||||
afterEach(() => setSshSubsystem(null));
|
||||
|
||||
it('succeeds on a fresh local target path', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
|
||||
const r = await executeTool(
|
||||
'SshDownload',
|
||||
{ connection_id: connId, remote_path: '/srv/agent/data.bin', local_path: 'output/data.bin' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(false);
|
||||
const body = JSON.parse(r.output) as { ok: boolean; bytes: number };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.bytes).toBe(7);
|
||||
const rows = repos.audit.listForConnection(connId, 10);
|
||||
expect(rows[0].outcome).toBe('success');
|
||||
});
|
||||
|
||||
it('rejects when local path escapes workspace', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
|
||||
const r = await executeTool(
|
||||
'SshDownload',
|
||||
{ connection_id: connId, remote_path: '/srv/agent/x', local_path: '/tmp/escape.bin' },
|
||||
ctx,
|
||||
);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/local path rejected/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh TOOL_DEFS', () => {
|
||||
it('exposes the four SSH tools with required parameters', () => {
|
||||
expect(Object.keys(TOOL_DEFS).sort()).toEqual([
|
||||
'SshDownload', 'SshExec', 'SshListConnections', 'SshUpload',
|
||||
]);
|
||||
expect(TOOL_DEFS.SshExec.function.parameters.required).toEqual(['connection_id', 'command']);
|
||||
expect(TOOL_DEFS.SshUpload.function.parameters.required).toEqual(['connection_id', 'local_path', 'remote_path']);
|
||||
expect(TOOL_DEFS.SshDownload.function.parameters.required).toEqual(['connection_id', 'remote_path', 'local_path']);
|
||||
expect(TOOL_DEFS.SshListConnections.function.parameters.required).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh SshListConnections', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
let repos: { conn: SshConnectionRepo; audit: SshAuditRepo; abuse: SshAbuseRepo; grants: SshGrantsRepo };
|
||||
|
||||
beforeEach(() => {
|
||||
db = bootstrapDb();
|
||||
({ sub, repos } = makeSubsystem({ db }));
|
||||
setSshSubsystem(sub);
|
||||
});
|
||||
afterEach(() => {
|
||||
setSshSubsystem(null);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('rejects when piece does not declare allowed_ssh_connections', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: undefined });
|
||||
const r = await executeTool('SshListConnections', {}, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/does not declare allowed_ssh_connections/);
|
||||
});
|
||||
|
||||
it('returns empty array when no connections exist', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'] });
|
||||
const r = await executeTool('SshListConnections', {}, ctx);
|
||||
expect(r.isError).toBe(false);
|
||||
expect(JSON.parse(r.output)).toEqual({ connections: [] });
|
||||
});
|
||||
|
||||
it('wildcard returns owner+granted connections only (others without grant filtered)', async () => {
|
||||
const a = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'alice-srv' }));
|
||||
// bob-owned: alice has no grant → access denied → filtered out.
|
||||
repos.conn.create(baseConnInput({ ownerId: 'bob', label: 'bob-srv' }));
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'], userId: 'alice' });
|
||||
const r = await executeTool('SshListConnections', {}, ctx);
|
||||
expect(r.isError).toBe(false);
|
||||
const parsed = JSON.parse(r.output);
|
||||
expect(parsed.connections.map((c: any) => c.label)).toEqual(['alice-srv']);
|
||||
expect(parsed.connections[0]).toMatchObject({
|
||||
id: a.id,
|
||||
label: 'alice-srv',
|
||||
host: 'srv.example.com',
|
||||
port: 22,
|
||||
username: 'deploy',
|
||||
host_key_verified: false,
|
||||
host_key_pending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('explicit UUID list filters out non-matching connections', async () => {
|
||||
const a = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'a' }));
|
||||
const c = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'c' }));
|
||||
const ctx = await ctxWithWorkspace({ allowed: [a.id], userId: 'alice' });
|
||||
const r = await executeTool('SshListConnections', {}, ctx);
|
||||
const parsed = JSON.parse(r.output);
|
||||
expect(parsed.connections.map((x: any) => x.id)).toEqual([a.id]);
|
||||
expect(parsed.connections.map((x: any) => x.id)).not.toContain(c.id);
|
||||
});
|
||||
|
||||
it('writes an audit row with action ssh.list_connections', async () => {
|
||||
repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'x' }));
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'], userId: 'alice' });
|
||||
await executeTool('SshListConnections', {}, ctx);
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT action, outcome FROM ssh_audit_log WHERE action = 'ssh.list_connections' ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.get() as { action: string; outcome: string } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.outcome).toBe('success');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user