Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 e2fc3df9fe fix(git-remote): resolve relative pull origins against the repo dir, matching git -C
isTrustedLocalOrigin realpath'd a relative origin against process.cwd(),
but `git -C repoPath pull` resolves relative file remotes against the
repo dir — the containment guard was checking a different path than the
one git actually pulls. Resolve relative origins against repoPath before
the realpath containment check. Regression test mutation-checked (fails
on the cwd-based resolution).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:55:24 -07:00
421f892703 fix(security): three singleton P0s — whoami over stdio, auto_link_allow_remote opt-in, sync file-transport for trusted durability origin
Takeover of three community PRs, each repaired per adversarial review:

- #2376 (whoami/stdio): thread an MCP transport tag (stdio|http) through
  dispatch so whoami over the stdio pipe stops fail-closing with
  unknown_transport. Repair: return {transport:'stdio', scopes:[]} instead
  of the PR's {transport:'local', ...} — 'local' stays reserved for the
  trusted ctx.remote===false CLI surface so untrusted stdio callers can
  never masquerade as it. Auth-less HTTP still throws (fail-closed).

- #2478 (auto_link_allow_remote): default-deny opt-in config flag that lets
  remote MCP put_page callers run auto-link/timeline post-hooks on
  single-user brains with a trusted client. Repair: stripped the smuggled
  think/gather excerptLen 600→3000 behavior change; registered the key in
  KNOWN_CONFIG_KEYS; added the promised unit tests.

- #2709 (sync file transport): pullRepo gains a gated allowLocalFileOrigin
  opt-in that relaxes protocol.file.allow only when the resolved pull
  origin is a local path realpath-contained under GBRAIN_HOME (the
  durability bare repo). Repair: rebased the stale sync.ts hunk onto the
  current pullRepo(gitContextRoot) call site; network remotes and
  out-of-root local origins keep full SSRF hardening.

Co-authored-by: Facundo-Moreno-Dev <Facundo-Moreno-Dev@users.noreply.github.com>
Co-authored-by: onchito-walks <onchito-walks@users.noreply.github.com>
Co-authored-by: hunterbohm <hunterbohm@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:36:17 -07:00
12 changed files with 387 additions and 13 deletions
+1
View File
@@ -1768,6 +1768,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
try {
toolResult = await dispatchToolCall(engine, name, params as Record<string, unknown> | undefined, {
remote: true,
transport: 'http',
takesHoldersAllowList: tokenAllowList,
sourceId: tokenSourceId,
metaHook: getBrainHotMemoryMeta,
+9 -1
View File
@@ -1960,7 +1960,15 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// timeout (ETIMEDOUT / SIGTERM on err.cause) from ordinary pull
// failure. Pull applies to the whole git repo (gitContextRoot), not
// just the sync scope — git has no per-subdir pull.
pullRepo(gitContextRoot);
//
// #2709: allow the file transport ONLY for the trusted local durability
// origin (the gbrain-managed bare repo under GBRAIN_HOME). pullRepo
// re-verifies via realpath containment that the resolved origin is a
// local path under the trusted root before relaxing, so federated
// https sources and arbitrary local origins keep full SSRF hardening.
// Without this, a durability-hardened brain fails every sync.git_pull
// with "transport 'file' not allowed".
pullRepo(gitContextRoot, { allowLocalFileOrigin: true });
serr(`[gbrain phase] sync.git_pull done ${Date.now() - _t0}ms`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
+4
View File
@@ -963,6 +963,10 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
// Link resolution (issue #972)
'link_resolution',
'link_resolution.global_basename',
// #2478: opt-in — let remote (MCP) put_page callers run auto-link/timeline
// post-hooks. Default FALSE (fail-closed); safe only for single-user brains
// whose MCP client is trusted. See isAutoLinkRemoteAllowed.
'auto_link_allow_remote',
// Spend controls (v0.42.42.0, issue #2139). Previously `--force`-only — the
// operator had to discover these by reading source. Registered so `config
// set` accepts them directly. See docs/operations/spend-controls.md.
+98 -5
View File
@@ -15,8 +15,9 @@
* stderr warning at use site is the operator's signal.
*/
import { execFileSync } from 'child_process';
import { lstatSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { lstatSync, existsSync, readdirSync, realpathSync } from 'fs';
import { isAbsolute, join, sep } from 'path';
import { fileURLToPath } from 'url';
import { isInternalUrl } from './url-safety.ts';
/**
@@ -47,6 +48,21 @@ export const GIT_SSRF_FLAGS = [
'-c', 'protocol.ext.allow=never',
] as const;
/**
* Global git config flags for a TRUSTED LOCAL-FILE origin (the brain's own bare
* durability repo under GBRAIN_HOME). A local filesystem remote is not an SSRF
* vector — no DNS, no network, no redirects — so the file transport is safe
* here. Redirect + external-helper hardening are KEPT; only protocol.file.allow
* is relaxed, and submodules stay disabled via GIT_SSRF_SUBCOMMAND_FLAGS so
* `always` cannot open a recursive file surface. Used ONLY via pullRepo's
* gated allowLocalFileOrigin path.
*/
export const GIT_SSRF_FLAGS_LOCAL = [
'-c', 'http.followRedirects=false',
'-c', 'protocol.file.allow=always',
'-c', 'protocol.ext.allow=never',
] as const;
/**
* Subcommand-level flags. Spread AFTER the subcommand verb (clone/pull).
* - --no-recurse-submodules: .gitmodules cannot become a second fetch surface
@@ -214,9 +230,86 @@ export function cloneRepo(url: string, destDir: string, opts: CloneOpts = {}): v
}
}
/** Pull a repo with --ff-only and the same SSRF-defensive flags as cloneRepo. */
export function pullRepo(repoPath: string, opts: { timeoutMs?: number } = {}): void {
const args: string[] = ['-C', repoPath, ...GIT_SSRF_FLAGS, 'pull', ...GIT_SSRF_SUBCOMMAND_FLAGS, '--ff-only'];
/**
* True when a git remote URL uses the local-file transport (no network): a
* `file://` URL or a bare filesystem path. Anything with a network scheme
* (http/https/ssh/git://) or scp-like `[user@]host:path` is remote.
*/
export function isLocalFileRemote(url: string): boolean {
if (!url) return false;
if (url.startsWith('file://')) return true;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) return false;
const colon = url.indexOf(':');
const slash = url.indexOf('/');
if (colon !== -1 && (slash === -1 || colon < slash)) return false; // scp-like host:path
return true;
}
/**
* Resolve the URL of the remote `git pull` would use for repoPath's current
* branch (its configured upstream remote, defaulting to `origin`), or null.
*/
function pullOriginUrl(repoPath: string): string | null {
const run = (args: string[]): string | null => {
try {
return execFileSync('git', ['-C', repoPath, ...args], {
stdio: ['ignore', 'pipe', 'ignore'],
env: { ...process.env, ...GIT_ENV },
}).toString().trim() || null;
} catch {
return null;
}
};
const branch = run(['symbolic-ref', '--short', '-q', 'HEAD']);
const remote = (branch && run(['config', '--get', `branch.${branch}.remote`])) || 'origin';
return run(['remote', 'get-url', remote]);
}
/**
* True only when repoPath's pull remote is a local-file path physically
* contained under trustedRoot (the gbrain-managed durability bare repo under
* GBRAIN_HOME). Gates the file-transport relaxation: a federated source
* (https-only via parseRemoteUrl) or any local path outside the trusted root
* never qualifies, so hardening cannot be bypassed by configuring a local origin.
*/
function isTrustedLocalOrigin(repoPath: string, trustedRoot: string): boolean {
const url = pullOriginUrl(repoPath);
if (!url || !isLocalFileRemote(url)) return false;
// Resolve a relative origin against the repo, matching what `git -C repoPath
// pull` actually pulls (git resolves relative file remotes against the repo
// dir, NOT this process's cwd) — the guard must check the same path git uses.
const raw = url.startsWith('file://') ? fileURLToPath(url) : url;
const originPath = isAbsolute(raw) ? raw : join(repoPath, raw);
try {
const real = realpathSync(originPath);
const root = realpathSync(trustedRoot);
return real === root || real.startsWith(root.endsWith(sep) ? root : root + sep);
} catch {
return false;
}
}
/**
* Pull a repo with --ff-only and SSRF-defensive flags. Strict by default
* (protocol.file.allow=never), matching cloneRepo.
*
* `allowLocalFileOrigin` opts into the file transport ONLY for a trusted local
* bare repo — the gbrain durability origin under GBRAIN_HOME (default) or
* `trustedOriginRoot`. Even then, pullRepo re-verifies the resolved origin is a
* local path contained under that root before relaxing, so network remotes keep
* full hardening. Needed because the brain's own durability origin is a local
* bare repo (file transport), which the strict flags reject with
* "fatal: transport 'file' not allowed".
*/
export function pullRepo(
repoPath: string,
opts: { timeoutMs?: number; allowLocalFileOrigin?: boolean; trustedOriginRoot?: string } = {},
): void {
const trustedRoot = opts.trustedOriginRoot ?? (process.env.GBRAIN_HOME || join(process.env.HOME || '', '.gbrain'));
const flags = opts.allowLocalFileOrigin && isTrustedLocalOrigin(repoPath, trustedRoot)
? GIT_SSRF_FLAGS_LOCAL
: GIT_SSRF_FLAGS;
const args: string[] = ['-C', repoPath, ...flags, 'pull', ...GIT_SSRF_SUBCOMMAND_FLAGS, '--ff-only'];
try {
execFileSync('git', args, {
stdio: ['ignore', 'pipe', 'pipe'],
+23
View File
@@ -1225,6 +1225,29 @@ export async function isAutoTimelineEnabled(engine: BrainEngine): Promise<boolea
return !['false', '0', 'no', 'off'].includes(normalized);
}
/**
* Read the auto_link_allow_remote config flag. Defaults to FALSE
* (remote callers are not trusted for auto-link/timeline).
*
* When TRUE: auto-link and auto-timeline post-hooks fire even for
* remote (MCP) callers. This is safe for single-user personal brains
* where the MCP client is trusted (e.g., a local agent talking to a
* local GBrain instance). Multi-tenant deployments should leave this
* FALSE — untrusted MCP clients can plant link-targeted pages to
* manipulate search ranking via the backlink boost.
*
* Note the inverted default vs isAutoLinkEnabled: this is an OPT-IN
* (['true','1','yes','on'] enable), everything else — including
* null/unset — stays fail-closed. `config set` is CLI-only, so a
* remote caller cannot flip it for itself.
*/
export async function isAutoLinkRemoteAllowed(engine: BrainEngine): Promise<boolean> {
const val = await engine.getConfig('auto_link_allow_remote');
if (val == null) return false; // default deny
const normalized = val.trim().toLowerCase();
return ['true', '1', 'yes', 'on'].includes(normalized);
}
/**
* Read the `link_resolution.global_basename` config flag. Defaults to
* FALSE (opt-in only; existing brains keep ancestor-walk resolution).
+36 -6
View File
@@ -16,7 +16,7 @@ import { expandQuery } from './search/expansion.ts';
import { dedupResults } from './search/dedup.ts';
import { captureEvalCandidate, isEvalCaptureEnabled, isEvalScrubEnabled } from './eval-capture.ts';
import type { HybridSearchMeta } from './types.ts';
import { extractPageLinks, isAutoLinkEnabled, isAutoTimelineEnabled, isGlobalBasenameEnabled, parseTimelineEntries, makeResolver, type UnresolvedFrontmatterRef } from './link-extraction.ts';
import { extractPageLinks, isAutoLinkEnabled, isAutoLinkRemoteAllowed, isAutoTimelineEnabled, isGlobalBasenameEnabled, parseTimelineEntries, makeResolver, type UnresolvedFrontmatterRef } from './link-extraction.ts';
import { isFactsBackstopEligible } from './facts/eligibility.ts';
import { stripTakesFence } from './takes-fence.ts';
import { stripFactsFence } from './facts-fence.ts';
@@ -332,6 +332,20 @@ export interface OperationContext {
* remote/untrusted (defense in depth in case the type is bypassed via cast).
*/
remote: boolean;
/**
* Which MCP transport delivered this call, when it came over MCP.
* `'stdio'` is the local pipe (`gbrain serve`) — remote/untrusted by the
* trust boundary, but auth-less BY DESIGN (no per-token auth on a local
* pipe; see src/mcp/server.ts). `'http'` is the bearer/OAuth HTTP transport,
* which threads `auth`. Unset for the trusted local CLI path.
*
* whoami uses this to distinguish "stdio pipe, legitimately auth-less"
* (report transport: 'stdio') from "HTTP transport that dropped auth"
* (a real bug → fail-closed unknown_transport). Do NOT use this to grant
* trust — `remote` remains the trust discriminator, and 'local' stays
* reserved for ctx.remote === false.
*/
transport?: 'stdio' | 'http';
/**
* Subagent runtime context (v0.16+). Set by the subagent tool dispatcher when
* dispatching an op as a tool call from an LLM loop. Used to enforce per-op
@@ -965,7 +979,11 @@ const put_page: Operation = {
const trustedWorkspace = ctx.viaSubagent === true
&& Array.isArray(ctx.allowedSlugPrefixes)
&& ctx.allowedSlugPrefixes.length > 0;
if (ctx.remote !== false && !trustedWorkspace) {
// #2478: auto_link_allow_remote is the operator OPT-IN that re-enables the
// post-hooks for remote callers (single-user brains with a trusted MCP
// client). Default deny; `config set` is CLI-only so a remote caller
// cannot flip it for itself.
if (ctx.remote !== false && !trustedWorkspace && !(await isAutoLinkRemoteAllowed(ctx.engine))) {
autoLinks = { skipped: 'remote' };
autoTimeline = { skipped: 'remote' };
} else if (result.parsedPage) {
@@ -3712,10 +3730,12 @@ const whoami: Operation = {
description:
'Introspect the calling identity. Returns one of three transport shapes: ' +
'{transport: "oauth", client_id, client_name, scopes, expires_at}, ' +
'{transport: "legacy", token_name, scopes, expires_at: null}, or ' +
'{transport: "local", scopes: []}. Throws unknown_transport when the ' +
'context is ambiguous (remote=true without auth) — fail-closed posture ' +
'mirroring the v0.26.9 trust-boundary contract.',
'{transport: "legacy", token_name, scopes, expires_at: null}, ' +
'{transport: "local", scopes: []} (trusted local CLI only), or ' +
'{transport: "stdio", scopes: []} (auth-less stdio MCP pipe — untrusted, ' +
'no privileges). Throws unknown_transport when the ' +
'context is ambiguous (remote=true without auth over HTTP) — fail-closed ' +
'posture mirroring the v0.26.9 trust-boundary contract.',
params: {},
scope: 'read',
handler: async (ctx) => {
@@ -3727,6 +3747,16 @@ const whoami: Operation = {
if (ctx.remote === false) {
return { transport: 'local', scopes: [] };
}
// The stdio MCP pipe (`gbrain serve`) is remote/untrusted by the trust
// boundary but carries no per-token auth by design (local pipe — see
// src/mcp/server.ts). That is NOT the "transport dropped auth" bug; it is
// the expected shape for stdio, so whoami reports it instead of throwing.
// Deliberately NOT `transport: 'local'` — that shape is the trusted
// local-CLI marker (ctx.remote === false) that clients special-case;
// an untrusted stdio caller must stay distinguishable from it.
if (ctx.transport === 'stdio') {
return { transport: 'stdio', scopes: [] };
}
if (!ctx.auth) {
throw new OperationError(
'unknown_transport',
+9
View File
@@ -30,6 +30,14 @@ export interface ToolResult {
export interface DispatchOpts {
/** Defaults to true (remote/untrusted). Local CLI callers (`gbrain call`) pass false. */
remote?: boolean;
/**
* Which MCP transport delivered this call. `'stdio'` set by the stdio
* server (local pipe, no per-token auth), `'http'` by the HTTP transports.
* Threaded into OperationContext.transport so whoami can distinguish a
* legitimately auth-less stdio pipe from an HTTP transport that dropped
* auth. Never used to grant trust — `remote` stays the discriminator.
*/
transport?: 'stdio' | 'http';
/** Override the default stderr logger (e.g. CLI uses console.* directly). */
logger?: OperationContext['logger'];
/**
@@ -210,6 +218,7 @@ export function buildOperationContext(
// this fallback covers code paths that historically passed undefined.
sourceId: opts.sourceId ?? 'default',
auth: opts.auth,
transport: opts.transport,
};
}
+1
View File
@@ -381,6 +381,7 @@ export async function startHttpTransport(opts: HttpTransportOptions) {
// path defaults to 'default' per AuthResult.sourceId above.
const result = await dispatchToolCall(engine, toolName, args, {
remote: true,
transport: 'http',
takesHoldersAllowList: auth.takesHoldersAllowList,
sourceId: auth.sourceId,
// #1336: thread the token's federated_read grant so read ops scope
+4
View File
@@ -42,6 +42,10 @@ export async function startMcpServer(engine: BrainEngine) {
// `gbrain call <op>` (sets remote=false in src/cli.ts).
return dispatchToolCall(engine, name, params, {
remote: true,
// Identify the stdio local pipe so whoami reports an unprivileged
// stdio identity instead of fail-closing on the by-design absence of
// per-token auth. Does NOT loosen trust — remote stays true.
transport: 'stdio',
takesHoldersAllowList: ['world'],
// v0.31: source defaults to 'default' for stdio (no per-token scope).
// Operators who want a different source on stdio MCP should set
+123 -1
View File
@@ -4,6 +4,7 @@ import { join } from 'path';
import { tmpdir } from 'os';
import {
GIT_SSRF_FLAGS,
GIT_SSRF_FLAGS_LOCAL,
GIT_SSRF_SUBCOMMAND_FLAGS,
parseRemoteUrl,
RemoteUrlError,
@@ -23,6 +24,7 @@ import { withEnv } from './helpers/with-env.ts';
const FAKE_GIT_DIR = join(tmpdir(), `gbrain-git-remote-test-${process.pid}`);
const FAKE_GIT_LOG = join(FAKE_GIT_DIR, 'argv.log');
const FAKE_GIT_MODE = join(FAKE_GIT_DIR, 'mode');
const FAKE_GIT_ORIGIN = join(FAKE_GIT_DIR, 'origin-url');
function writeFakeGit(): void {
mkdirSync(FAKE_GIT_DIR, { recursive: true });
@@ -30,6 +32,8 @@ function writeFakeGit(): void {
writeFileSync(FAKE_GIT_MODE, 'ok');
// Per-invocation argv goes into argv.log (one JSON array per line).
writeFileSync(FAKE_GIT_LOG, '');
// Origin URL emitted by fake-git for `remote get-url` in local-origin mode.
writeFileSync(FAKE_GIT_ORIGIN, '');
const script = `#!/usr/bin/env bash
# Fake git for git-remote.test.ts
{ printf '['; for arg in "$@"; do printf '%s,' "$(printf '%s' "$arg" | jq -Rs .)"; done; printf 'null]\\n'; } >> "${FAKE_GIT_LOG}"
@@ -38,6 +42,13 @@ case "$mode" in
fail) exit 1 ;;
url-drift) echo "https://github.com/different/url" ;;
url-match) echo "https://github.com/expected/url" ;;
local-origin)
case " $* " in
*" symbolic-ref "*) echo "main" ;;
*" get-url "*) cat "${FAKE_GIT_ORIGIN}" 2>/dev/null || true ;;
*) : ;;
esac
;;
*) ;;
esac
exit 0
@@ -62,10 +73,14 @@ function clearArgvLog(): void {
writeFileSync(FAKE_GIT_LOG, '');
}
function setMode(mode: 'ok' | 'fail' | 'url-drift' | 'url-match'): void {
function setMode(mode: 'ok' | 'fail' | 'url-drift' | 'url-match' | 'local-origin'): void {
writeFileSync(FAKE_GIT_MODE, mode);
}
function setOrigin(url: string): void {
writeFileSync(FAKE_GIT_ORIGIN, url);
}
beforeAll(() => writeFakeGit());
afterAll(() => rmSync(FAKE_GIT_DIR, { recursive: true, force: true }));
beforeEach(() => {
@@ -350,6 +365,113 @@ describe('pullRepo', () => {
});
rmSync(repo, { recursive: true, force: true });
});
// -------------------------------------------------------------------------
// local-file origin gating (#2709, security-sensitive). pullRepo is strict
// by default and only relaxes protocol.file.allow to `always` when opted-in
// AND the resolved pull origin is a local-file path physically contained
// under the trusted root. We observe the recorded argv's `-c` block
// (between the `-C <repo>` prefix and the `pull` verb).
// -------------------------------------------------------------------------
test('default (no opts): -c block is strict — protocol.file.allow=never, origin never resolved', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-strict-default');
mkdirSync(repo, { recursive: true });
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo);
});
const calls = readArgvLog();
// Strict path short-circuits origin resolution: exactly one git call.
expect(calls.length).toBe(1);
const argv = calls[0];
const flagBlock = argv.slice(2, argv.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
expect(flagBlock).toContain('protocol.file.allow=never');
expect(flagBlock).not.toContain('protocol.file.allow=always');
rmSync(repo, { recursive: true, force: true });
});
test('allowLocalFileOrigin + local origin under trusted root: -c block relaxes to GIT_SSRF_FLAGS_LOCAL (protocol.file.allow=always)', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-local-trusted');
mkdirSync(repo, { recursive: true });
// A REAL local bare-repo dir as origin, physically under the trusted root
// (realpathSync requires both to exist for the containment check).
const originPath = join(FAKE_GIT_DIR, 'local-origin.git');
mkdirSync(originPath, { recursive: true });
setMode('local-origin');
setOrigin(originPath);
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
});
const pullCall = readArgvLog().find(c => c.includes('pull'));
expect(pullCall).toBeDefined();
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS_LOCAL]);
expect(flagBlock).toContain('protocol.file.allow=always');
expect(flagBlock).not.toContain('protocol.file.allow=never');
rmSync(repo, { recursive: true, force: true });
rmSync(originPath, { recursive: true, force: true });
});
test('allowLocalFileOrigin + local origin OUTSIDE trusted root: -c block stays strict (protocol.file.allow=never)', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-local-outside');
mkdirSync(repo, { recursive: true });
const originPath = join(FAKE_GIT_DIR, 'local-origin.git');
mkdirSync(originPath, { recursive: true });
// A real trusted root that does NOT contain originPath.
const outsideRoot = join(tmpdir(), `gbrain-outside-root-${process.pid}`);
mkdirSync(outsideRoot, { recursive: true });
setMode('local-origin');
setOrigin(originPath);
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: outsideRoot });
});
const pullCall = readArgvLog().find(c => c.includes('pull'));
expect(pullCall).toBeDefined();
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
expect(flagBlock).toContain('protocol.file.allow=never');
expect(flagBlock).not.toContain('protocol.file.allow=always');
rmSync(repo, { recursive: true, force: true });
rmSync(originPath, { recursive: true, force: true });
rmSync(outsideRoot, { recursive: true, force: true });
});
test('allowLocalFileOrigin + https origin: -c block stays strict (network remote never relaxed)', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-https-origin');
mkdirSync(repo, { recursive: true });
setMode('local-origin');
setOrigin('https://github.com/example/repo.git');
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
});
const pullCall = readArgvLog().find(c => c.includes('pull'));
expect(pullCall).toBeDefined();
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
expect(flagBlock).toContain('protocol.file.allow=never');
expect(flagBlock).not.toContain('protocol.file.allow=always');
rmSync(repo, { recursive: true, force: true });
});
test('relative origin resolves against the repo dir (matching `git -C`), not process.cwd()', async () => {
// git resolves a relative file remote against the repo dir (`git -C repo
// pull`), so the containment guard must too. The relative name exists
// under repo (inside the trusted root) but NOT under process.cwd() — a
// cwd-based guard would realpath-fail and stay strict.
const repo = join(FAKE_GIT_DIR, 'pull-rel-origin');
mkdirSync(join(repo, 'rel-origin.git'), { recursive: true });
setMode('local-origin');
setOrigin('rel-origin.git');
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
});
const pullCall = readArgvLog().find(c => c.includes('pull'));
expect(pullCall).toBeDefined();
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS_LOCAL]);
rmSync(repo, { recursive: true, force: true });
});
});
// ---------------------------------------------------------------------------
+29
View File
@@ -8,6 +8,7 @@ import {
makeResolver,
parseTimelineEntries,
isAutoLinkEnabled,
isAutoLinkRemoteAllowed,
FRONTMATTER_LINK_MAP,
type SlugResolver,
} from '../src/core/link-extraction.ts';
@@ -782,6 +783,34 @@ describe('isAutoLinkEnabled', () => {
});
});
// ─── isAutoLinkRemoteAllowed (#2478) — inverted default: opt-in, fail-closed ──
describe('isAutoLinkRemoteAllowed', () => {
test('null/unset -> false (default deny for remote callers)', async () => {
const engine = makeFakeEngine(new Map());
expect(await isAutoLinkRemoteAllowed(engine)).toBe(false);
});
test('"true" -> true (operator opt-in)', async () => {
const engine = makeFakeEngine(new Map([['auto_link_allow_remote', 'true']]));
expect(await isAutoLinkRemoteAllowed(engine)).toBe(true);
});
test('"1", "yes", "on" -> true; case/whitespace tolerant', async () => {
for (const v of ['1', 'yes', 'on', ' TRUE ']) {
const engine = makeFakeEngine(new Map([['auto_link_allow_remote', v]]));
expect(await isAutoLinkRemoteAllowed(engine)).toBe(true);
}
});
test('"false" and garbage -> false (fail-closed, NOT fail-safe-to-on)', async () => {
for (const v of ['false', '0', 'no', 'off', 'garbage', '']) {
const engine = makeFakeEngine(new Map([['auto_link_allow_remote', v]]));
expect(await isAutoLinkRemoteAllowed(engine)).toBe(false);
}
});
});
// ─── Frontmatter link extraction (v0.13) ────────────────────────
/**
+50
View File
@@ -94,6 +94,44 @@ describe('whoami op contract', () => {
expect(result.expires_at).toBeNull();
});
// stdio MCP pipe (`gbrain serve`): remote/untrusted by the trust boundary
// but auth-less BY DESIGN (a local pipe has no per-token auth). This is NOT
// the "transport dropped auth" bug below — it is the expected shape for
// stdio, so whoami reports an unprivileged stdio identity instead of
// throwing. Regression guard for the long-standing unknown_transport thrown
// on every stdio whoami call. Crucially the shape is 'stdio', NOT 'local':
// 'local' is the trusted-CLI marker clients special-case, and an untrusted
// stdio caller must never be indistinguishable from it.
test('stdio transport returns stdio identity with empty scopes (no auth by design)', async () => {
const result = (await whoami.handler(
ctxWith({ remote: true, transport: 'stdio', auth: undefined }),
{},
)) as any;
expect(result.transport).toBe('stdio');
expect(result.scopes).toEqual([]);
});
test('stdio transport never reports the trusted local shape, even with a stale auth blob', async () => {
// Empty scopes regardless of auth: the stdio pipe must never surface
// privileges, and must never masquerade as the trusted 'local' shape.
const result = (await whoami.handler(
ctxWith({
remote: true,
transport: 'stdio',
auth: {
token: 'x',
clientId: 'gbrain_cl_123',
scopes: ['admin'],
expiresAt: 999999,
} as AuthInfo,
}),
{},
)) as any;
expect(result.transport).toBe('stdio');
expect(result.transport).not.toBe('local');
expect(result.scopes).toEqual([]);
});
// Q3: ambiguous transport — fail-closed. The footgun this guards against
// is a future transport that lands without threading auth, where a buggy
// caller might trust whoami's output to gate sensitive ops.
@@ -107,6 +145,18 @@ describe('whoami op contract', () => {
}
});
test('unknown_transport still throws for an auth-less HTTP transport (fail-closed preserved)', async () => {
// The stdio relaxation must NOT extend to HTTP: an HTTP transport that
// drops auth is a genuine bug and stays fail-closed.
try {
await whoami.handler(ctxWith({ remote: true, transport: 'http', auth: undefined }), {});
throw new Error('expected throw');
} catch (e) {
expect(e).toBeInstanceOf(OperationError);
expect((e as OperationError).message).toMatch(/unknown_transport|did not thread/);
}
});
test('unknown_transport throws when remote is undefined (cast bypass guard)', async () => {
// F7b contract: ctx.remote is REQUIRED. If a caller widens the type to
// Partial<> and passes through undefined, whoami should treat it as