mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 17:02:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2fc3df9fe | ||
|
|
421f892703 |
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) ────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user