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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/dist/services/enforcement-engine.d.ts +64 -0
- package/dist/services/enforcement-engine.d.ts.map +1 -0
- package/dist/services/enforcement-engine.js +271 -0
- package/dist/services/enforcement-engine.js.map +1 -0
- package/dist/services/policy-loader.d.ts +56 -0
- package/dist/services/policy-loader.d.ts.map +1 -0
- package/dist/services/policy-loader.js +202 -0
- package/dist/services/policy-loader.js.map +1 -0
- package/dist/tools/file-tools.d.ts +21 -0
- package/dist/tools/file-tools.d.ts.map +1 -0
- package/dist/tools/file-tools.js +369 -0
- package/dist/tools/file-tools.js.map +1 -0
- package/dist/types.d.ts +286 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +41 -0
- package/src/index.ts +171 -0
- package/src/services/enforcement-engine.ts +322 -0
- package/src/services/policy-loader.ts +255 -0
- package/src/tools/file-tools.ts +453 -0
- package/src/types.ts +305 -0
- package/tsconfig.json +20 -0
|
@@ -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
|
+
}
|