mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +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>
361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
/**
|
|
* Source resolution for CLI commands (v0.18.0).
|
|
*
|
|
* Resolution priority (highest first):
|
|
* 1. Explicit --source <id> flag (caller passes this as `explicit`)
|
|
* 2. GBRAIN_SOURCE env var
|
|
* 3. .gbrain-source dotfile in CWD or any ancestor directory
|
|
* 4. Registered source whose local_path contains CWD
|
|
* 5. Brain-level default via `gbrain sources default <id>`
|
|
* 6. Literal 'default' (backward compat for pre-v0.17 brains)
|
|
*
|
|
* This helper is shared by the sources CLI, future sync/extract/query
|
|
* commands (Steps 4/5), and the operation layer (Step 2+).
|
|
*/
|
|
|
|
import { readFileSync, lstatSync, type Stats } from 'fs';
|
|
import { join, dirname, resolve } from 'path';
|
|
import type { BrainEngine } from './engine.ts';
|
|
import { SOURCE_ID_RE, isValidSourceId } from './source-id.ts';
|
|
import { isTrustedDotfile, realpathOrResolve } from './path-confine.ts';
|
|
|
|
const DOTFILE = '.gbrain-source';
|
|
// Canonical SOURCE_ID_RE imported from `source-id.ts` (single source of truth).
|
|
// Re-exported below as `__testing.SOURCE_ID_RE` for legacy test imports.
|
|
// Two validator shapes per codex r2 P1-F:
|
|
// - `isValidSourceId(s)`: boolean — used by tiers that silently fall through
|
|
// on invalid input (dotfile tier 3, brain_default tier 5)
|
|
// - explicit throw — used by tiers that must reject loudly with a tailored
|
|
// message (explicit `--source` flag tier 1, GBRAIN_SOURCE env tier 2).
|
|
// Tier-specific messages are clearer than the generic assertValidSourceId
|
|
// error, so the throws stay inline.
|
|
|
|
function readDotfileWalk(startDir: string): string | null {
|
|
let dir = resolve(startDir);
|
|
// Guard against infinite loops on malformed paths.
|
|
for (let i = 0; i < 50; i++) {
|
|
const candidate = join(dir, DOTFILE);
|
|
// lstatSync (NOT statSync) so a planted symlink is seen here, not silently
|
|
// followed-then-trusted. Any stat error (ENOENT / permission) → skip this
|
|
// candidate and keep walking (fail-closed). On a multi-user host an
|
|
// attacker who can write a shared ancestor dir could otherwise plant a
|
|
// forged `.gbrain-source`; `isTrustedDotfile` refuses symlinks,
|
|
// foreign-owned, and world-writable files (#418).
|
|
let st: Stats | null = null;
|
|
try { st = lstatSync(candidate); } catch { st = null; }
|
|
if (st && isTrustedDotfile(st)) {
|
|
try {
|
|
const content = readFileSync(candidate, 'utf8').trim().split('\n')[0].trim();
|
|
// Silent-fallback tier per codex P1-F: invalid dotfile content
|
|
// (legacy ids with underscores, hand-edits with whitespace, etc.)
|
|
// falls through to the next tier instead of throwing. The CLI's
|
|
// explicit/env tiers throw; dotfiles are operator-edited and the
|
|
// forgiving behavior preserves the resolver's existing semantics.
|
|
if (isValidSourceId(content)) return content;
|
|
} catch {
|
|
// Unreadable dotfile — skip and keep walking.
|
|
}
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break; // reached filesystem root
|
|
dir = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the source id for a CLI command.
|
|
*
|
|
* @param engine Connected brain engine (for sources table lookups).
|
|
* @param explicit The --source <id> flag value, if the caller parsed one.
|
|
* @param cwd The working directory to walk for .gbrain-source. Defaults
|
|
* to process.cwd(). Exposed for testability.
|
|
* @returns The resolved source id. Falls back to 'default' if no other
|
|
* signal is present. Never returns null — every command must
|
|
* target exactly one default source.
|
|
* @throws If the resolved id doesn't correspond to a registered source
|
|
* (prevents silently writing to a nonexistent source and bloating
|
|
* pages with a dead FK).
|
|
*/
|
|
export async function resolveSourceId(
|
|
engine: BrainEngine,
|
|
explicit: string | null | undefined,
|
|
cwd: string = process.cwd(),
|
|
): Promise<string> {
|
|
// 1. Explicit flag wins.
|
|
if (explicit) {
|
|
if (!SOURCE_ID_RE.test(explicit)) {
|
|
throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
|
|
}
|
|
await assertSourceExists(engine, explicit);
|
|
return explicit;
|
|
}
|
|
|
|
// 2. Env var.
|
|
const env = process.env.GBRAIN_SOURCE;
|
|
if (env && env.length > 0) {
|
|
if (!SOURCE_ID_RE.test(env)) {
|
|
throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
|
|
}
|
|
await assertSourceExists(engine, env);
|
|
return env;
|
|
}
|
|
|
|
// 3. .gbrain-source dotfile walk-up.
|
|
const dotfile = readDotfileWalk(cwd);
|
|
if (dotfile) {
|
|
await assertSourceExists(engine, dotfile);
|
|
return dotfile;
|
|
}
|
|
|
|
// 4. Registered source whose local_path contains CWD.
|
|
// Uses longest-prefix match so nested-path configurations (e.g.
|
|
// gstack at ~/gstack + plans at ~/gstack/plans) pick the deepest.
|
|
const registered = await engine.executeRaw<{ id: string; local_path: string }>(
|
|
`SELECT id, local_path FROM sources WHERE local_path IS NOT NULL`,
|
|
);
|
|
// realpath BOTH sides (not bare resolve) so a symlinked CWD can't forge a
|
|
// prefix match against a registered local_path it doesn't really live under
|
|
// (codex #9). realpathOrResolve falls back to lexical resolve() for a stale
|
|
// registration whose path no longer exists. Resolving both sides keeps a
|
|
// legitimately symlinked vault matching — only one-sided symlinks break.
|
|
const cwdResolved = realpathOrResolve(cwd);
|
|
let best: { id: string; pathLen: number } | null = null;
|
|
for (const r of registered) {
|
|
const p = realpathOrResolve(r.local_path);
|
|
if (cwdResolved === p || cwdResolved.startsWith(p + '/')) {
|
|
if (!best || p.length > best.pathLen) {
|
|
best = { id: r.id, pathLen: p.length };
|
|
}
|
|
}
|
|
}
|
|
if (best) return best.id;
|
|
|
|
// 5. Brain-level default.
|
|
// Silent-fallback tier per codex P1-F: an invalid `sources.default` config
|
|
// value (operator hand-edit gone wrong, legacy underscore id) falls through
|
|
// to tier 6 rather than throwing. Resolver stays robust to bad config.
|
|
const globalDefault = await engine.getConfig('sources.default');
|
|
if (globalDefault && isValidSourceId(globalDefault)) {
|
|
await assertSourceExists(engine, globalDefault);
|
|
return globalDefault;
|
|
}
|
|
|
|
// 5.5. Single-non-default-source convenience (v0.41.13, #1434).
|
|
// When NO brain_default is set AND exactly one registered source has
|
|
// local_path set AND it isn't 'default', route there. This closes
|
|
// the "532 silent edit failures" bug class where users with a single
|
|
// Vault-mounted source ran `gbrain sync` without --source and routed
|
|
// to source_id='default' (which held 0 pages). Conservative: fires
|
|
// only when there's literally one option — multi-source brains still
|
|
// require explicit --source or sources.default.
|
|
//
|
|
// Placed AFTER brain_default per codex review: a user who explicitly
|
|
// set sources.default has stated intent, that wins over auto-routing.
|
|
const soleNonDefault = await pickSoleNonDefaultSource(engine);
|
|
if (soleNonDefault) return soleNonDefault;
|
|
|
|
// 6. Fallback: the seeded 'default' source. Always exists post-migration
|
|
// v16 so this is a safe terminal.
|
|
return 'default';
|
|
}
|
|
|
|
/**
|
|
* Returns the id of the SINGLE registered non-default source with a
|
|
* local_path, when exactly one such row exists. Returns null when:
|
|
* - zero non-default sources are registered (fresh install)
|
|
* - 2+ non-default sources are registered (ambiguous — user must pick)
|
|
* - the only non-default source has a NULL local_path (no on-disk shape)
|
|
* - the only registered source IS 'default'
|
|
*
|
|
* Excludes archived sources (`archived = false`) so a soft-deleted source
|
|
* doesn't auto-resolve. Shared by `resolveSourceId` and `resolveSourceWithTier`
|
|
* so the heuristic can't drift between the two entry points.
|
|
*/
|
|
async function pickSoleNonDefaultSource(engine: BrainEngine): Promise<string | null> {
|
|
// archived column was added in v34 (v0.26.5). Older brains may not have
|
|
// it — fall back to the un-archived query in that case via try/catch.
|
|
let rows: Array<{ id: string }>;
|
|
try {
|
|
rows = await engine.executeRaw<{ id: string }>(
|
|
`SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default' AND archived = false`,
|
|
);
|
|
} catch {
|
|
rows = await engine.executeRaw<{ id: string }>(
|
|
`SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default'`,
|
|
);
|
|
}
|
|
if (rows.length === 1) return rows[0].id;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Format the one-line stderr nudge that fires when source resolution falls
|
|
* through to the `sole_non_default` tier. Returns null when suppressed via
|
|
* `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1` (CI / scripted-pipeline ergonomics).
|
|
*
|
|
* Single source of truth so the wording stays consistent across every CLI
|
|
* dispatch site that fires the nudge (sync, import, extract, etc.). Callers
|
|
* print to stderr; this helper just builds the line.
|
|
*/
|
|
export function formatSoleNonDefaultNudge(sourceId: string): string | null {
|
|
if (process.env.GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE === '1') return null;
|
|
return `[gbrain] routing to source '${sourceId}' (sole non-default source registered; pass --source to override).`;
|
|
}
|
|
|
|
async function assertSourceExists(engine: BrainEngine, id: string): Promise<void> {
|
|
const rows = await engine.executeRaw<{ id: string }>(
|
|
`SELECT id FROM sources WHERE id = $1`,
|
|
[id],
|
|
);
|
|
if (rows.length === 0) {
|
|
throw new Error(
|
|
`Source "${id}" not found. Available sources: ` +
|
|
`run \`gbrain sources list\` to see registered sources, ` +
|
|
`or \`gbrain sources add ${id}\` to create it.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the local_path of the resolved source (per the resolveSourceId chain).
|
|
*
|
|
* Returns the on-disk brain repo path for the source the user is currently
|
|
* operating against. Used by `gbrain storage status` and `gbrain export
|
|
* --restore-only` to find the brain repo without raw SQL or bare try/catch.
|
|
*
|
|
* Resolution order:
|
|
* 1. `sources.local_path` for the resolved source id (multi-source v0.18+ path)
|
|
* 2. Legacy global `sync.repo_path` config key (pre-v0.18 default-source brains)
|
|
* 3. null
|
|
*
|
|
* @returns local_path string, or null if no path is configured anywhere.
|
|
* @throws If DB error occurs (does NOT silently swallow). Callers handle
|
|
* the null case to provide their own fallback (typically a hard error
|
|
* telling the user to pass --repo).
|
|
*/
|
|
export async function getDefaultSourcePath(
|
|
engine: BrainEngine,
|
|
cwd: string = process.cwd(),
|
|
): Promise<string | null> {
|
|
const sourceId = await resolveSourceId(engine, null, cwd);
|
|
const rows = await engine.executeRaw<{ local_path: string | null }>(
|
|
`SELECT local_path FROM sources WHERE id = $1`,
|
|
[sourceId],
|
|
);
|
|
if (rows[0]?.local_path) return rows[0].local_path;
|
|
|
|
// Legacy fallback: pre-v0.18 brains stored the repo path in the global
|
|
// config table under sync.repo_path. The sources table exists but its
|
|
// local_path is NULL for the seeded 'default' row. Fall back so storage
|
|
// tiering works without forcing a `gbrain sources add . --path .` migration.
|
|
const legacyPath = await engine.getConfig('sync.repo_path');
|
|
return legacyPath ?? null;
|
|
}
|
|
|
|
/**
|
|
* v0.37.7.0 — tier labels for `resolveSourceWithTier()`. Exported so
|
|
* `gbrain sources current --json` and downstream consumers share a
|
|
* canonical vocabulary instead of redefining strings inline.
|
|
*
|
|
* Order matches the 1-6 priority of `resolveSourceId()`.
|
|
*/
|
|
export const SOURCE_TIER_NAMES = [
|
|
'flag',
|
|
'env',
|
|
'dotfile',
|
|
'local_path',
|
|
'brain_default',
|
|
'sole_non_default',
|
|
'seed_default',
|
|
] as const;
|
|
export type SourceTier = typeof SOURCE_TIER_NAMES[number];
|
|
|
|
/**
|
|
* Same resolution chain as `resolveSourceId()`, but also returns
|
|
* WHICH tier won. Additive — does not duplicate the logic; runs the
|
|
* same six steps in the same order. Used by `gbrain sources current`
|
|
* so users can verify the resolved source AND the reason it resolved
|
|
* before destructive ops.
|
|
*
|
|
* @returns `{ source_id, tier, detail? }` where `detail` is an
|
|
* optional human-readable extra (e.g. the env-var name or
|
|
* the matched dotfile / local_path).
|
|
*/
|
|
export async function resolveSourceWithTier(
|
|
engine: BrainEngine,
|
|
explicit: string | null | undefined,
|
|
cwd: string = process.cwd(),
|
|
): Promise<{ source_id: string; tier: SourceTier; detail?: string }> {
|
|
// 1. Explicit flag wins.
|
|
if (explicit) {
|
|
if (!SOURCE_ID_RE.test(explicit)) {
|
|
throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
|
|
}
|
|
await assertSourceExists(engine, explicit);
|
|
return { source_id: explicit, tier: 'flag', detail: `--source ${explicit}` };
|
|
}
|
|
|
|
// 2. Env var.
|
|
const env = process.env.GBRAIN_SOURCE;
|
|
if (env && env.length > 0) {
|
|
if (!SOURCE_ID_RE.test(env)) {
|
|
throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
|
|
}
|
|
await assertSourceExists(engine, env);
|
|
return { source_id: env, tier: 'env', detail: `GBRAIN_SOURCE=${env}` };
|
|
}
|
|
|
|
// 3. .gbrain-source dotfile walk-up.
|
|
const dotfile = readDotfileWalk(cwd);
|
|
if (dotfile) {
|
|
await assertSourceExists(engine, dotfile);
|
|
return { source_id: dotfile, tier: 'dotfile', detail: `.gbrain-source` };
|
|
}
|
|
|
|
// 4. Registered source whose local_path contains CWD.
|
|
const registered = await engine.executeRaw<{ id: string; local_path: string }>(
|
|
`SELECT id, local_path FROM sources WHERE local_path IS NOT NULL`,
|
|
);
|
|
// realpath both sides — see the matching block in resolveSourceId (codex #9).
|
|
const cwdResolved = realpathOrResolve(cwd);
|
|
let best: { id: string; path: string; pathLen: number } | null = null;
|
|
for (const r of registered) {
|
|
const p = realpathOrResolve(r.local_path);
|
|
if (cwdResolved === p || cwdResolved.startsWith(p + '/')) {
|
|
if (!best || p.length > best.pathLen) {
|
|
best = { id: r.id, path: p, pathLen: p.length };
|
|
}
|
|
}
|
|
}
|
|
if (best) return { source_id: best.id, tier: 'local_path', detail: best.path };
|
|
|
|
// 5. Brain-level default. Silent-fallback (P1-F) like tier 5 in resolveSourceId.
|
|
const globalDefault = await engine.getConfig('sources.default');
|
|
if (globalDefault && isValidSourceId(globalDefault)) {
|
|
await assertSourceExists(engine, globalDefault);
|
|
return { source_id: globalDefault, tier: 'brain_default', detail: 'sources.default config' };
|
|
}
|
|
|
|
// 5.5. Single-non-default-source convenience (v0.41.13, #1434).
|
|
// See resolveSourceId for the design rationale. Same helper, same
|
|
// precedence (AFTER brain_default).
|
|
const soleNonDefault = await pickSoleNonDefaultSource(engine);
|
|
if (soleNonDefault) {
|
|
return {
|
|
source_id: soleNonDefault,
|
|
tier: 'sole_non_default',
|
|
detail: `only non-default registered source with local_path`,
|
|
};
|
|
}
|
|
|
|
// 6. Fallback: seeded 'default' source.
|
|
return { source_id: 'default', tier: 'seed_default' };
|
|
}
|
|
|
|
/** Exposed for tests. */
|
|
export const __testing = {
|
|
readDotfileWalk,
|
|
SOURCE_ID_RE,
|
|
};
|