mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* fix(security): confine routing dotfiles, skills dir, slugs, and transcription exec Shared src/core/path-confine.ts consolidates the realpath-containment idiom (moved from sources-ops.ts) and adds isTrustedDotfile + isWriteTargetContained. - .gbrain-source (source-resolver) and .gbrain-mount (brain-resolver) walk-up dotfiles are now lstat trust-gated: a symlink, foreign-owned, or world-writable file is refused on multi-user hosts (#418), fail-closed on stat error. - resolveWorkspaceSkillsDir + every skills-dir tier (env, cwd_walk_up, repo_root, cwd_skills, install_path) route through realpath containment so a symlinked workspace/skills can't escape the declared workspace (#419). - resolveSourceId/resolveBrainId realpath both sides of the registered local_path / mount prefix match so a symlinked cwd can't misattribute source/brain. - validateSlug rejects NUL/control, bidi/RTL overrides, backslashes, and URL-encoded path separators at the shared putPage/updateSlug chokepoint; write-through confirms the file path stays within the source tree. - transcribeLargeFile uses execFileSync arg-arrays + fs.rmSync (no shell), so a path with shell metacharacters is never parsed by a shell (#245). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): default dynamic-registration clients to authorization_code Self-registered DCR clients (the unauthenticated network registration path) previously defaulted to the client_credentials grant, which bypasses the /authorize consent screen. They now default to authorization_code; an explicit client_credentials request is rejected with invalid_client_metadata unless the operator opts in with the new --enable-dcr-insecure flag. A loud stderr WARNING prints at startup whenever DCR is enabled (#1353). Manual CLI/admin client registration is unchanged (operator-trusted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): schema-lint hardening migration (search_path + view security_invoker) Migration v120 brings existing brains to the same posture as fresh installs: - ALTER VIEW page_links SET (security_invoker = on) on Postgres so the view honors the caller's RLS instead of the owner's (the view-through-RLS bypass). - ALTER FUNCTION ... SET search_path on the gbrain-owned trigger/event functions (both engines, IF EXISTS so engine-only functions are skipped; body untouched, so the load-bearing auto_enable_rls event trigger is unchanged). Closes #171. - Broaden the BYPASSRLS preflight in the historical RLS migration gates to honor superuser and inherited-role BYPASSRLS, so a superuser-connected fresh install no longer aborts (#1385). Fresh-install function definitions in schema.sql / pglite-schema.ts are born-correct (regenerated schema-embedded.ts). scripts/check-search-path.sh is a new CI guard (wired into verify) that fails if a trigger function in the schema base files is added without SET search_path. Postgres-only assertions live in the bootstrap E2E; the PGLite path is covered by test/migration-v120.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * v0.42.55.0 fix(security): dotfile/skills/slug confinement, DCR consent default, schema-lint migration Bump VERSION + package.json to 0.42.55.0 and add the CHANGELOG entry for the security-hardening wave (#418 #419 #245 #1353 #1647 #171 #1385). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(security): note the DCR consent default in SECURITY.md (#1353) The "disable client_credentials, only allow authorization_code" guidance is now the built-in DCR default; document the new --enable-dcr-insecure escape hatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(todos): add takes_search + code_def to the federated by-slug P1 (#2200) The v0.42.55.0 eng-review codex pass flagged takes_search (holder-allowlist only) and code_def (brain-wide raw SQL over content_chunks) as remaining same-class surfaces. Noted on the existing #2200 P1 follow-up, with the caveat that the #2399 close-list deliberately keeps #1371/#2200 open until this lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): correct plpgsql alias collision in #1385 BYPASSRLS gate (real-PG) The broadened BYPASSRLS preflight aliased `pg_roles r`, but several RLS DO-blocks already declare `r record` for their backfill FOR loop, so plpgsql resolved `r.oid`/`r.rolbypassrls` to the unassigned record variable → "record \"r\" is not assigned yet" on real Postgres (PGLite tolerated it; the DATABASE_URL-gated e2e jobs are the backstop). Renamed the subquery alias to `pr` at all 10 migrate.ts sites; also broadened the schema.sql base RLS gate the same way (with the `pr` alias) for #1385 consistency on superuser fresh installs, and regenerated schema-embedded.ts. Also fixes a PRE-EXISTING engine-parity bug (confirmed failing on clean origin/master): the relationalFanout shape compared `canonical_chunk_id`, a serial id that diverges between a fresh PGLite engine and a shared Postgres DB (setupDB TRUNCATEs without RESTART IDENTITY). Compare its presence, not the exact value. Validated on real Postgres (pgvector/pg16): migration v120 applies, the v35 RLS backfill runs, and engine-parity + postgres-bootstrap + jsonb-parity are green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
5.4 KiB
TypeScript
121 lines
5.4 KiB
TypeScript
/**
|
|
* E2E test for PostgresEngine forward-reference bootstrap.
|
|
*
|
|
* Codex caught that `test/e2e/helpers.ts:74` uses the standalone
|
|
* `db.initSchema()` from `src/core/db.ts`, which only runs SCHEMA_SQL and
|
|
* never calls runMigrations(). A test using that helper would NOT exercise
|
|
* `PostgresEngine.initSchema()`'s reordered path, producing false-positive
|
|
* coverage. This test deliberately bypasses the standard helper and
|
|
* instantiates `PostgresEngine` directly, calling `engine.initSchema()` so
|
|
* the bootstrap → SCHEMA_SQL → runMigrations sequence runs end-to-end.
|
|
*
|
|
* Covers issues #366, #375, #378 — Postgres-side wedges where pre-v0.18
|
|
* brains crashed on `column "source_id" does not exist`.
|
|
*
|
|
* NOTE: snapshot-based historical state simulation is out of scope for this
|
|
* wave (would require maintaining historical schema dumps). The test
|
|
* mutates a fresh-LATEST brain to a pre-v0.18 shape; codex flagged this as
|
|
* approximate. Acceptable here because the bootstrap's contract is narrow:
|
|
* "given a brain that lacks the specific forward-references, initSchema
|
|
* produces a brain at LATEST." The test exercises exactly that contract.
|
|
*
|
|
* Run: DATABASE_URL=postgresql://... bun run test:e2e test/e2e/postgres-bootstrap.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
|
|
import { LATEST_VERSION } from '../../src/core/migrate.ts';
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
const skip = !DATABASE_URL;
|
|
|
|
describe.skipIf(skip)('PostgresEngine forward-reference bootstrap (E2E)', () => {
|
|
let engine: PostgresEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PostgresEngine();
|
|
await engine.connect({ database_url: DATABASE_URL! });
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
test('PostgresEngine.initSchema applies bootstrap → SCHEMA_SQL → migrations on pre-v0.18 brain', async () => {
|
|
// First call: bring the test DB to LATEST shape so we have something to mutate.
|
|
await engine.initSchema();
|
|
|
|
// Clear data from prior tests in the suite. Adding a UNIQUE(slug)
|
|
// constraint below would fail if multi-source fixtures left rows with
|
|
// duplicate slugs across sources (which is valid under the composite
|
|
// UNIQUE this test is undoing).
|
|
const conn = (engine as any).sql;
|
|
await conn.unsafe(`TRUNCATE pages, content_chunks, links, tags, raw_data, timeline_entries, page_versions, ingest_log RESTART IDENTITY CASCADE`);
|
|
|
|
// Mutate to pre-v0.18 shape: drop source_id and the sources table.
|
|
// The advisory lock is released between initSchema calls, so this
|
|
// direct DDL won't deadlock.
|
|
await conn.unsafe(`
|
|
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
|
|
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
|
|
DROP INDEX IF EXISTS idx_pages_source_id;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS source_id CASCADE;
|
|
DROP TABLE IF EXISTS sources CASCADE;
|
|
`);
|
|
await engine.setConfig('version', '20');
|
|
|
|
// The path under test: full PostgresEngine.initSchema() including the
|
|
// bootstrap call, SCHEMA_SQL replay, and runMigrations chain.
|
|
await engine.initSchema();
|
|
|
|
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
|
|
|
|
// Verify the forward-referenced column exists after upgrade.
|
|
const colCheck = await conn`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema = current_schema()
|
|
AND table_name = 'pages'
|
|
AND column_name = 'source_id'
|
|
`;
|
|
expect(colCheck).toHaveLength(1);
|
|
|
|
// Verify the default source row was seeded.
|
|
const srcCheck = await conn`SELECT id FROM sources WHERE id = 'default'`;
|
|
expect(srcCheck).toHaveLength(1);
|
|
});
|
|
|
|
test('PostgresEngine.initSchema is idempotent on a brain already at LATEST', async () => {
|
|
// Fresh-LATEST brain. Calling initSchema again must not error and must
|
|
// not regress the version.
|
|
await engine.initSchema();
|
|
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
|
|
});
|
|
|
|
// Migration v120 — schema-lint hardening (#1647 / #171). Postgres-only
|
|
// assertions (security_invoker has no surface on embedded PGLite).
|
|
test('v120: page_links view runs with security_invoker=on (#1647b)', async () => {
|
|
await engine.initSchema();
|
|
const rows = await engine.executeRaw<{ reloptions: string[] | null }>(
|
|
`SELECT c.reloptions FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = 'public' AND c.relname = 'page_links' AND c.relkind = 'v'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(JSON.stringify(rows[0].reloptions ?? [])).toContain('security_invoker=on');
|
|
});
|
|
|
|
test('v120: trigger + event-trigger functions pin search_path, incl auto_enable_rls (#1647a/#171)', async () => {
|
|
await engine.initSchema();
|
|
const rows = await engine.executeRaw<{ proname: string; proconfig: unknown }>(
|
|
`SELECT p.proname, p.proconfig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
WHERE n.nspname = 'public'
|
|
AND p.proname IN ('bump_page_generation_fn','bump_page_generation_clock_fn',
|
|
'update_chunk_search_vector','update_page_search_vector',
|
|
'notify_minion_job_change','auto_enable_rls')`,
|
|
);
|
|
expect(rows.length).toBeGreaterThanOrEqual(5);
|
|
for (const r of rows) {
|
|
expect(JSON.stringify(r.proconfig ?? [])).toContain('search_path=');
|
|
}
|
|
});
|
|
});
|