aegis-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Governed File Tools — MCP tool registrations for file operations.
3
+ *
4
+ * These are the tools agents call instead of raw file system access.
5
+ * Every call is validated against the loaded policy before execution.
6
+ * The agent never sees the policy — only the verdict.
7
+ *
8
+ * Tools:
9
+ * aegis_check_permissions — Pre-check before writing (saves wasted generation)
10
+ * aegis_write_file — Governed write with path + content validation
11
+ * aegis_read_file — Governed read with path validation
12
+ * aegis_delete_file — Governed delete (uses write permissions)
13
+ * aegis_execute — Governed command execution
14
+ * aegis_complete_task — Task completion with quality gate validation
15
+ * aegis_policy_summary — Minimal role/permissions summary (~200 tokens)
16
+ */
17
+
18
+ import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
19
+ import { dirname, join, isAbsolute } from 'node:path';
20
+ import { execSync } from 'node:child_process';
21
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
+ import { z } from 'zod';
23
+ import type { EnforcementEngine } from '../services/enforcement-engine.js';
24
+ import type { PolicyState, ResolvedRole } from '../types.js';
25
+
26
+ export function registerTools(
27
+ server: McpServer,
28
+ getEngine: () => EnforcementEngine,
29
+ getState: () => PolicyState,
30
+ getRole: () => ResolvedRole
31
+ ): void {
32
+
33
+ // ─── aegis_check_permissions ──────────────────────────────────────────────
34
+
35
+ server.registerTool(
36
+ 'aegis_check_permissions',
37
+ {
38
+ title: 'Check Permissions',
39
+ description: `Check if an operation is allowed on a path before attempting it. Use this to pre-validate before writing or reading files — saves you from composing content that would be blocked.
40
+
41
+ Args:
42
+ - path (string): Target file path relative to project root
43
+ - operation ('read' | 'write' | 'delete'): The operation to check
44
+
45
+ Returns:
46
+ { "allowed": true } or { "allowed": false, "reason": "..." }`,
47
+ inputSchema: {
48
+ path: z.string().describe('Target file path relative to project root'),
49
+ operation: z.enum(['read', 'write', 'delete']).describe('Operation to check'),
50
+ },
51
+ annotations: {
52
+ readOnlyHint: true,
53
+ destructiveHint: false,
54
+ idempotentHint: true,
55
+ openWorldHint: false,
56
+ },
57
+ },
58
+ async ({ path, operation }) => {
59
+ const engine = getEngine();
60
+ const verdict = operation === 'read'
61
+ ? engine.validateRead(path)
62
+ : engine.validateWrite(path);
63
+
64
+ return {
65
+ content: [{
66
+ type: 'text' as const,
67
+ text: JSON.stringify(
68
+ verdict.allowed
69
+ ? { allowed: true }
70
+ : { allowed: false, reason: verdict.reason }
71
+ ),
72
+ }],
73
+ };
74
+ }
75
+ );
76
+
77
+ // ─── aegis_write_file ─────────────────────────────────────────────────────
78
+
79
+ server.registerTool(
80
+ 'aegis_write_file',
81
+ {
82
+ title: 'Write File (Governed)',
83
+ description: `Write content to a file with governance enforcement. Path is validated against your role's permissions and governance boundaries. Content is scanned for sensitive patterns. If the write violates policy, it is blocked and you receive the specific reason.
84
+
85
+ Args:
86
+ - path (string): File path relative to project root
87
+ - content (string): File content to write
88
+
89
+ Returns:
90
+ { "status": "success", "path": "..." } or { "status": "blocked", "reason": "..." }`,
91
+ inputSchema: {
92
+ path: z.string().describe('File path relative to project root'),
93
+ content: z.string().describe('File content to write'),
94
+ },
95
+ annotations: {
96
+ readOnlyHint: false,
97
+ destructiveHint: true,
98
+ idempotentHint: true,
99
+ openWorldHint: false,
100
+ },
101
+ },
102
+ async ({ path, content }) => {
103
+ const engine = getEngine();
104
+ const state = getState();
105
+ const role = getRole();
106
+
107
+ // Validate path permissions
108
+ const pathVerdict = engine.validateWrite(path);
109
+ if (!pathVerdict.allowed) {
110
+ await logBlocked(engine, role, path, 'write', pathVerdict.reason);
111
+ return blocked(pathVerdict.reason);
112
+ }
113
+
114
+ // Scan content for sensitive patterns
115
+ const contentVerdict = engine.scanContent(content, path);
116
+ if (!contentVerdict.allowed) {
117
+ await logBlocked(engine, role, path, 'write (sensitive content)', contentVerdict.reason);
118
+ return blocked(contentVerdict.reason);
119
+ }
120
+
121
+ // Write the file
122
+ const absPath = toAbsolute(path, state.projectRoot);
123
+ await mkdir(dirname(absPath), { recursive: true });
124
+ await writeFile(absPath, content, 'utf-8');
125
+
126
+ return {
127
+ content: [{
128
+ type: 'text' as const,
129
+ text: JSON.stringify({ status: 'success', path }),
130
+ }],
131
+ };
132
+ }
133
+ );
134
+
135
+ // ─── aegis_read_file ──────────────────────────────────────────────────────
136
+
137
+ server.registerTool(
138
+ 'aegis_read_file',
139
+ {
140
+ title: 'Read File (Governed)',
141
+ description: `Read the contents of a file with governance enforcement. Path is validated against your role's read permissions. If the read violates policy, it is blocked.
142
+
143
+ Args:
144
+ - path (string): File path relative to project root
145
+
146
+ Returns:
147
+ File content as text, or { "status": "blocked", "reason": "..." }`,
148
+ inputSchema: {
149
+ path: z.string().describe('File path relative to project root'),
150
+ },
151
+ annotations: {
152
+ readOnlyHint: true,
153
+ destructiveHint: false,
154
+ idempotentHint: true,
155
+ openWorldHint: false,
156
+ },
157
+ },
158
+ async ({ path }) => {
159
+ const engine = getEngine();
160
+ const state = getState();
161
+
162
+ const verdict = engine.validateRead(path);
163
+ if (!verdict.allowed) {
164
+ return blocked(verdict.reason);
165
+ }
166
+
167
+ const absPath = toAbsolute(path, state.projectRoot);
168
+ const content = await readFile(absPath, 'utf-8');
169
+
170
+ return {
171
+ content: [{
172
+ type: 'text' as const,
173
+ text: content,
174
+ }],
175
+ };
176
+ }
177
+ );
178
+
179
+ // ─── aegis_delete_file ────────────────────────────────────────────────────
180
+
181
+ server.registerTool(
182
+ 'aegis_delete_file',
183
+ {
184
+ title: 'Delete File (Governed)',
185
+ description: `Delete a file with governance enforcement. Write permissions are required. If the delete violates policy, it is blocked.
186
+
187
+ Args:
188
+ - path (string): File path relative to project root
189
+
190
+ Returns:
191
+ { "status": "success", "path": "..." } or { "status": "blocked", "reason": "..." }`,
192
+ inputSchema: {
193
+ path: z.string().describe('File path relative to project root'),
194
+ },
195
+ annotations: {
196
+ readOnlyHint: false,
197
+ destructiveHint: true,
198
+ idempotentHint: false,
199
+ openWorldHint: false,
200
+ },
201
+ },
202
+ async ({ path }) => {
203
+ const engine = getEngine();
204
+ const state = getState();
205
+ const role = getRole();
206
+
207
+ const verdict = engine.validateWrite(path);
208
+ if (!verdict.allowed) {
209
+ await logBlocked(engine, role, path, 'delete', verdict.reason);
210
+ return blocked(verdict.reason);
211
+ }
212
+
213
+ const absPath = toAbsolute(path, state.projectRoot);
214
+ await unlink(absPath);
215
+
216
+ return {
217
+ content: [{
218
+ type: 'text' as const,
219
+ text: JSON.stringify({ status: 'success', path }),
220
+ }],
221
+ };
222
+ }
223
+ );
224
+
225
+ // ─── aegis_execute ────────────────────────────────────────────────────────
226
+
227
+ server.registerTool(
228
+ 'aegis_execute',
229
+ {
230
+ title: 'Execute Command (Governed)',
231
+ description: `Execute a shell command in the project directory. Currently validates that the command runs within the project root. Future versions will enforce command-level permissions.
232
+
233
+ Args:
234
+ - command (string): Shell command to execute
235
+ - cwd (string, optional): Working directory (defaults to project root)
236
+
237
+ Returns:
238
+ { "status": "success", "stdout": "...", "stderr": "..." } or { "status": "error", ... }`,
239
+ inputSchema: {
240
+ command: z.string().describe('Shell command to execute'),
241
+ cwd: z.string().optional().describe('Working directory (defaults to project root)'),
242
+ },
243
+ annotations: {
244
+ readOnlyHint: false,
245
+ destructiveHint: true,
246
+ idempotentHint: false,
247
+ openWorldHint: true,
248
+ },
249
+ },
250
+ async ({ command, cwd }) => {
251
+ const state = getState();
252
+
253
+ try {
254
+ const result = execSync(command, {
255
+ cwd: cwd ?? state.projectRoot,
256
+ encoding: 'utf-8',
257
+ timeout: 60_000,
258
+ maxBuffer: 1024 * 1024 * 10,
259
+ });
260
+
261
+ return {
262
+ content: [{
263
+ type: 'text' as const,
264
+ text: JSON.stringify({ status: 'success', stdout: result, stderr: '' }),
265
+ }],
266
+ };
267
+ } catch (err: unknown) {
268
+ const execErr = err as { stdout?: string; stderr?: string; message?: string };
269
+ return {
270
+ isError: true,
271
+ content: [{
272
+ type: 'text' as const,
273
+ text: JSON.stringify({
274
+ status: 'error',
275
+ stdout: execErr.stdout ?? '',
276
+ stderr: execErr.stderr ?? execErr.message ?? 'Unknown error',
277
+ }),
278
+ }],
279
+ };
280
+ }
281
+ }
282
+ );
283
+
284
+ // ─── aegis_complete_task ──────────────────────────────────────────────────
285
+
286
+ server.registerTool(
287
+ 'aegis_complete_task',
288
+ {
289
+ title: 'Complete Task',
290
+ description: `Signal task completion and run required quality gates. Maps the governance quality_gate.pre_commit flags to build_commands and runs each required check. Returns pass/fail with details.
291
+
292
+ Args:
293
+ - task_id (string): Identifier for the task being completed
294
+ - summary (string): Brief summary of what was accomplished
295
+
296
+ Returns:
297
+ { "status": "passed", "gates_run": [...] } or { "status": "failed", "failures": [...] }`,
298
+ inputSchema: {
299
+ task_id: z.string().describe('Task identifier'),
300
+ summary: z.string().describe('Summary of completed work'),
301
+ },
302
+ annotations: {
303
+ readOnlyHint: false,
304
+ destructiveHint: false,
305
+ idempotentHint: true,
306
+ openWorldHint: false,
307
+ },
308
+ },
309
+ async ({ task_id, summary }) => {
310
+ const engine = getEngine();
311
+ const state = getState();
312
+ const gates = engine.getQualityGateCommands();
313
+
314
+ if (gates.length === 0) {
315
+ return {
316
+ content: [{
317
+ type: 'text' as const,
318
+ text: JSON.stringify({
319
+ status: 'passed',
320
+ task_id,
321
+ summary,
322
+ gates_run: [],
323
+ message: 'No quality gates configured with matching build commands.',
324
+ }),
325
+ }],
326
+ };
327
+ }
328
+
329
+ const results: Array<{ name: string; passed: boolean; output?: string }> = [];
330
+
331
+ for (const gate of gates) {
332
+ try {
333
+ const output = execSync(gate.command, {
334
+ cwd: state.projectRoot,
335
+ encoding: 'utf-8',
336
+ timeout: 120_000,
337
+ });
338
+ results.push({ name: gate.name, passed: true, output: output.slice(0, 500) });
339
+ } catch (err: unknown) {
340
+ const execErr = err as { stderr?: string; message?: string };
341
+ results.push({
342
+ name: gate.name,
343
+ passed: false,
344
+ output: (execErr.stderr ?? execErr.message ?? 'Failed').slice(0, 500),
345
+ });
346
+ }
347
+ }
348
+
349
+ const allPassed = results.every((r) => r.passed);
350
+
351
+ return {
352
+ content: [{
353
+ type: 'text' as const,
354
+ text: JSON.stringify({
355
+ status: allPassed ? 'passed' : 'failed',
356
+ task_id,
357
+ summary,
358
+ gates_run: results,
359
+ }),
360
+ }],
361
+ };
362
+ }
363
+ );
364
+
365
+ // ─── aegis_policy_summary ─────────────────────────────────────────────────
366
+
367
+ server.registerTool(
368
+ 'aegis_policy_summary',
369
+ {
370
+ title: 'Policy Summary',
371
+ description: `Get a minimal summary of your current role and permissions. Returns your role name, writable paths, excluded paths, forbidden actions, and key governance rules — just enough to understand your boundaries without loading full policy files.
372
+
373
+ Returns:
374
+ { "role": "...", "writable_paths": [...], "forbidden_actions": [...], ... }`,
375
+ inputSchema: {},
376
+ annotations: {
377
+ readOnlyHint: true,
378
+ destructiveHint: false,
379
+ idempotentHint: true,
380
+ openWorldHint: false,
381
+ },
382
+ },
383
+ async () => {
384
+ const role = getRole();
385
+ const state = getState();
386
+ const protocol = state.governance.override_protocol;
387
+
388
+ const summary = {
389
+ role: role.id,
390
+ role_name: role.name,
391
+ purpose: role.purpose,
392
+ autonomy: role.autonomy,
393
+ writable_paths: role.writable_paths,
394
+ secondary_paths: role.secondary_paths,
395
+ excluded_paths: role.excluded_paths,
396
+ readable_paths: role.readable_paths,
397
+ forbidden_actions: role.forbidden_actions,
398
+ governance_forbidden_paths: state.governance.permissions.boundaries.forbidden ?? [],
399
+ override_behavior: protocol?.behavior ?? 'warn_confirm_and_log',
400
+ immutable_policies: protocol?.immutable_policies ?? [],
401
+ quality_gates: {
402
+ must_pass_tests: state.governance.quality_gate.pre_commit.must_pass_tests ?? false,
403
+ must_pass_lint: state.governance.quality_gate.pre_commit.must_pass_lint ?? false,
404
+ must_pass_typecheck: state.governance.quality_gate.pre_commit.must_pass_typecheck ?? false,
405
+ },
406
+ };
407
+
408
+ return {
409
+ content: [{
410
+ type: 'text' as const,
411
+ text: JSON.stringify(summary),
412
+ }],
413
+ };
414
+ }
415
+ );
416
+ }
417
+
418
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
419
+
420
+ function toAbsolute(path: string, projectRoot: string): string {
421
+ return isAbsolute(path) ? path : join(projectRoot, path);
422
+ }
423
+
424
+ function blocked(reason: string): {
425
+ isError: boolean;
426
+ content: Array<{ type: 'text'; text: string }>;
427
+ } {
428
+ return {
429
+ isError: true,
430
+ content: [{
431
+ type: 'text' as const,
432
+ text: JSON.stringify({ status: 'blocked', reason }),
433
+ }],
434
+ };
435
+ }
436
+
437
+ async function logBlocked(
438
+ engine: EnforcementEngine,
439
+ role: ResolvedRole,
440
+ path: string,
441
+ operation: string,
442
+ reason: string
443
+ ): Promise<void> {
444
+ await engine.logOverride({
445
+ timestamp: new Date().toISOString(),
446
+ policy_violated: reason,
447
+ policy_text: reason,
448
+ action_requested: `${operation}: ${path}`,
449
+ human_confirmed: false,
450
+ agent_role: role.id,
451
+ rationale: 'Blocked by enforcement layer',
452
+ });
453
+ }