mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Wave-assembled from PR #3724 by @gregario. Co-Authored-By: Greg Jackson <gregj64@gmail.com>
389 lines
15 KiB
TypeScript
389 lines
15 KiB
TypeScript
/**
|
|
* Contract tests for the v0.28 sources_* MCP ops.
|
|
*
|
|
* - Op metadata: pins scope, localOnly, mutating, and that each op exists in
|
|
* the registered `operations` array (auto-flows through tool-defs).
|
|
* - Functional: invokes each op handler against a PGLite engine to confirm
|
|
* the expected return shape.
|
|
* - Scope-enforcement smoke test: simulates the serve-http.ts:673 hasScope
|
|
* gate so we know read-only tokens get insufficient_scope on sources_add.
|
|
* Full HTTP-transport coverage lives in test/e2e/serve-http-oauth.test.ts.
|
|
*/
|
|
|
|
import { test, expect, describe, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdirSync, writeFileSync, rmSync, chmodSync, existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { operations, OperationError } from '../src/core/operations.ts';
|
|
import type { OperationContext, AuthInfo, Operation } from '../src/core/operations.ts';
|
|
import { hasScope } from '../src/core/scope.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
const FAKE_GIT_DIR = join(tmpdir(), `gbrain-sources-mcp-test-${process.pid}`);
|
|
const GBRAIN_HOME = join(FAKE_GIT_DIR, 'gbrain-home');
|
|
|
|
function writeFakeGit(): void {
|
|
mkdirSync(FAKE_GIT_DIR, { recursive: true });
|
|
const script = `#!/usr/bin/env bash
|
|
has_clone=0
|
|
has_remote_get_url=0
|
|
for ((i=1; i<=$#; i++)); do
|
|
arg="\${!i}"
|
|
next_idx=$((i+1))
|
|
next="\${!next_idx:-}"
|
|
if [ "$arg" = "clone" ]; then has_clone=1; fi
|
|
if [ "$arg" = "remote" ] && [ "$next" = "get-url" ]; then has_remote_get_url=1; fi
|
|
done
|
|
if [ "$has_clone" = "1" ]; then
|
|
dest="\${@: -1}"
|
|
mkdir -p "$dest/.git"
|
|
echo "ref: refs/heads/main" > "$dest/.git/HEAD"
|
|
exit 0
|
|
fi
|
|
if [ "$has_remote_get_url" = "1" ]; then
|
|
echo "https://github.com/example/repo"
|
|
exit 0
|
|
fi
|
|
exit 0
|
|
`;
|
|
const path = join(FAKE_GIT_DIR, 'git');
|
|
writeFileSync(path, script);
|
|
chmodSync(path, 0o755);
|
|
}
|
|
|
|
const fakePath = (): string => `${FAKE_GIT_DIR}:${process.env.PATH ?? ''}`;
|
|
|
|
beforeAll(async () => {
|
|
writeFakeGit();
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
rmSync(FAKE_GIT_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
rmSync(GBRAIN_HOME, { recursive: true, force: true });
|
|
mkdirSync(GBRAIN_HOME, { recursive: true });
|
|
});
|
|
|
|
function findOp(name: string): Operation {
|
|
const op = operations.find(o => o.name === name);
|
|
if (!op) throw new Error(`op not found: ${name}`);
|
|
return op;
|
|
}
|
|
|
|
function ctxRemote(scopes: string[]): OperationContext {
|
|
const auth: AuthInfo = {
|
|
token: 'gbrain_at_xxx',
|
|
clientId: 'gbrain_cl_test',
|
|
clientName: 'test-client',
|
|
scopes,
|
|
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
};
|
|
return {
|
|
engine: engine as any,
|
|
config: { engine: 'pglite' } as any,
|
|
logger: { info() {}, warn() {}, error() {} },
|
|
dryRun: false,
|
|
remote: true,
|
|
auth,
|
|
sourceId: 'default',
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Op metadata pins (auto-flow through tool-defs)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('sources_* op metadata', () => {
|
|
const expected: Array<{
|
|
name: string;
|
|
scope: NonNullable<Operation['scope']>;
|
|
mutating: boolean;
|
|
localOnly: boolean;
|
|
}> = [
|
|
{ name: 'sources_add', scope: 'sources_admin', mutating: true, localOnly: false },
|
|
{ name: 'sources_list', scope: 'read', mutating: false, localOnly: false },
|
|
{ name: 'sources_remove', scope: 'sources_admin', mutating: true, localOnly: false },
|
|
{ name: 'sources_status', scope: 'read', mutating: false, localOnly: false },
|
|
];
|
|
for (const e of expected) {
|
|
test(`${e.name}: scope=${e.scope}, mutating=${e.mutating}, localOnly=${e.localOnly}`, () => {
|
|
const op = findOp(e.name);
|
|
expect(op.scope).toBe(e.scope);
|
|
expect(!!op.mutating).toBe(e.mutating);
|
|
expect(!!op.localOnly).toBe(e.localOnly);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Functional handler shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('sources_* handlers — happy path', () => {
|
|
test('sources_add (with --url) clones, INSERTs, returns row', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const op = findOp('sources_add');
|
|
const ctx = ctxRemote(['sources_admin']);
|
|
const row = (await op.handler(ctx, {
|
|
id: 'mcp-add-test',
|
|
url: 'https://github.com/example/repo',
|
|
federated: true,
|
|
})) as any;
|
|
expect(row.id).toBe('mcp-add-test');
|
|
expect(row.config.remote_url).toBe('https://github.com/example/repo');
|
|
expect(row.config.federated).toBe(true);
|
|
expect(existsSync(join(row.local_path, '.git'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('sources_list returns array with remote_url', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const addOp = findOp('sources_add');
|
|
await addOp.handler(ctxRemote(['sources_admin']), {
|
|
id: 'mcp-list-test',
|
|
url: 'https://github.com/example/repo',
|
|
});
|
|
const listOp = findOp('sources_list');
|
|
const result = (await listOp.handler(ctxRemote(['read']), {})) as any;
|
|
expect(Array.isArray(result.sources)).toBe(true);
|
|
const found = result.sources.find((s: any) => s.id === 'mcp-list-test');
|
|
expect(found).toBeDefined();
|
|
expect(found.remote_url).toBe('https://github.com/example/repo');
|
|
});
|
|
});
|
|
|
|
test('sources_status returns clone_state', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const addOp = findOp('sources_add');
|
|
await addOp.handler(ctxRemote(['sources_admin']), {
|
|
id: 'mcp-status-test',
|
|
url: 'https://github.com/example/repo',
|
|
});
|
|
const statusOp = findOp('sources_status');
|
|
const result = (await statusOp.handler(ctxRemote(['read']), {
|
|
id: 'mcp-status-test',
|
|
})) as any;
|
|
expect(result.id).toBe('mcp-status-test');
|
|
expect(result.clone_state).toBe('healthy');
|
|
expect(result.remote_url).toBe('https://github.com/example/repo');
|
|
});
|
|
});
|
|
|
|
test('sources_remove deletes row + clone (with confirm_destructive)', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const addOp = findOp('sources_add');
|
|
const row = (await addOp.handler(ctxRemote(['sources_admin']), {
|
|
id: 'mcp-remove-test',
|
|
url: 'https://github.com/example/repo',
|
|
})) as any;
|
|
const removeOp = findOp('sources_remove');
|
|
const result = (await removeOp.handler(ctxRemote(['sources_admin']), {
|
|
id: 'mcp-remove-test',
|
|
confirm_destructive: true,
|
|
})) as any;
|
|
expect(result.clone_removed).toBe(true);
|
|
expect(existsSync(row.local_path)).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scope-enforcement smoke test
|
|
// Simulates serve-http.ts:673's hasScope gate. The full HTTP path (real
|
|
// bearer auth + middleware) lives in test/e2e/serve-http-oauth.test.ts.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('sources_* scope enforcement (simulates serve-http gate)', () => {
|
|
function gate(op: Operation, grantedScopes: string[]): { allowed: boolean; required: string } {
|
|
const required = op.scope || 'read';
|
|
return { allowed: hasScope(grantedScopes, required), required };
|
|
}
|
|
|
|
test('read-only token is REJECTED for sources_add (sources_admin required)', () => {
|
|
const r = gate(findOp('sources_add'), ['read']);
|
|
expect(r.required).toBe('sources_admin');
|
|
expect(r.allowed).toBe(false);
|
|
});
|
|
|
|
test('read-only token is REJECTED for sources_remove', () => {
|
|
expect(gate(findOp('sources_remove'), ['read']).allowed).toBe(false);
|
|
});
|
|
|
|
test('read-only token is ALLOWED for sources_list (read-scoped)', () => {
|
|
expect(gate(findOp('sources_list'), ['read']).allowed).toBe(true);
|
|
});
|
|
|
|
test('read-only token is ALLOWED for sources_status (read-scoped)', () => {
|
|
expect(gate(findOp('sources_status'), ['read']).allowed).toBe(true);
|
|
});
|
|
|
|
test('sources_admin token is ALLOWED for all sources_* ops', () => {
|
|
const granted = ['sources_admin'];
|
|
expect(gate(findOp('sources_add'), granted).allowed).toBe(true);
|
|
expect(gate(findOp('sources_list'), granted).allowed).toBe(false); // sources_admin doesn't imply read (sibling axes)
|
|
expect(gate(findOp('sources_remove'), granted).allowed).toBe(true);
|
|
expect(gate(findOp('sources_status'), granted).allowed).toBe(false);
|
|
});
|
|
|
|
test('admin token is ALLOWED for all sources_* ops (admin implies all)', () => {
|
|
const granted = ['admin'];
|
|
expect(gate(findOp('sources_add'), granted).allowed).toBe(true);
|
|
expect(gate(findOp('sources_list'), granted).allowed).toBe(true);
|
|
expect(gate(findOp('sources_remove'), granted).allowed).toBe(true);
|
|
expect(gate(findOp('sources_status'), granted).allowed).toBe(true);
|
|
});
|
|
|
|
test('gstack /setup-gbrain Path 4 token (read + sources_admin) covers everything', () => {
|
|
const granted = ['read', 'sources_admin'];
|
|
expect(gate(findOp('sources_add'), granted).allowed).toBe(true);
|
|
expect(gate(findOp('sources_list'), granted).allowed).toBe(true);
|
|
expect(gate(findOp('sources_remove'), granted).allowed).toBe(true);
|
|
expect(gate(findOp('sources_status'), granted).allowed).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSRF rejection at the op layer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// v0.28.1 codex hardening: remote callers cannot override path/clone_dir
|
|
// (those are local-CLI-only — remote sources_admin is for managing federated
|
|
// remote URLs, not arbitrary host-path writes).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('sources_add — remote callers ignore path/clone_dir overrides', () => {
|
|
test('remote sources_admin: clone_dir override is silently ignored', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const op = findOp('sources_add');
|
|
const ctx = ctxRemote(['sources_admin']);
|
|
const row = (await op.handler(ctx, {
|
|
id: 'attack-clone-dir',
|
|
url: 'https://github.com/example/repo',
|
|
clone_dir: '/etc/gbrain-pwned', // attacker-supplied
|
|
})) as any;
|
|
// Clone landed at the SAFE default, not /etc/gbrain-pwned.
|
|
expect(row.local_path).not.toBe('/etc/gbrain-pwned');
|
|
// defaultCloneDir → gbrainPath('clones', id) → join, so '\' on win32.
|
|
expect(row.local_path).toContain(join('clones', 'attack-clone-dir'));
|
|
// /etc/gbrain-pwned was never written.
|
|
expect(existsSync('/etc/gbrain-pwned')).toBe(false);
|
|
});
|
|
});
|
|
|
|
test('remote sources_admin: path override (without url) is rejected with an error', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const op = findOp('sources_add');
|
|
const ctx = ctxRemote(['sources_admin']);
|
|
// Remote callers must not silently succeed when passing path — the call
|
|
// should fail so the client knows the source was NOT registered with a
|
|
// usable local_path. Silent success + null local_path means sync --all
|
|
// skips the source forever (#3645).
|
|
await expect(op.handler(ctx, {
|
|
id: 'attack-path',
|
|
path: '/etc',
|
|
})).rejects.toThrow(/path.*clone_dir.*not honored|confinement/i);
|
|
});
|
|
});
|
|
|
|
test('local CLI caller (ctx.remote=false) keeps clone_dir override', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const op = findOp('sources_add');
|
|
// Simulate trusted local CLI: ctx.remote = false, no auth needed.
|
|
const ctxLocal: OperationContext = {
|
|
engine: engine as any,
|
|
config: { engine: 'pglite' } as any,
|
|
logger: { info() {}, warn() {}, error() {} },
|
|
dryRun: false,
|
|
remote: false,
|
|
sourceId: 'default',
|
|
};
|
|
const customDir = join(GBRAIN_HOME, 'custom-clones', 'local-override');
|
|
const row = (await op.handler(ctxLocal, {
|
|
id: 'local-override',
|
|
url: 'https://github.com/example/repo',
|
|
clone_dir: customDir,
|
|
})) as any;
|
|
// Local CLI is trusted: the override took effect.
|
|
expect(row.local_path).toBe(customDir);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// v0.28.1 codex hardening: listSources honors include_archived flag
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('sources_list — include_archived honored (was silently leaking)', () => {
|
|
test('default: archived sources are NOT returned', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
// Add source then archive it.
|
|
const addOp = findOp('sources_add');
|
|
await addOp.handler(ctxRemote(['sources_admin']), {
|
|
id: 'archived-src',
|
|
url: 'https://github.com/example/repo',
|
|
});
|
|
await engine.executeRaw(
|
|
`UPDATE sources SET archived = true WHERE id = $1`,
|
|
['archived-src'],
|
|
);
|
|
|
|
const listOp = findOp('sources_list');
|
|
const result = (await listOp.handler(ctxRemote(['read']), {})) as any;
|
|
const found = result.sources.find((s: any) => s.id === 'archived-src');
|
|
expect(found).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
test('include_archived: true returns archived sources', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const addOp = findOp('sources_add');
|
|
await addOp.handler(ctxRemote(['sources_admin']), {
|
|
id: 'archived-included',
|
|
url: 'https://github.com/example/repo',
|
|
});
|
|
await engine.executeRaw(
|
|
`UPDATE sources SET archived = true WHERE id = $1`,
|
|
['archived-included'],
|
|
);
|
|
|
|
const listOp = findOp('sources_list');
|
|
const result = (await listOp.handler(ctxRemote(['read']), {
|
|
include_archived: true,
|
|
})) as any;
|
|
const found = result.sources.find((s: any) => s.id === 'archived-included');
|
|
expect(found).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('sources_add SSRF gate (delegated to parseRemoteUrl)', () => {
|
|
test('rejects RFC1918 192.168.x.x with structured error', async () => {
|
|
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
|
const op = findOp('sources_add');
|
|
try {
|
|
await op.handler(ctxRemote(['sources_admin']), {
|
|
id: 'ssrf-bad',
|
|
url: 'https://192.168.1.1/x.git',
|
|
});
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
// The handler raises SourceOpError(invalid_remote_url). At the
|
|
// dispatch layer this gets serialized as a normal error response.
|
|
expect((e as Error).name).toBe('SourceOpError');
|
|
expect((e as any).code).toBe('invalid_remote_url');
|
|
}
|
|
});
|
|
});
|
|
});
|