This commit is contained in:
@@ -149,6 +149,7 @@ async function ctxWithWorkspace(opts: {
|
||||
pieceName?: string;
|
||||
allowed?: string[];
|
||||
jobId?: string;
|
||||
spaceId?: string | null;
|
||||
}): Promise<ToolContext> {
|
||||
const workspace = opts.workspace ?? (await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-tool-')));
|
||||
return {
|
||||
@@ -159,6 +160,7 @@ async function ctxWithWorkspace(opts: {
|
||||
pieceName: opts.pieceName ?? 'ops',
|
||||
allowedSshConnections: opts.allowed,
|
||||
jobId: opts.jobId ?? 'job1',
|
||||
spaceId: opts.spaceId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,6 +297,59 @@ describe('engine/tools/ssh — preflight (piece + access + state)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh — per-space isolation (spec §11)', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
let repos: ReturnType<typeof makeSubsystem>['repos'];
|
||||
let connA: string;
|
||||
let connB: string;
|
||||
|
||||
function verifyHostKey(id: string): void {
|
||||
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(), id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = bootstrapDb();
|
||||
({ sub, repos } = makeSubsystem({ db }));
|
||||
setSshSubsystem(sub);
|
||||
// alice owns BOTH connections, but they live in different spaces.
|
||||
const a = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'srvA', spaceId: 'space-A' }));
|
||||
const b = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'srvB', spaceId: 'space-B' }));
|
||||
verifyHostKey(a.id);
|
||||
verifyHostKey(b.id);
|
||||
connA = a.id;
|
||||
connB = b.id;
|
||||
});
|
||||
|
||||
afterEach(() => setSshSubsystem(null));
|
||||
|
||||
it('a job in space A can use the space-A connection', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'], spaceId: 'space-A' });
|
||||
const r = await executeTool('SshExec', { connection_id: connA, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('a job in space A CANNOT use a space-B connection even though the user owns it', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'], spaceId: 'space-A' });
|
||||
const r = await executeTool('SshExec', { connection_id: connB, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/access denied \(space_mismatch\)/);
|
||||
});
|
||||
|
||||
it('SshListConnections in space A lists only the space-A connection', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'], spaceId: 'space-A' });
|
||||
const r = await executeTool('SshListConnections', {}, ctx);
|
||||
expect(r.isError).toBe(false);
|
||||
const parsed = JSON.parse(r.output) as { connections?: Array<{ id: string; label: string }> };
|
||||
const list = parsed.connections ?? [];
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].id).toBe(connA);
|
||||
expect(list.some((c) => c.id === connB)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh SshExec', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
|
||||
Reference in New Issue
Block a user