Files
gbrain/test/frontmatter-install-hook.test.ts
T
1eb430a2df v0.42.37.0 fix(security,ingest): source-isolation grant enforcement + non-string frontmatter guard + papercuts (#1999)
* 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>
2026-06-08 21:19:25 -07:00

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');
});
});