mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(security): scope cross-source reads to the caller grant; close get_page exact-path leak One shared resolveRequestedScope() routes every source-scoped read op (query, code_callers/callees, search_by_image, code_blast/flow, get_page) through a single fail-closed trust+grant ladder: a remote caller's __all__ collapses to its granted sources (never the whole brain) and an explicit out-of-grant source_id is rejected. get_page's exact-match path now honors a federated grant via getPage(sourceIds[]) in both engines. Legacy bearer tokens carry their stored permissions.source_id grant (bounded, never widened). Also retries getConfig on transient connection loss. Closes #1924, #1371, #1393, #1336, #1603. * fix(ingest): non-string frontmatter no longer aborts lint/sync; embed/hook/catalog papercuts Parser coerces a non-string title to a string and falls back to inference for slug/type (never fabricating a "123" slug), with a lint NON_STRING_FIELD finding surfacing the malformed frontmatter; a defensive guard in content-sanity stops a non-string title from crashing the whole lint/sync run brain-wide. Plus: embed --catch-up no longer arms the overflowed 32-bit budget timer (and surfaces unembeddable chunks); the frontmatter pre-commit hook ships a correct .md/.mdx regex; and the skill catalog parses YAML block-scalar descriptions. Closes #1883, #1658, #1556, #1948, #1946, #1840, #1711. * v0.42.37.0 fix(security,ingest): source-isolation grant enforcement + non-string frontmatter guard + papercuts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add NON_STRING_FIELD frontmatter validation class to docs for v0.42.37.0 The v0.42.37.0 non-string-frontmatter fix added an eighth validation class (NON_STRING_FIELD / lint code frontmatter-non-string-field). Update the two current-state docs that enumerate the validation classes: - skills/frontmatter-guard/SKILL.md (seven->eight + table row) - docs/integrations/pre-commit.md (seven->eight + table row) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
5.8 KiB
TypeScript
133 lines
5.8 KiB
TypeScript
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { execFileSync } from 'child_process';
|
|
import { installHook, uninstallHook } from '../src/commands/frontmatter-install-hook.ts';
|
|
|
|
function gitInit(dir: string) {
|
|
execFileSync('git', ['init', '-q', dir]);
|
|
execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@example.com']);
|
|
execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']);
|
|
}
|
|
|
|
describe('frontmatter install-hook (B13)', () => {
|
|
let tmp: string;
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), 'fm-hook-'));
|
|
gitInit(tmp);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('installHook writes executable .githooks/pre-commit and sets core.hooksPath', () => {
|
|
const result = installHook(tmp, false);
|
|
expect(result).toBe('installed');
|
|
const hookPath = join(tmp, '.githooks', 'pre-commit');
|
|
expect(existsSync(hookPath)).toBe(true);
|
|
const content = readFileSync(hookPath, 'utf8');
|
|
expect(content).toContain('gbrain frontmatter');
|
|
expect(content).toContain('git diff --cached');
|
|
// installHook's contract is "set core.hooksPath unless it's already set
|
|
// elsewhere". Test BOTH branches deterministically by reading the local
|
|
// scope only: clean CI → local should be `.githooks`; developer with a
|
|
// global core.hooksPath (e.g. dotfiles → ~/.config/git/hooks) → local
|
|
// should be empty because installHook correctly skipped clobbering.
|
|
// Reading via `--get` without `--local` falls back to global scope when
|
|
// local is unset, which made this test environmentally fragile.
|
|
let globalHooksPath = '';
|
|
try {
|
|
globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
} catch { /* unset is the expected clean-env case */ }
|
|
let localHooksPath = '';
|
|
try {
|
|
localHooksPath = execFileSync('git', ['-C', tmp, 'config', '--local', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
} catch { /* unset is fine when global was present */ }
|
|
if (globalHooksPath) {
|
|
expect(localHooksPath).toBe('');
|
|
} else {
|
|
expect(localHooksPath).toBe('.githooks');
|
|
}
|
|
});
|
|
|
|
test('#1840 — generated hook matches .md/.mdx (single-backslash regex, not over-escaped)', () => {
|
|
installHook(tmp, false);
|
|
const content = readFileSync(join(tmp, '.githooks', 'pre-commit'), 'utf8');
|
|
// The shell must see `grep -E '\.mdx?$'`. Pre-fix it emitted `'\\.mdx?$'`
|
|
// (literal backslash), so the hook matched nothing and silently no-opped.
|
|
expect(content).toContain("grep -E '\\.mdx?$'");
|
|
expect(content).not.toContain("grep -E '\\\\.mdx?$'");
|
|
|
|
// Prove the emitted pattern actually selects markdown files. Extract the
|
|
// exact pattern between the single quotes and run it through ripgrep-free
|
|
// JS regex parity (POSIX ERE `\.mdx?$` == JS `/\.mdx?$/`).
|
|
const m = content.match(/grep -E '([^']+)'/);
|
|
expect(m).not.toBeNull();
|
|
const re = new RegExp(m![1]);
|
|
expect(re.test('notes/thing.md')).toBe(true);
|
|
expect(re.test('notes/thing.mdx')).toBe(true);
|
|
expect(re.test('notes/thing.txt')).toBe(false);
|
|
});
|
|
|
|
test('installHook refuses to clobber existing hook without --force', () => {
|
|
const hooksDir = join(tmp, '.githooks');
|
|
mkdirSync(hooksDir, { recursive: true });
|
|
const hookPath = join(hooksDir, 'pre-commit');
|
|
writeFileSync(hookPath, '#!/bin/sh\necho "user hook"');
|
|
const result = installHook(tmp, false);
|
|
expect(result).toBe('skipped_existing');
|
|
// Original survives.
|
|
expect(readFileSync(hookPath, 'utf8')).toContain('user hook');
|
|
expect(existsSync(hookPath + '.bak')).toBe(false);
|
|
});
|
|
|
|
test('installHook with force overwrites and saves .bak', () => {
|
|
const hooksDir = join(tmp, '.githooks');
|
|
mkdirSync(hooksDir, { recursive: true });
|
|
const hookPath = join(hooksDir, 'pre-commit');
|
|
writeFileSync(hookPath, '#!/bin/sh\necho "user hook"');
|
|
const result = installHook(tmp, true);
|
|
expect(result).toBe('installed');
|
|
expect(existsSync(hookPath + '.bak')).toBe(true);
|
|
expect(readFileSync(hookPath + '.bak', 'utf8')).toContain('user hook');
|
|
expect(readFileSync(hookPath, 'utf8')).toContain('gbrain frontmatter');
|
|
});
|
|
|
|
test('installHook on existing gbrain hook refreshes silently (no .bak)', () => {
|
|
installHook(tmp, false);
|
|
const hookPath = join(tmp, '.githooks', 'pre-commit');
|
|
expect(existsSync(hookPath + '.bak')).toBe(false);
|
|
// Re-run; should be 'unchanged' (banner already present).
|
|
const second = installHook(tmp, false);
|
|
expect(second).toBe('unchanged');
|
|
});
|
|
|
|
test('uninstallHook removes the gbrain hook and restores .bak when present', () => {
|
|
const hooksDir = join(tmp, '.githooks');
|
|
mkdirSync(hooksDir, { recursive: true });
|
|
const hookPath = join(hooksDir, 'pre-commit');
|
|
writeFileSync(hookPath, '#!/bin/sh\necho "user hook"');
|
|
installHook(tmp, true);
|
|
expect(existsSync(hookPath + '.bak')).toBe(true);
|
|
|
|
const removed = uninstallHook(tmp);
|
|
expect(removed).toBe(true);
|
|
// .bak content restored as the active hook.
|
|
expect(readFileSync(hookPath, 'utf8')).toContain('user hook');
|
|
expect(existsSync(hookPath + '.bak')).toBe(false);
|
|
});
|
|
|
|
test('uninstallHook on a non-gbrain hook returns false (does not remove user hook)', () => {
|
|
const hooksDir = join(tmp, '.githooks');
|
|
mkdirSync(hooksDir, { recursive: true });
|
|
const hookPath = join(hooksDir, 'pre-commit');
|
|
writeFileSync(hookPath, '#!/bin/sh\necho "user hook"');
|
|
const removed = uninstallHook(tmp);
|
|
expect(removed).toBe(false);
|
|
expect(readFileSync(hookPath, 'utf8')).toContain('user hook');
|
|
});
|
|
});
|