diff --git a/README.md b/README.md index 5909554e9..0a888df06 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,8 @@ gitnexus mcp # Start MCP server (stdio) — serves all index gitnexus serve # Start local HTTP server (multi-repo) for web UI connection gitnexus list # List all indexed repositories gitnexus status # Show index status for current repo +gitnexus governance [path] # Detect advisory governance surfaces and sensitive operations +gitnexus governance --json # Emit governance report as JSON for agent/tool consumption gitnexus clean # Delete index for current repo gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate repository wiki from knowledge graph diff --git a/gitnexus/README.md b/gitnexus/README.md index 55cdc12f7..ec7365fa6 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -162,6 +162,8 @@ gitnexus serve # Start local HTTP server (multi-repo) for web gitnexus index # Register an existing .gitnexus/ folder into the global registry gitnexus list # List all indexed repositories gitnexus status # Show index status for current repo +gitnexus governance [path] # Detect advisory governance surfaces and sensitive operations +gitnexus governance --json # Emit governance report as JSON for agent/tool consumption gitnexus clean # Delete index for current repo gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph diff --git a/gitnexus/src/cli/governance.ts b/gitnexus/src/cli/governance.ts new file mode 100644 index 000000000..9c69a8dd6 --- /dev/null +++ b/gitnexus/src/cli/governance.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import { detectGovernance } from '../core/governance/detector.js'; + +export interface GovernanceOptions { + json?: boolean; + maxFiles?: string; +} + +export async function governanceCommand( + targetPath = '.', + options: GovernanceOptions = {}, +): Promise { + const root = path.resolve(targetPath); + const maxFiles = options.maxFiles ? Number.parseInt(options.maxFiles, 10) : undefined; + const report = detectGovernance(root, { + maxFiles: Number.isFinite(maxFiles) ? maxFiles : undefined, + }); + + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + + process.stdout.write(report.contextMarkdown); +} diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 4b009e4aa..2cadcef0e 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -124,6 +124,13 @@ program .description('Show runtime platform capabilities and embedding configuration') .action(createLazyAction(() => import('./doctor.js'), 'doctorCommand')); +program + .command('governance [path]') + .description('Detect repository governance boundaries and sensitive operation paths') + .option('--json', 'Emit the governance report as JSON') + .option('--max-files ', 'Maximum files to scan (default: 5000)') + .action(createLazyAction(() => import('./governance.js'), 'governanceCommand')); + program .command('clean') .description('Delete GitNexus index for current repo') diff --git a/gitnexus/src/core/governance/detector.ts b/gitnexus/src/core/governance/detector.ts new file mode 100644 index 000000000..e8b0ebcb9 --- /dev/null +++ b/gitnexus/src/core/governance/detector.ts @@ -0,0 +1,608 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { load as parseYaml } from 'js-yaml'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; + +export type GovernanceSurfaceKind = + | 'mcp' + | 'cedar' + | 'surfaces' + | 'veritas-acta' + | 'agt' + | 'scopeblind' + | 'generic'; + +export type SensitiveOperationKind = 'network' | 'exec' | 'filesystem-write' | 'secret-access'; + +export interface GovernanceConstraint { + id: string; + title: string; + kind: string; + sourcePath: string; + sourceField?: string; + appliesTo: SensitiveOperationKind[] | ['all']; + details?: Record; +} + +export interface GovernanceSurface { + id: string; + kind: GovernanceSurfaceKind; + path: string; + parser: 'json' | 'jsonc' | 'yaml' | 'cedar' | 'text'; + sha256: string; + constraints: GovernanceConstraint[]; + parseError?: string; +} + +export interface SensitiveOperation { + id: string; + kind: SensitiveOperationKind; + path: string; + line: number; + column: number; + matched: string; + evidence: string; +} + +export interface GovernanceGraphPatch { + nodes: GraphNode[]; + relationships: GraphRelationship[]; +} + +export interface GovernanceDetectionReport { + root: string; + surfaces: GovernanceSurface[]; + operations: SensitiveOperation[]; + graphPatch: GovernanceGraphPatch; + contextMarkdown: string; +} + +export interface GovernanceDetectionOptions { + maxFiles?: number; + maxFileBytes?: number; +} + +const DEFAULT_MAX_FILES = 5000; +const DEFAULT_MAX_FILE_BYTES = 512 * 1024; + +const SKIP_DIRS = new Set([ + '.git', + '.gitnexus', + 'node_modules', + 'dist', + 'build', + 'coverage', + '.next', + '.venv', + '__pycache__', +]); + +const SOURCE_EXTENSIONS = new Set([ + '.cjs', + '.cs', + '.go', + '.java', + '.js', + '.jsx', + '.mjs', + '.php', + '.py', + '.rb', + '.rs', + '.sh', + '.ts', + '.tsx', +]); + +const SENSITIVE_PATTERNS: Array<{ + kind: SensitiveOperationKind; + re: RegExp; + label: string; +}> = [ + { kind: 'network', re: /\bfetch\s*\(/g, label: 'fetch()' }, + { kind: 'network', re: /\baxios\s*\./g, label: 'axios' }, + { kind: 'network', re: /\b(requests|httpx)\s*\./g, label: 'python http client' }, + { kind: 'network', re: /\b(WebSocket|EventSource)\s*\(/g, label: 'browser socket' }, + { + kind: 'network', + re: /\b(net|tls|http|https)\.(request|get|createServer|connect)\s*\(/g, + label: 'node network', + }, + { kind: 'exec', re: /\b(exec|execFile|spawn|spawnSync)\s*\(/g, label: 'child process' }, + { kind: 'exec', re: /\bchild_process\b/g, label: 'child_process module' }, + { + kind: 'exec', + re: /\b(subprocess|os)\.(run|Popen|system|execv|spawn)/g, + label: 'python process', + }, + { + kind: 'filesystem-write', + re: /\b(writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream)\s*\(/g, + label: 'node fs write', + }, + { + kind: 'filesystem-write', + re: /\bopen\s*\([^\n]*(?:['"]w['"]|['"]a['"])/g, + label: 'python file write', + }, + { + kind: 'secret-access', + re: /\b(process\.env|Deno\.env|getenv|os\.environ)\b/g, + label: 'environment secret access', + }, +]; + +export function detectGovernance( + repoRoot: string, + opts: GovernanceDetectionOptions = {}, +): GovernanceDetectionReport { + const root = path.resolve(repoRoot); + const files = listCandidateFiles(root, opts); + const surfaces = files.flatMap((file) => detectSurface(root, file)); + const operations = scanSensitiveOperations(root, files, opts); + const graphPatch = buildGovernanceGraphPatch(surfaces, operations); + return { + root, + surfaces, + operations, + graphPatch, + contextMarkdown: formatGovernanceContext({ root, surfaces, operations, graphPatch }), + }; +} + +export function buildGovernanceGraphPatch( + surfaces: GovernanceSurface[], + operations: SensitiveOperation[], +): GovernanceGraphPatch { + const nodes: GraphNode[] = []; + const relationships: GraphRelationship[] = []; + + for (const surface of surfaces) { + nodes.push({ + id: `governance:surface:${surface.id}`, + label: 'CodeElement', + properties: { + name: `Governance surface: ${surface.path}`, + filePath: surface.path, + governanceKind: surface.kind, + governanceParser: surface.parser, + governanceSha256: surface.sha256, + governanceConstraintCount: surface.constraints.length, + governanceParseError: surface.parseError, + }, + }); + + for (const constraint of surface.constraints) { + nodes.push({ + id: `governance:constraint:${constraint.id}`, + label: 'CodeElement', + properties: { + name: constraint.title, + filePath: constraint.sourcePath, + governanceKind: 'constraint', + constraintKind: constraint.kind, + appliesTo: constraint.appliesTo, + sourceField: constraint.sourceField, + details: constraint.details, + }, + }); + relationships.push({ + id: `governance-rel:${surface.id}:defines:${constraint.id}`, + sourceId: `governance:surface:${surface.id}`, + targetId: `governance:constraint:${constraint.id}`, + type: 'DEFINES', + confidence: 1, + reason: 'governance-surface-defines-constraint', + }); + } + } + + for (const operation of operations) { + nodes.push({ + id: `governance:operation:${operation.id}`, + label: 'CodeElement', + properties: { + name: `${operation.kind}: ${operation.matched}`, + filePath: operation.path, + startLine: operation.line, + endLine: operation.line, + governanceKind: 'sensitive-operation', + operationKind: operation.kind, + evidence: operation.evidence, + }, + }); + + for (const surface of surfaces) { + for (const constraint of surface.constraints) { + if (!constraintAppliesToOperation(constraint, operation)) continue; + relationships.push({ + id: `governance-rel:${operation.id}:uses:${constraint.id}`, + sourceId: `governance:operation:${operation.id}`, + targetId: `governance:constraint:${constraint.id}`, + type: 'USES', + confidence: 0.75, + reason: `governance-boundary-applies:${constraint.kind}`, + }); + } + } + } + + return { nodes, relationships }; +} + +export function formatGovernanceContext( + report: Omit, +): string { + const lines = [ + '## Governance boundaries detected by GitNexus', + '', + `Repository: ${report.root}`, + `Governance surfaces: ${report.surfaces.length}`, + `Sensitive operations: ${report.operations.length}`, + `Graph patch: ${report.graphPatch.nodes.length} nodes, ${report.graphPatch.relationships.length} edges`, + '', + ]; + + if (report.surfaces.length > 0) { + lines.push('### Policy/config surfaces'); + for (const surface of report.surfaces) { + const status = surface.parseError + ? `parse warning: ${surface.parseError}` + : `${surface.constraints.length} constraints`; + lines.push(`- ${surface.path} (${surface.kind}, ${status})`); + } + lines.push(''); + } + + if (report.operations.length > 0) { + lines.push('### Sensitive code paths'); + for (const operation of report.operations.slice(0, 25)) { + lines.push( + `- ${operation.path}:${operation.line} ${operation.kind} via ${operation.matched}`, + ); + } + if (report.operations.length > 25) lines.push(`- ... ${report.operations.length - 25} more`); + lines.push(''); + } + + lines.push('### Agent guidance'); + if (report.surfaces.length === 0) { + lines.push( + '- No repository governance files were detected. Do not infer network, exec, or write permissions from GitNexus context alone.', + ); + } else { + lines.push( + '- Treat these governance files as first-class repository context when editing sensitive code paths.', + ); + lines.push( + '- If a sensitive operation touches network, process execution, filesystem writes, or secrets, check the linked governance constraint before generating code.', + ); + lines.push( + '- This report is advisory: enforcement still belongs to the runtime hook, policy engine, gateway, or CI job that owns the boundary.', + ); + } + + return `${lines.join('\n')}\n`; +} + +function detectSurface(root: string, absPath: string): GovernanceSurface[] { + const rel = relativePath(root, absPath); + const base = path.basename(rel).toLowerCase(); + const normalized = rel.split(path.sep).join('/').toLowerCase(); + const kind = classifySurface(normalized, base); + if (kind === null) return []; + + const raw = readFileSync(absPath, 'utf8'); + const parser = parserForPath(absPath, kind); + const surface: GovernanceSurface = { + id: stableId(rel), + kind, + path: rel, + parser, + sha256: sha256(raw), + constraints: [], + }; + + try { + if (parser === 'cedar') { + surface.constraints = constraintsFromCedar(surface, raw); + } else if (parser === 'yaml') { + surface.constraints = constraintsFromObject(surface, parseYaml(raw), []); + } else if (parser === 'json' || parser === 'jsonc') { + surface.constraints = constraintsFromObject(surface, parseJsonc(raw), []); + } else { + surface.constraints = constraintsFromText(surface, raw); + } + } catch (err) { + surface.parseError = err instanceof Error ? err.message : String(err); + surface.constraints = [ + { + id: `${surface.id}:parse-warning`, + title: `Governance file present but could not be parsed: ${rel}`, + kind: 'parse-warning', + sourcePath: rel, + appliesTo: ['all'], + }, + ]; + } + + if (surface.constraints.length === 0) { + surface.constraints = [ + { + id: `${surface.id}:presence`, + title: `Governance surface present: ${rel}`, + kind: `${kind}:presence`, + sourcePath: rel, + appliesTo: ['all'], + }, + ]; + } + + return [surface]; +} + +function classifySurface(normalized: string, base: string): GovernanceSurfaceKind | null { + if (base === '.mcp.json' || base === '.mcp.jsonc' || base === 'mcp.json') return 'mcp'; + if (base === 'surfaces.yaml' || base === 'surfaces.yml' || base === '.surfaces.yaml') + return 'surfaces'; + if (base.endsWith('.cedar')) return 'cedar'; + if (normalized.includes('.veritasacta/') || base === 'veritasacta.config.json') + return 'veritas-acta'; + if (normalized.includes('.scopeblind/') || base === 'protect-mcp.config.json') + return 'scopeblind'; + if ( + base === 'agent-governance.json' || + base === '.agent-governance.json' || + normalized.includes('agent-governance') + ) + return 'agt'; + return null; +} + +function parserForPath(absPath: string, kind: GovernanceSurfaceKind): GovernanceSurface['parser'] { + if (kind === 'cedar') return 'cedar'; + const ext = path.extname(absPath).toLowerCase(); + if (ext === '.yaml' || ext === '.yml') return 'yaml'; + if (ext === '.jsonc') return 'jsonc'; + if (ext === '.json') return 'json'; + return 'text'; +} + +function constraintsFromCedar(surface: GovernanceSurface, raw: string): GovernanceConstraint[] { + const constraints: GovernanceConstraint[] = []; + const policyCount = (raw.match(/\b(?:permit|forbid)\s*\(/g) || []).length; + if (policyCount > 0) { + constraints.push({ + id: `${surface.id}:cedar-policy-set`, + title: `Cedar policy set (${policyCount} policies)`, + kind: 'cedar-policy-set', + sourcePath: surface.path, + appliesTo: ['all'], + details: { policyCount }, + }); + } + return constraints; +} + +function constraintsFromText(surface: GovernanceSurface, raw: string): GovernanceConstraint[] { + if (raw.trim().length === 0) return []; + return [ + { + id: `${surface.id}:text-policy`, + title: `Text governance surface: ${surface.path}`, + kind: 'text-policy', + sourcePath: surface.path, + appliesTo: ['all'], + }, + ]; +} + +function constraintsFromObject( + surface: GovernanceSurface, + value: unknown, + pathParts: string[], +): GovernanceConstraint[] { + const constraints: GovernanceConstraint[] = []; + if (value === null || value === undefined) return constraints; + + if (Array.isArray(value)) { + value.forEach((item, index) => + constraints.push(...constraintsFromObject(surface, item, [...pathParts, String(index)])), + ); + return constraints; + } + + if (typeof value !== 'object') return constraints; + const obj = value as Record; + + if (surface.kind === 'mcp' && pathParts.length === 0 && isRecord(obj.mcpServers)) { + for (const serverName of Object.keys(obj.mcpServers).sort()) { + constraints.push({ + id: `${surface.id}:mcp-server:${stableId(serverName)}`, + title: `MCP server boundary: ${serverName}`, + kind: 'mcp-server', + sourcePath: surface.path, + sourceField: `mcpServers.${serverName}`, + appliesTo: ['exec', 'filesystem-write', 'network'], + details: { serverName }, + }); + } + } + + for (const [key, nested] of Object.entries(obj)) { + const fieldPath = [...pathParts, key].join('.'); + const keyNorm = key.toLowerCase(); + const scalarValues = collectScalarPreview(nested); + const appliesTo = appliesToForKey(keyNorm); + if (appliesTo !== null) { + constraints.push({ + id: `${surface.id}:field:${stableId(fieldPath)}`, + title: `${surface.kind} constraint: ${fieldPath}`, + kind: keyNorm, + sourcePath: surface.path, + sourceField: fieldPath, + appliesTo, + details: scalarValues.length > 0 ? { values: scalarValues } : undefined, + }); + } + constraints.push(...constraintsFromObject(surface, nested, [...pathParts, key])); + } + + return dedupeConstraints(constraints); +} + +function appliesToForKey(keyNorm: string): GovernanceConstraint['appliesTo'] | null { + if ( + keyNorm.includes('host') || + keyNorm.includes('url') || + keyNorm.includes('network') || + keyNorm.includes('egress') + ) + return ['network']; + if ( + keyNorm.includes('command') || + keyNorm.includes('exec') || + keyNorm.includes('shell') || + keyNorm.includes('subprocess') + ) + return ['exec']; + if ( + keyNorm.includes('write') || + keyNorm.includes('filesystem') || + keyNorm.includes('path') || + keyNorm.includes('workspace') + ) + return ['filesystem-write']; + if ( + keyNorm.includes('secret') || + keyNorm.includes('env') || + keyNorm.includes('token') || + keyNorm.includes('credential') + ) + return ['secret-access']; + if ( + keyNorm.includes('receipt') || + keyNorm.includes('sign') || + keyNorm.includes('policy') || + keyNorm.includes('cedar') || + keyNorm.includes('sandbox') || + keyNorm.includes('scope') + ) + return ['all']; + return null; +} + +function scanSensitiveOperations( + root: string, + files: string[], + opts: GovernanceDetectionOptions, +): SensitiveOperation[] { + const maxFileBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES; + const operations: SensitiveOperation[] = []; + for (const absPath of files) { + const ext = path.extname(absPath).toLowerCase(); + if (!SOURCE_EXTENSIONS.has(ext)) continue; + const st = statSync(absPath); + if (st.size > maxFileBytes) continue; + const rel = relativePath(root, absPath); + const raw = readFileSync(absPath, 'utf8'); + const lines = raw.split(/\r?\n/); + lines.forEach((line, index) => { + for (const pattern of SENSITIVE_PATTERNS) { + pattern.re.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.re.exec(line)) !== null) { + operations.push({ + id: stableId(`${rel}:${index + 1}:${match.index + 1}:${pattern.kind}:${match[0]}`), + kind: pattern.kind, + path: rel, + line: index + 1, + column: match.index + 1, + matched: pattern.label, + evidence: line.trim().slice(0, 240), + }); + } + } + }); + } + return operations; +} + +function constraintAppliesToOperation( + constraint: GovernanceConstraint, + operation: SensitiveOperation, +): boolean { + const appliesTo = constraint.appliesTo as readonly string[]; + return appliesTo.includes('all') || appliesTo.includes(operation.kind); +} + +function listCandidateFiles(root: string, opts: GovernanceDetectionOptions): string[] { + const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES; + const out: string[] = []; + const walk = (dir: string): void => { + if (out.length >= maxFiles) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (out.length >= maxFiles) return; + const absPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walk(absPath); + } else if (entry.isFile()) { + out.push(absPath); + } + } + }; + if (existsSync(root)) walk(root); + return out.sort(); +} + +function relativePath(root: string, absPath: string): string { + return path.relative(root, absPath).split(path.sep).join('/'); +} + +function stableId(input: string): string { + return ( + input + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 96) || sha256(input).slice(0, 12) + ); +} + +function sha256(input: string | Buffer): string { + return createHash('sha256').update(input).digest('hex'); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function collectScalarPreview(value: unknown): string[] { + const out: string[] = []; + const visit = (item: unknown): void => { + if (out.length >= 8) return; + if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') { + out.push(String(item)); + } else if (Array.isArray(item)) { + for (const child of item) visit(child); + } + }; + visit(value); + return out; +} + +function dedupeConstraints(constraints: GovernanceConstraint[]): GovernanceConstraint[] { + const seen = new Set(); + return constraints.filter((constraint) => { + if (seen.has(constraint.id)) return false; + seen.add(constraint.id); + return true; + }); +} diff --git a/gitnexus/test/unit/governance-detector.test.ts b/gitnexus/test/unit/governance-detector.test.ts new file mode 100644 index 000000000..83b7c801e --- /dev/null +++ b/gitnexus/test/unit/governance-detector.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + buildGovernanceGraphPatch, + detectGovernance, + formatGovernanceContext, +} from '../../src/core/governance/detector.js'; + +function makeRepo(): string { + return mkdtempSync(path.join(tmpdir(), 'gitnexus-governance-')); +} + +describe('governance detector', () => { + it('detects MCP, Cedar, surfaces, and Veritas Acta governance surfaces', () => { + const root = makeRepo(); + mkdirSync(path.join(root, '.veritasacta'), { recursive: true }); + writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + payments: { command: 'node', args: ['server.js'], allowedHosts: ['api.example.com'] }, + }, + }), + ); + writeFileSync( + path.join(root, 'policy.cedar'), + 'permit(principal, action == Action::"read", resource);\n', + ); + writeFileSync( + path.join(root, 'surfaces.yaml'), + 'surfaces:\n checkout:\n allowed_hosts:\n - api.example.com\n require_receipts: true\n', + ); + writeFileSync( + path.join(root, '.veritasacta/config.json'), + JSON.stringify({ receipts: { required: true }, policy: { id: 'checkout-v1' } }), + ); + + const report = detectGovernance(root); + + expect(report.surfaces.map((s) => s.kind).sort()).toEqual([ + 'cedar', + 'mcp', + 'surfaces', + 'veritas-acta', + ]); + expect(report.surfaces.flatMap((s) => s.constraints).some((c) => c.kind === 'mcp-server')).toBe( + true, + ); + expect( + report.surfaces.flatMap((s) => s.constraints).some((c) => c.kind === 'cedar-policy-set'), + ).toBe(true); + expect(report.contextMarkdown).toContain('Governance boundaries detected by GitNexus'); + }); + + it('detects sensitive operations and links them to matching governance constraints', () => { + const root = makeRepo(); + mkdirSync(path.join(root, 'src'), { recursive: true }); + writeFileSync( + path.join(root, 'surfaces.yaml'), + 'network:\n allowed_hosts:\n - api.example.com\nexecution:\n command_allowlist:\n - git\n', + ); + writeFileSync( + path.join(root, 'src/agent.ts'), + 'import { exec } from "node:child_process";\nawait fetch("https://api.example.com");\nexec("git status");\nprocess.env.API_TOKEN;\n', + ); + + const report = detectGovernance(root); + + expect(report.operations.map((op) => op.kind)).toEqual( + expect.arrayContaining(['network', 'exec', 'secret-access']), + ); + expect( + report.graphPatch.nodes.some((node) => node.id.startsWith('governance:operation:')), + ).toBe(true); + expect( + report.graphPatch.relationships.some((rel) => + rel.reason.startsWith('governance-boundary-applies'), + ), + ).toBe(true); + }); + + it('emits a reversible graph patch using existing node labels and relationship types', () => { + const root = makeRepo(); + writeFileSync(path.join(root, '.agent-governance.json'), JSON.stringify({ sandbox: true })); + writeFileSync( + path.join(root, 'tool.py'), + 'import subprocess\nsubprocess.run(["git", "status"])\n', + ); + + const report = detectGovernance(root); + const patch = buildGovernanceGraphPatch(report.surfaces, report.operations); + + expect(patch.nodes.length).toBeGreaterThan(0); + expect(patch.nodes.every((node) => node.label === 'CodeElement')).toBe(true); + expect(patch.relationships.every((rel) => ['DEFINES', 'USES'].includes(rel.type))).toBe(true); + }); + + it('formats no-surface guidance without inventing permissions', () => { + const root = makeRepo(); + writeFileSync(path.join(root, 'client.ts'), 'fetch("https://example.com")\n'); + const report = detectGovernance(root); + const text = formatGovernanceContext(report); + + expect(report.surfaces).toHaveLength(0); + expect(text).toContain('Do not infer network, exec, or write permissions'); + }); +});