diff --git a/package.json b/package.json index 986c7b91..a7cd5c2b 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,11 @@ "test:loop-cost": "cd tools/loop-cost && npm test", "test:loop-sync": "cd tools/loop-sync && npm test", "test:loop-context": "cd tools/loop-context && npm test", + "test:loop-gate": "cd tools/loop-gate && npm test", "test:mcp-server": "cd tools/mcp-server && npm test", "test:loop-worktree": "cd tools/loop-worktree && npm test", - "test:tools": "npm run test:loop-audit && npm run test:loop-init && npm run test:loop-cost && npm run test:loop-sync && npm run test:loop-context && npm run test:mcp-server && npm run test:loop-worktree", - "build:tools": "cd tools/loop-audit && npm run build && cd ../loop-init && npm run build && cd ../loop-cost && npm run build && cd ../loop-sync && npm run build && cd ../loop-context && npm run build && cd ../mcp-server && npm run build && cd ../loop-worktree && npm run build", + "test:tools": "npm run test:loop-audit && npm run test:loop-init && npm run test:loop-cost && npm run test:loop-sync && npm run test:loop-context && npm run test:loop-gate && npm run test:mcp-server && npm run test:loop-worktree", + "build:tools": "cd tools/loop-audit && npm run build && cd ../loop-init && npm run build && cd ../loop-cost && npm run build && cd ../loop-sync && npm run build && cd ../loop-context && npm run build && cd ../loop-gate && npm run build && cd ../mcp-server && npm run build && cd ../loop-worktree && npm run build", "contributors:generate": "node scripts/generate-contributors.mjs", "contributors:check": "node scripts/generate-contributors.mjs --check" }, diff --git a/scripts/ci-validate-gates.sh b/scripts/ci-validate-gates.sh index 1255542f..5f49346a 100755 --- a/scripts/ci-validate-gates.sh +++ b/scripts/ci-validate-gates.sh @@ -44,6 +44,11 @@ cd ../loop-context npm ci npm test +echo "Building and testing loop-gate…" +cd ../loop-gate +npm ci +npm test + echo "Building and testing mcp-server…" cd ../mcp-server npm ci diff --git a/tools/mcp-server/dist/index.js b/tools/mcp-server/dist/index.js index 35bb267e..e79e04c6 100755 --- a/tools/mcp-server/dist/index.js +++ b/tools/mcp-server/dist/index.js @@ -2,7 +2,9 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; -import { resolveProjectRoot, loadRegistry, loadPatternDoc, listSkills, loadSkill, loadState, listStateFiles, loadLoopConfig, loadBudget, loadRunLog, loadSafetyDoc, listPatternDocs, } from './resolver.js'; +import path from 'node:path'; +import { loadGateConfig, checkGate } from '@cobusgreyling/loop-gate'; +import { resolveProjectRoot, loadRegistry, loadPatternDoc, listSkills, loadSkill, loadState, listStateFiles, loadLoopConfig, loadBudget, loadRunLog, loadSafetyDoc, listPatternDocs, loadGatePolicy, } from './resolver.js'; const server = new McpServer({ name: 'loop-engineering', version: '1.0.0', @@ -63,6 +65,17 @@ server.resource('safety', 'loop://safety', { description: 'Safety documentation }], }; }); +server.resource('gate', 'loop://gate', { description: 'gate.yaml — Machine-readable safety policy (path denylist, auto-merge allowlist)' }, async () => { + const root = await resolveProjectRoot(); + const content = await loadGatePolicy(root); + return { + contents: [{ + uri: 'loop://gate', + mimeType: 'text/yaml', + text: content ?? 'No gate.yaml policy found', + }], + }; +}); // ── Resource Templates (dynamic) ─────────────────────────────────── server.resource('pattern', new ResourceTemplate('loop://patterns/{patternId}', { list: undefined }), { description: 'Full pattern documentation by ID (e.g. daily-triage, pr-babysitter, ci-sweeper)' }, async (uri, variables) => { const patternId = variables.patternId; @@ -308,6 +321,33 @@ server.tool('loop_estimate_cost', 'Estimate daily token cost for a pattern at a } return { content: [{ type: 'text', text: lines.join('\n') }] }; }); +server.tool('loop_gate_check', 'Evaluate a proposed change against the static safety policy (gate.yaml) to see if it triggers the denylist or exceeds thresholds', { + action: z.enum(['commit', 'merge', 'auto-merge']).describe('What the loop is about to do'), + paths: z.array(z.string()).describe('List of changed file paths'), +}, async ({ action, paths }) => { + const root = await resolveProjectRoot(); + const gateFile = path.join(root, 'gate.yaml'); + let config; + try { + config = await loadGateConfig(gateFile); + } + catch (err) { + return { content: [{ type: 'text', text: `Error loading gate policy: ${err.message}` }] }; + } + const decision = checkGate({ config, action: action, paths }); + const lines = [ + `## Gate Decision: ${decision.allowed ? '✅ ALLOWED' : '❌ BLOCKED'}`, + `- **Trigger:** ${decision.trigger}`, + `- **Reason:** ${decision.reason}` + ]; + if (decision.matchedPaths.length > 0) { + lines.push('', '**Matched Paths:**'); + for (const p of decision.matchedPaths) { + lines.push(`- ${p}`); + } + } + return { content: [{ type: 'text', text: lines.join('\n') }] }; +}); // ── Start ────────────────────────────────────────────────────────── async function main() { const transport = new StdioServerTransport(); diff --git a/tools/mcp-server/dist/resolver.d.ts b/tools/mcp-server/dist/resolver.d.ts index 17d238e4..beb22c73 100644 --- a/tools/mcp-server/dist/resolver.d.ts +++ b/tools/mcp-server/dist/resolver.d.ts @@ -44,4 +44,5 @@ export declare function loadLoopConfig(root: string): Promise; export declare function loadBudget(root: string): Promise; export declare function loadRunLog(root: string): Promise; export declare function loadSafetyDoc(root: string): Promise; +export declare function loadGatePolicy(root: string): Promise; export declare function listPatternDocs(root: string): Promise; diff --git a/tools/mcp-server/dist/resolver.js b/tools/mcp-server/dist/resolver.js index 551cc019..762a0048 100644 --- a/tools/mcp-server/dist/resolver.js +++ b/tools/mcp-server/dist/resolver.js @@ -134,6 +134,9 @@ export async function loadSafetyDoc(root) { } return null; } +export async function loadGatePolicy(root) { + return readFileIfExists(path.join(root, 'gate.yaml')); +} export async function listPatternDocs(root) { const patternsDir = path.join(root, 'patterns'); if (!(await fileExists(patternsDir))) diff --git a/tools/mcp-server/package-lock.json b/tools/mcp-server/package-lock.json index e3d5ac1a..813d698f 100644 --- a/tools/mcp-server/package-lock.json +++ b/tools/mcp-server/package-lock.json @@ -9,7 +9,9 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@cobusgreyling/loop-gate": "file:../loop-gate", "@modelcontextprotocol/sdk": "^1.12.1", + "minimatch": "^10.2.5", "yaml": "^2.8.0", "zod": "^3.25 || ^4.0" }, @@ -24,6 +26,29 @@ "node": ">=18" } }, + "../loop-gate": { + "name": "@cobusgreyling/loop-gate", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.0", + "yaml": "^2.8.0" + }, + "bin": { + "loop-gate": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cobusgreyling/loop-gate": { + "resolved": "../loop-gate", + "link": true + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -132,6 +157,15 @@ } } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -169,6 +203,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -760,6 +806,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/tools/mcp-server/package.json b/tools/mcp-server/package.json index 56b568d7..54e1b267 100644 --- a/tools/mcp-server/package.json +++ b/tools/mcp-server/package.json @@ -43,7 +43,9 @@ "access": "public" }, "dependencies": { + "@cobusgreyling/loop-gate": "file:../loop-gate", "@modelcontextprotocol/sdk": "^1.12.1", + "minimatch": "^10.2.5", "yaml": "^2.8.0", "zod": "^3.25 || ^4.0" }, diff --git a/tools/mcp-server/src/index.ts b/tools/mcp-server/src/index.ts index 3a33cea7..1ee0ad99 100644 --- a/tools/mcp-server/src/index.ts +++ b/tools/mcp-server/src/index.ts @@ -3,6 +3,8 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; +import path from 'node:path'; +import { loadGateConfig, checkGate } from '@cobusgreyling/loop-gate'; import { resolveProjectRoot, loadRegistry, @@ -16,6 +18,7 @@ import { loadRunLog, loadSafetyDoc, listPatternDocs, + loadGatePolicy, } from './resolver.js'; const server = new McpServer({ @@ -110,6 +113,23 @@ server.resource( }, ); +server.resource( + 'gate', + 'loop://gate', + { description: 'gate.yaml — Machine-readable safety policy (path denylist, auto-merge allowlist)' }, + async () => { + const root = await resolveProjectRoot(); + const content = await loadGatePolicy(root); + return { + contents: [{ + uri: 'loop://gate', + mimeType: 'text/yaml', + text: content ?? 'No gate.yaml policy found', + }], + }; + }, +); + // ── Resource Templates (dynamic) ─────────────────────────────────── server.resource( @@ -426,6 +446,43 @@ server.tool( }, ); +server.tool( + 'loop_gate_check', + 'Evaluate a proposed change against the static safety policy (gate.yaml) to see if it triggers the denylist or exceeds thresholds', + { + action: z.enum(['commit', 'merge', 'auto-merge']).describe('What the loop is about to do'), + paths: z.array(z.string()).describe('List of changed file paths'), + }, + async ({ action, paths }) => { + const root = await resolveProjectRoot(); + const gateFile = path.join(root, 'gate.yaml'); + + let config; + try { + config = await loadGateConfig(gateFile); + } catch (err: any) { + return { content: [{ type: 'text' as const, text: `Error loading gate policy: ${err.message}` }] }; + } + + const decision = checkGate({ config, action: action as any, paths }); + + const lines = [ + `## Gate Decision: ${decision.allowed ? '✅ ALLOWED' : '❌ BLOCKED'}`, + `- **Trigger:** ${decision.trigger}`, + `- **Reason:** ${decision.reason}` + ]; + + if (decision.matchedPaths.length > 0) { + lines.push('', '**Matched Paths:**'); + for (const p of decision.matchedPaths) { + lines.push(`- ${p}`); + } + } + + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + }, +); + // ── Start ────────────────────────────────────────────────────────── async function main() { diff --git a/tools/mcp-server/src/resolver.ts b/tools/mcp-server/src/resolver.ts index d5bed355..498ecad1 100644 --- a/tools/mcp-server/src/resolver.ts +++ b/tools/mcp-server/src/resolver.ts @@ -173,6 +173,10 @@ export async function loadSafetyDoc(root: string): Promise { return null; } +export async function loadGatePolicy(root: string): Promise { + return readFileIfExists(path.join(root, 'gate.yaml')); +} + export async function listPatternDocs(root: string): Promise { const patternsDir = path.join(root, 'patterns'); if (!(await fileExists(patternsDir))) return []; diff --git a/tools/mcp-server/test/server.test.mjs b/tools/mcp-server/test/server.test.mjs index 294502c0..a64da88b 100644 --- a/tools/mcp-server/test/server.test.mjs +++ b/tools/mcp-server/test/server.test.mjs @@ -19,6 +19,7 @@ import { loadSafetyDoc, listPatternDocs, loadPatternDoc, + loadGatePolicy, } from '../dist/resolver.js'; let tmpRoot; @@ -98,6 +99,19 @@ async function setup() { '# Safety\n\n## Path Denylists\n- .env\n- credentials\n', ); + // gate.yaml + await writeFile( + path.join(tmpRoot, 'gate.yaml'), + `version: 1 +denylist: + - ".env" + - "**/secrets/**" +maxFiles: 10 +autoMergeAllowlist: + - "docs/**" +`, + ); + return tmpRoot; } @@ -296,6 +310,18 @@ test('loadSafetyDoc reads docs/safety.md', async () => { } }); +test('loadGatePolicy reads gate.yaml', async () => { + const root = await setup(); + try { + const gate = await loadGatePolicy(root); + assert.ok(gate); + assert.ok(gate.includes('version: 1')); + assert.ok(gate.includes('.env')); + } finally { + await cleanup(); + } +}); + test('listPatternDocs finds .md files in patterns/', async () => { const root = await setup(); try { @@ -357,7 +383,7 @@ test('server lists all tools over stdio', async () => { try { const res = await callServer(root, [{ id: 1, method: 'tools/list', params: {} }]); const names = res.get(1).result.tools.map(t => t.name); - assert.equal(names.length, 8); + assert.equal(names.length, 9); assert.ok(names.includes('loop_list_patterns')); assert.ok(names.includes('loop_estimate_cost')); } finally { @@ -408,3 +434,33 @@ test('pattern resource is readable over stdio', async () => { await cleanup(); } }); + +test('gate resource is readable over stdio', async () => { + const root = await setup(); + try { + const res = await callServer(root, [{ + id: 1, method: 'resources/read', + params: { uri: 'loop://gate' }, + }]); + const text = res.get(1).result.contents[0].text; + assert.ok(text.includes('version: 1')); + assert.ok(text.includes('denylist:')); + } finally { + await cleanup(); + } +}); + +test('loop_gate_check tool returns policy decision', async () => { + const root = await setup(); + try { + const res = await callServer(root, [{ + id: 1, method: 'tools/call', + params: { name: 'loop_gate_check', arguments: { action: 'commit', paths: ['.env', 'src/main.ts'] } }, + }]); + const text = res.get(1).result.content[0].text; + assert.ok(text.includes('BLOCKED')); + assert.ok(text.includes('denylist')); + } finally { + await cleanup(); + } +});