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,322 @@
1
+ /**
2
+ * EnforcementEngine — Validates agent actions against loaded policy.
3
+ *
4
+ * All validation happens in Node.js process memory. The agent never sees
5
+ * the policy files. It only sees the verdict: allowed, or blocked with reason.
6
+ *
7
+ * Two-layer enforcement:
8
+ * Layer 1 (skeleton): permissions.boundaries, scope paths, override_protocol
9
+ * Layer 2 (extensions): sensitive_patterns, cross_domain_rules, sensitivity_tiers
10
+ */
11
+
12
+ import { appendFile, mkdir } from 'node:fs/promises';
13
+ import { dirname, join, relative, isAbsolute } from 'node:path';
14
+ import { minimatch } from 'minimatch';
15
+ import type {
16
+ PolicyState,
17
+ ResolvedRole,
18
+ EnforcementVerdict,
19
+ OverrideLogEntry,
20
+ } from '../types.js';
21
+
22
+ export class EnforcementEngine {
23
+ constructor(
24
+ private state: PolicyState,
25
+ private activeRole: ResolvedRole
26
+ ) {}
27
+
28
+ /**
29
+ * Update references when policy reloads.
30
+ */
31
+ updateState(state: PolicyState, role: ResolvedRole): void {
32
+ this.state = state;
33
+ this.activeRole = role;
34
+ }
35
+
36
+ // ─── Write Validation ─────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Check if a write to the given path is allowed.
40
+ * Checks in order: governance forbidden → role excluded → role writable scope.
41
+ */
42
+ validateWrite(targetPath: string): EnforcementVerdict {
43
+ const relPath = this.toRelativePath(targetPath);
44
+
45
+ // 1. Governance-level forbidden paths (highest priority)
46
+ const forbidden = this.state.governance.permissions.boundaries.forbidden;
47
+ if (forbidden && this.matchesAny(relPath, forbidden)) {
48
+ return {
49
+ allowed: false,
50
+ reason: `Path "${relPath}" is in the forbidden list. This path must never be read or modified.`,
51
+ policy_ref: 'governance.json > permissions > boundaries > forbidden',
52
+ immutable: true,
53
+ };
54
+ }
55
+
56
+ // 2. Governance-level read_only paths
57
+ const readOnly = this.state.governance.permissions.boundaries.read_only;
58
+ if (readOnly && this.matchesAny(relPath, readOnly)) {
59
+ return {
60
+ allowed: false,
61
+ reason: `Path "${relPath}" is read-only per governance policy.`,
62
+ policy_ref: 'governance.json > permissions > boundaries > read_only',
63
+ immutable: false,
64
+ };
65
+ }
66
+
67
+ // 3. Role excluded paths
68
+ if (this.activeRole.excluded_paths.length > 0 &&
69
+ this.matchesAny(relPath, this.activeRole.excluded_paths)) {
70
+ return {
71
+ allowed: false,
72
+ reason: `Path "${relPath}" is excluded for role "${this.activeRole.id}".`,
73
+ policy_ref: `roles/${this.activeRole.id}.json > scope > excluded_paths`,
74
+ immutable: false,
75
+ };
76
+ }
77
+
78
+ // 4. Role writable scope — must be in writable_paths or secondary_paths
79
+ if (this.activeRole.writable_paths.length > 0) {
80
+ const inWritable = this.matchesAny(relPath, this.activeRole.writable_paths);
81
+ const inSecondary = this.activeRole.secondary_paths.length > 0 &&
82
+ this.matchesAny(relPath, this.activeRole.secondary_paths);
83
+
84
+ if (!inWritable && !inSecondary) {
85
+ return {
86
+ allowed: false,
87
+ reason: `Path "${relPath}" is outside the writable scope of role "${this.activeRole.id}".`,
88
+ policy_ref: `roles/${this.activeRole.id}.json > scope`,
89
+ immutable: false,
90
+ };
91
+ }
92
+ }
93
+
94
+ // 5. Governance-level writable whitelist (if defined, path must match)
95
+ const writable = this.state.governance.permissions.boundaries.writable;
96
+ if (writable && writable.length > 0 && !this.matchesAny(relPath, writable)) {
97
+ return {
98
+ allowed: false,
99
+ reason: `Path "${relPath}" is not in the governance writable list.`,
100
+ policy_ref: 'governance.json > permissions > boundaries > writable',
101
+ immutable: false,
102
+ };
103
+ }
104
+
105
+ return { allowed: true };
106
+ }
107
+
108
+ // ─── Read Validation ──────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Check if a read of the given path is allowed.
112
+ */
113
+ validateRead(targetPath: string): EnforcementVerdict {
114
+ const relPath = this.toRelativePath(targetPath);
115
+
116
+ // Governance-level forbidden
117
+ const forbidden = this.state.governance.permissions.boundaries.forbidden;
118
+ if (forbidden && this.matchesAny(relPath, forbidden)) {
119
+ return {
120
+ allowed: false,
121
+ reason: `Path "${relPath}" is forbidden. This path must never be read or modified.`,
122
+ policy_ref: 'governance.json > permissions > boundaries > forbidden',
123
+ immutable: true,
124
+ };
125
+ }
126
+
127
+ // Role excluded paths block reads too
128
+ if (this.activeRole.excluded_paths.length > 0 &&
129
+ this.matchesAny(relPath, this.activeRole.excluded_paths)) {
130
+ return {
131
+ allowed: false,
132
+ reason: `Path "${relPath}" is excluded for role "${this.activeRole.id}".`,
133
+ policy_ref: `roles/${this.activeRole.id}.json > scope > excluded_paths`,
134
+ immutable: false,
135
+ };
136
+ }
137
+
138
+ // Role readable scope — if defined, must match
139
+ if (this.activeRole.readable_paths.length > 0 &&
140
+ !this.matchesAny(relPath, this.activeRole.readable_paths)) {
141
+ return {
142
+ allowed: false,
143
+ reason: `Path "${relPath}" is outside the readable scope of role "${this.activeRole.id}".`,
144
+ policy_ref: `roles/${this.activeRole.id}.json > paths > read`,
145
+ immutable: false,
146
+ };
147
+ }
148
+
149
+ return { allowed: true };
150
+ }
151
+
152
+ // ─── Content Scanning ─────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Scan proposed file content for sensitive patterns.
156
+ * Uses governance.permissions.sensitive_patterns when present.
157
+ */
158
+ scanContent(content: string, targetPath: string): EnforcementVerdict {
159
+ const patterns = this.state.governance.permissions.sensitive_patterns;
160
+ if (!patterns || patterns.length === 0) return { allowed: true };
161
+
162
+ for (const sp of patterns) {
163
+ const regex = this.compilePattern(sp.pattern);
164
+ if (!regex) continue;
165
+
166
+ if (regex.test(content)) {
167
+ return {
168
+ allowed: false,
169
+ reason: `Content for "${targetPath}" contains a sensitive pattern: ${sp.reason}`,
170
+ policy_ref: 'governance.json > permissions > sensitive_patterns',
171
+ immutable: false,
172
+ };
173
+ }
174
+ }
175
+
176
+ return { allowed: true };
177
+ }
178
+
179
+ // ─── Cross-Domain Validation ──────────────────────────────────────────────
180
+
181
+ /**
182
+ * Validate that a cross-domain import respects boundaries.
183
+ * Uses governance.cross_domain_rules when present (extension field).
184
+ */
185
+ validateCrossDomain(sourcePath: string, importPath: string): EnforcementVerdict {
186
+ const rules = this.state.governance.cross_domain_rules;
187
+ if (!rules || !rules.shared_interfaces_path) return { allowed: true };
188
+
189
+ const domains = this.state.constitution.project.domains;
190
+ if (!domains || domains.length === 0) return { allowed: true };
191
+
192
+ const sourceDomain = this.getDomain(sourcePath, domains);
193
+ const importDomain = this.getDomain(importPath, domains);
194
+
195
+ // Same domain or can't determine — allow
196
+ if (!sourceDomain || !importDomain || sourceDomain === importDomain) {
197
+ return { allowed: true };
198
+ }
199
+
200
+ // Cross-domain — must go through shared interfaces
201
+ if (!importPath.includes(rules.shared_interfaces_path)) {
202
+ return {
203
+ allowed: false,
204
+ reason: `Cross-domain import from "${sourceDomain}" to "${importDomain}" must go through "${rules.shared_interfaces_path}". Direct import of "${importPath}" is not allowed.`,
205
+ policy_ref: 'governance.json > cross_domain_rules',
206
+ immutable: false,
207
+ };
208
+ }
209
+
210
+ return { allowed: true };
211
+ }
212
+
213
+ // ─── Override Protocol ────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Determine how to handle a policy violation based on the override protocol.
217
+ */
218
+ getOverrideBehavior(policyRef: string): {
219
+ behavior: 'block_and_log' | 'warn_confirm_and_log' | 'log_only';
220
+ isImmutable: boolean;
221
+ } {
222
+ const protocol = this.state.governance.override_protocol;
223
+ const behavior = protocol?.behavior ?? 'warn_confirm_and_log';
224
+ const immutable = protocol?.immutable_policies ?? [];
225
+
226
+ const isImmutable = immutable.some((p) => policyRef.includes(p));
227
+
228
+ return {
229
+ behavior: isImmutable ? 'block_and_log' : behavior,
230
+ isImmutable,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Log an override to the append-only overrides.jsonl file.
236
+ */
237
+ async logOverride(entry: OverrideLogEntry): Promise<void> {
238
+ const logPath = join(this.state.policyDir, 'state', 'overrides.jsonl');
239
+ await mkdir(dirname(logPath), { recursive: true });
240
+ const line = JSON.stringify(entry) + '\n';
241
+ await appendFile(logPath, line, 'utf-8');
242
+ }
243
+
244
+ // ─── Quality Gates ────────────────────────────────────────────────────────
245
+
246
+ /**
247
+ * Build the list of commands to run for quality gate validation.
248
+ * Maps pre_commit booleans to build_commands from constitution or governance.
249
+ */
250
+ getQualityGateCommands(): Array<{ name: string; command: string }> {
251
+ const gates = this.state.governance.quality_gate.pre_commit;
252
+ const commands = this.state.constitution.build_commands ??
253
+ this.state.governance.build_commands ??
254
+ {};
255
+
256
+ const result: Array<{ name: string; command: string }> = [];
257
+
258
+ if (gates.must_pass_tests && commands.test) {
259
+ result.push({ name: 'tests', command: commands.test });
260
+ }
261
+ if (gates.must_pass_lint && commands.lint) {
262
+ result.push({ name: 'lint', command: commands.lint });
263
+ }
264
+ if (gates.must_pass_typecheck && commands.typecheck) {
265
+ result.push({ name: 'typecheck', command: commands.typecheck });
266
+ }
267
+
268
+ // Custom checks from quality gate
269
+ if (gates.custom_checks) {
270
+ for (const check of gates.custom_checks) {
271
+ result.push({ name: check.name, command: check.command });
272
+ }
273
+ }
274
+
275
+ return result;
276
+ }
277
+
278
+ // ─── Private Helpers ──────────────────────────────────────────────────────
279
+
280
+ private matchesAny(path: string, patterns: string[]): boolean {
281
+ return patterns.some((pattern) => {
282
+ // Normalize: "compliance/" should match "compliance/src/index.ts"
283
+ const normalized = pattern.endsWith('/')
284
+ ? pattern + '**'
285
+ : pattern;
286
+ return minimatch(path, normalized, { dot: true });
287
+ });
288
+ }
289
+
290
+ private toRelativePath(targetPath: string): string {
291
+ if (isAbsolute(targetPath)) {
292
+ return relative(this.state.projectRoot, targetPath);
293
+ }
294
+ return targetPath;
295
+ }
296
+
297
+ private getDomain(
298
+ filePath: string,
299
+ domains: Array<{ name: string; path: string }>
300
+ ): string | null {
301
+ for (const domain of domains) {
302
+ const domainPath = domain.path.replace(/\/$/, '');
303
+ if (filePath.startsWith(domainPath + '/') || filePath.startsWith(domainPath)) {
304
+ return domain.name;
305
+ }
306
+ }
307
+ return null;
308
+ }
309
+
310
+ private compilePattern(pattern: string): RegExp | null {
311
+ try {
312
+ return new RegExp(pattern, 'gi');
313
+ } catch {
314
+ this.log(`Invalid regex in sensitive_patterns: ${pattern}`);
315
+ return null;
316
+ }
317
+ }
318
+
319
+ private log(message: string): void {
320
+ process.stderr.write(`[aegis-enforce] ${message}\n`);
321
+ }
322
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * PolicyLoader — Reads .agentpolicy/ files into process memory.
3
+ *
4
+ * Core of the zero-token-overhead design. All governance files are loaded
5
+ * into Node.js process memory on startup. The agent never sees these files —
6
+ * it only sees tool call results (allowed/blocked).
7
+ *
8
+ * Role resolution merges the skeleton fields (role.name, scope.primary_paths)
9
+ * with extension fields (paths.read/write, forbidden_actions) into a single
10
+ * ResolvedRole for fast enforcement lookups.
11
+ */
12
+
13
+ import { readFile, readdir, access } from 'node:fs/promises';
14
+ import { join, basename } from 'node:path';
15
+ import { watch } from 'chokidar';
16
+ import type {
17
+ PolicyState,
18
+ Constitution,
19
+ Governance,
20
+ RoleFile,
21
+ ResolvedRole,
22
+ AegisMcpConfig,
23
+ } from '../types.js';
24
+
25
+ export class PolicyLoader {
26
+ private state: PolicyState | null = null;
27
+ private watcher: ReturnType<typeof watch> | null = null;
28
+ private onReload?: () => void;
29
+
30
+ constructor(private config: AegisMcpConfig) {}
31
+
32
+ /**
33
+ * Load all policy files into memory. Call once on startup.
34
+ */
35
+ async load(): Promise<PolicyState> {
36
+ const policyDir = this.resolvePolicyDir();
37
+ await this.assertExists(policyDir, 'Policy directory');
38
+
39
+ const constitution = await this.loadJson<Constitution>(
40
+ join(policyDir, 'constitution.json'),
41
+ 'constitution.json'
42
+ );
43
+
44
+ const governance = await this.loadJson<Governance>(
45
+ join(policyDir, 'governance.json'),
46
+ 'governance.json'
47
+ );
48
+
49
+ const roles = await this.loadRoles(join(policyDir, 'roles'));
50
+
51
+ this.state = {
52
+ constitution,
53
+ governance,
54
+ roles,
55
+ projectRoot: this.config.projectRoot,
56
+ policyDir,
57
+ };
58
+
59
+ this.log(`Policy loaded: ${roles.size} role(s)`);
60
+ return this.state;
61
+ }
62
+
63
+ /**
64
+ * Get current policy state. Throws if not loaded.
65
+ */
66
+ getState(): PolicyState {
67
+ if (!this.state) {
68
+ throw new Error('Policy not loaded. Call load() first.');
69
+ }
70
+ return this.state;
71
+ }
72
+
73
+ /**
74
+ * Start watching .agentpolicy/ for changes and auto-reload.
75
+ */
76
+ startWatching(onReload?: () => void): void {
77
+ this.onReload = onReload;
78
+ const policyDir = this.resolvePolicyDir();
79
+
80
+ this.watcher = watch(policyDir, {
81
+ ignoreInitial: true,
82
+ awaitWriteFinish: { stabilityThreshold: 300 },
83
+ });
84
+
85
+ this.watcher.on('change', (path) => this.handleChange(path));
86
+ this.watcher.on('add', (path) => this.handleChange(path));
87
+ this.watcher.on('unlink', (path) => this.handleChange(path));
88
+
89
+ this.log('Watching policy directory for changes');
90
+ }
91
+
92
+ /**
93
+ * Stop watching and clean up.
94
+ */
95
+ async stopWatching(): Promise<void> {
96
+ if (this.watcher) {
97
+ await this.watcher.close();
98
+ this.watcher = null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get the resolved role for the configured agent, falling back to default.
104
+ */
105
+ getActiveRole(): ResolvedRole {
106
+ const state = this.getState();
107
+ const roleId = this.config.role;
108
+
109
+ const role = state.roles.get(roleId);
110
+ if (role) return role;
111
+
112
+ const defaultRole = state.roles.get('default');
113
+ if (defaultRole) {
114
+ this.log(`Role "${roleId}" not found, using default`);
115
+ return defaultRole;
116
+ }
117
+
118
+ // Synthesize a permissive default if no role files exist
119
+ this.log('No role files found, using synthesized permissive default');
120
+ return {
121
+ id: 'default',
122
+ name: 'default',
123
+ purpose: 'Synthesized default role — no role files found',
124
+ writable_paths: ['**/*'],
125
+ secondary_paths: [],
126
+ excluded_paths: [],
127
+ readable_paths: ['**/*'],
128
+ autonomy: 'advisory',
129
+ forbidden_actions: [],
130
+ };
131
+ }
132
+
133
+ // ─── Private ────────────────────────────────────────────────────────────────
134
+
135
+ private resolvePolicyDir(): string {
136
+ return join(
137
+ this.config.projectRoot,
138
+ this.config.policyDir ?? '.agentpolicy'
139
+ );
140
+ }
141
+
142
+ private async loadJson<T>(path: string, label: string): Promise<T> {
143
+ await this.assertExists(path, label);
144
+ const raw = await readFile(path, 'utf-8');
145
+ try {
146
+ return JSON.parse(raw) as T;
147
+ } catch (err) {
148
+ throw new Error(
149
+ `Failed to parse ${label}: ${err instanceof Error ? err.message : String(err)}`
150
+ );
151
+ }
152
+ }
153
+
154
+ private async loadRoles(rolesDir: string): Promise<Map<string, ResolvedRole>> {
155
+ const roles = new Map<string, ResolvedRole>();
156
+
157
+ try {
158
+ await access(rolesDir);
159
+ } catch {
160
+ return roles;
161
+ }
162
+
163
+ const entries = await readdir(rolesDir, { withFileTypes: true });
164
+ for (const entry of entries) {
165
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
166
+
167
+ const roleId = basename(entry.name, '.json');
168
+ const raw = await this.loadJson<RoleFile>(
169
+ join(rolesDir, entry.name),
170
+ `roles/${entry.name}`
171
+ );
172
+
173
+ roles.set(roleId, this.resolveRole(roleId, raw));
174
+ }
175
+
176
+ return roles;
177
+ }
178
+
179
+ /**
180
+ * Merge skeleton and extension fields into a single ResolvedRole.
181
+ *
182
+ * Skeleton: role.name, role.purpose, scope.primary_paths/secondary_paths/excluded_paths
183
+ * Extensions: paths.read/write, forbidden_actions, autonomy (flat string)
184
+ *
185
+ * For writable paths: scope.primary_paths takes precedence; paths.write used as fallback.
186
+ * For readable paths: paths.read used when present; otherwise derived from writable + secondary.
187
+ */
188
+ private resolveRole(id: string, raw: RoleFile): ResolvedRole {
189
+ // Role identity — skeleton nested object, or flat string + description
190
+ const name = typeof raw.role === 'object' ? raw.role.name : String(raw.role);
191
+ const purpose = typeof raw.role === 'object'
192
+ ? raw.role.purpose
193
+ : (raw.description ?? '');
194
+
195
+ // Writable paths — skeleton primary_paths, or extension paths.write
196
+ const writable_paths = raw.scope?.primary_paths?.length
197
+ ? raw.scope.primary_paths
198
+ : (raw.paths?.write ?? []);
199
+
200
+ // Secondary paths
201
+ const secondary_paths = raw.scope?.secondary_paths ?? [];
202
+
203
+ // Excluded paths
204
+ const excluded_paths = raw.scope?.excluded_paths ?? [];
205
+
206
+ // Readable paths — extension paths.read, or all writable + secondary
207
+ const readable_paths = raw.paths?.read?.length
208
+ ? raw.paths.read
209
+ : [...writable_paths, ...secondary_paths];
210
+
211
+ // Autonomy — flat extension string or skeleton override
212
+ const autonomy = raw.autonomy
213
+ ? String(raw.autonomy)
214
+ : 'advisory';
215
+
216
+ // Forbidden actions
217
+ const forbidden_actions = raw.forbidden_actions ?? [];
218
+
219
+ return {
220
+ id,
221
+ name,
222
+ purpose,
223
+ writable_paths,
224
+ secondary_paths,
225
+ excluded_paths,
226
+ readable_paths,
227
+ autonomy,
228
+ forbidden_actions,
229
+ };
230
+ }
231
+
232
+ private async handleChange(path: string): Promise<void> {
233
+ this.log(`Policy file changed: ${path}`);
234
+ try {
235
+ await this.load();
236
+ this.onReload?.();
237
+ } catch (err) {
238
+ this.log(
239
+ `Failed to reload policy: ${err instanceof Error ? err.message : String(err)}`
240
+ );
241
+ }
242
+ }
243
+
244
+ private async assertExists(path: string, label: string): Promise<void> {
245
+ try {
246
+ await access(path);
247
+ } catch {
248
+ throw new Error(`${label} not found at: ${path}`);
249
+ }
250
+ }
251
+
252
+ private log(message: string): void {
253
+ process.stderr.write(`[aegis-mcp] ${message}\n`);
254
+ }
255
+ }