mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +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>
144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
/**
|
|
* Brain resolution for CLI commands (v0.19.0, PR 0).
|
|
*
|
|
* Mirrors the 6-tier resolution pattern of v0.18.0's source-resolver.ts so
|
|
* agents learn one mental model. `--brain <id>` picks WHICH DATABASE to
|
|
* target (mounts + host). `--source <id>` (v0.18.0) picks WHICH REPO WITHIN
|
|
* the selected brain. Orthogonal axes.
|
|
*
|
|
* Resolution priority (highest first):
|
|
* 1. Explicit --brain <id> flag (caller passes this as `explicit`).
|
|
* 2. GBRAIN_BRAIN_ID env var.
|
|
* 3. .gbrain-mount dotfile in CWD or any ancestor directory.
|
|
* 4. Registered mount whose `path` contains CWD (longest-prefix match).
|
|
* 5. Brain-level default (future: ~/.gbrain/config.json `brains.default`).
|
|
* 6. Literal 'host' fallback (backward compat for every pre-v0.19 brain).
|
|
*
|
|
* Consumed by src/cli.ts, src/mcp/server.ts, and any future command that
|
|
* needs per-call brain selection. The subagent handler inherits the
|
|
* parent's brainId instead of re-running this resolver.
|
|
*/
|
|
|
|
import { readFileSync, lstatSync, type Stats } from 'fs';
|
|
import { join, dirname, resolve } from 'path';
|
|
import { HOST_BRAIN_ID, loadMounts, validateMountId, type MountEntry } from './brain-registry.ts';
|
|
import { isTrustedDotfile, realpathOrResolve } from './path-confine.ts';
|
|
|
|
const DOTFILE = '.gbrain-mount';
|
|
/** Same regex as brain-registry. Kept in sync. */
|
|
const BRAIN_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
|
|
/**
|
|
* Walk up from startDir looking for a .gbrain-mount dotfile. Returns the
|
|
* first valid id found, or null if none. Guards against filesystem-root
|
|
* infinite loops and malformed dotfiles (silent skip + continue walking).
|
|
*/
|
|
function readDotfileWalk(startDir: string): string | null {
|
|
let dir = resolve(startDir);
|
|
for (let i = 0; i < 50; i++) {
|
|
const candidate = join(dir, DOTFILE);
|
|
// lstatSync (NOT statSync) + isTrustedDotfile: refuse a symlink, foreign-
|
|
// owned, or world-writable `.gbrain-mount` planted by another user in a
|
|
// shared ancestor dir (same multi-user-host hijack as #418, applied to the
|
|
// brain axis). Any stat error → skip and keep walking (fail-closed).
|
|
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();
|
|
if (content === HOST_BRAIN_ID) return content;
|
|
if (BRAIN_ID_RE.test(content)) return content;
|
|
} catch {
|
|
// Unreadable dotfile — skip and keep walking.
|
|
}
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break; // filesystem root
|
|
dir = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Longest-prefix match: find the mount whose `path` contains `cwd`. */
|
|
function longestPathPrefixMount(mounts: MountEntry[], cwd: string): MountEntry | null {
|
|
// realpath both sides so a symlinked CWD can't forge a prefix match against a
|
|
// mount path it doesn't really live under (codex #9, brain-axis mirror of the
|
|
// source-resolver fix). Falls back to lexical resolve() for a stale mount path.
|
|
const cwdResolved = realpathOrResolve(cwd);
|
|
let best: { mount: MountEntry; pathLen: number } | null = null;
|
|
for (const m of mounts) {
|
|
if (m.enabled === false) continue;
|
|
const p = realpathOrResolve(m.path);
|
|
if (cwdResolved === p || cwdResolved.startsWith(p + '/')) {
|
|
if (!best || p.length > best.pathLen) {
|
|
best = { mount: m, pathLen: p.length };
|
|
}
|
|
}
|
|
}
|
|
return best ? best.mount : null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the brain id for a CLI command. Never returns null — every call
|
|
* targets exactly one brain, with 'host' as the guaranteed terminal fallback.
|
|
*
|
|
* @param explicit The --brain <id> flag value, if the caller parsed one.
|
|
* @param cwd Working directory for .gbrain-mount walk. Defaults to process.cwd().
|
|
* @param mountsLoader Override for testability. Returns the list of enabled
|
|
* mounts. Defaults to reading ~/.gbrain/mounts.json.
|
|
* @returns The resolved brain id. Always truthy. Either 'host' or a valid mount id.
|
|
*
|
|
* Does NOT validate that the id points at a registered mount — that is
|
|
* BrainRegistry.getBrain's job. This resolver answers "which id is the
|
|
* caller asking for?"; the registry answers "does that id exist?".
|
|
*/
|
|
export function resolveBrainId(
|
|
explicit: string | null | undefined,
|
|
cwd: string = process.cwd(),
|
|
mountsLoader: () => MountEntry[] = loadMounts,
|
|
): string {
|
|
// 1. Explicit flag wins.
|
|
if (explicit) {
|
|
if (explicit === HOST_BRAIN_ID) return HOST_BRAIN_ID;
|
|
validateMountId(explicit, '--brain value');
|
|
return explicit;
|
|
}
|
|
|
|
// 2. Env var.
|
|
const env = process.env.GBRAIN_BRAIN_ID;
|
|
if (env && env.length > 0) {
|
|
if (env === HOST_BRAIN_ID) return HOST_BRAIN_ID;
|
|
validateMountId(env, 'GBRAIN_BRAIN_ID');
|
|
return env;
|
|
}
|
|
|
|
// 3. Dotfile walk-up.
|
|
const dotfile = readDotfileWalk(cwd);
|
|
if (dotfile) return dotfile;
|
|
|
|
// 4. Registered mount path-prefix.
|
|
let mounts: MountEntry[] = [];
|
|
try {
|
|
mounts = mountsLoader();
|
|
} catch {
|
|
// mounts.json corruption shouldn't break brain resolution — fall through
|
|
// to 'host'. BrainRegistry.getBrain will throw the actionable error if
|
|
// the caller actually tried to touch a mount.
|
|
mounts = [];
|
|
}
|
|
const matched = longestPathPrefixMount(mounts, cwd);
|
|
if (matched) return matched.id;
|
|
|
|
// 5. Brain-level default — v2. Not wired in PR 0.
|
|
// 6. Fallback.
|
|
return HOST_BRAIN_ID;
|
|
}
|
|
|
|
/** Exposed for tests. */
|
|
export const __testing = {
|
|
readDotfileWalk,
|
|
longestPathPrefixMount,
|
|
DOTFILE,
|
|
BRAIN_ID_RE,
|
|
};
|