-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathcanonical-factory.ts
More file actions
216 lines (202 loc) · 7.73 KB
/
Copy pathcanonical-factory.ts
File metadata and controls
216 lines (202 loc) · 7.73 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
import { readdir, readFile, stat, rm } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import type { ZodType } from 'zod';
import type { McpContext } from '../context.js';
import { McpError } from '../errors.js';
import { MAX_DIR_ENTRIES } from '../limits.js';
import { safeWrite } from '../writers/safe-write.js';
import { assertContainedPath } from '../writers/path-containment.js';
import { parseMd, serializeMd } from '../writers/md-frontmatter.js';
// Flat identifier only — `/` is intentionally excluded so canonical names
// cannot create hidden subdirectory layouts that `list` does not surface.
const NAME_RE = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/;
const PROTECTED_NAMES: Record<string, string> = { rules: '_root' };
export type CanonicalFeature = 'rules' | 'commands' | 'agents';
export interface CanonicalFactoryOpts<TSummary> {
feature: CanonicalFeature;
frontmatterSchema: ZodType<unknown>;
toSummary: (name: string, frontmatter: Record<string, unknown>) => TSummary;
}
export interface CanonicalHandlers<TSummary> {
list(ctx: McpContext): Promise<TSummary[]>;
get(
ctx: McpContext,
input: { name: string },
): Promise<{ name: string; frontmatter: Record<string, unknown>; body: string }>;
create(
ctx: McpContext,
input: { name: string; frontmatter: Record<string, unknown>; body: string; dry_run?: boolean },
): Promise<{ path: string; written: boolean }>;
update(
ctx: McpContext,
input: {
name: string;
frontmatter?: Record<string, unknown>;
body?: string;
merge?: boolean;
dry_run?: boolean;
},
): Promise<{ path: string; written: boolean }>;
delete(
ctx: McpContext,
input: { name: string; force?: boolean; dry_run?: boolean },
): Promise<{ path: string; deleted: boolean }>;
}
function checkName(name: string): void {
if (!NAME_RE.test(name) || name.includes('..')) {
throw new McpError('INVALID_NAME', `invalid name: ${name}`);
}
}
function pathFor(projectRoot: string, feature: string, name: string): string {
return resolve(projectRoot, '.agentsmesh', feature, `${name}.md`);
}
export function createCanonicalHandlers<TSummary>(
opts: CanonicalFactoryOpts<TSummary>,
): CanonicalHandlers<TSummary> {
const { feature, frontmatterSchema, toSummary } = opts;
const featureDir = (root: string): string => resolve(root, '.agentsmesh', feature);
async function listFiles(root: string): Promise<string[]> {
try {
const entries = await readdir(featureDir(root), { withFileTypes: true });
return entries.filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name);
} catch {
return [];
}
}
async function exists(file: string): Promise<boolean> {
try {
await stat(file);
return true;
} catch {
return false;
}
}
return {
async list(ctx) {
// Reject a symlinked feature dir before reading any file through it.
await assertContainedPath({
root: ctx.projectRoot,
target: featureDir(ctx.projectRoot),
message: `path escapes ${feature} directory`,
});
const files = await listFiles(ctx.projectRoot);
const out: TSummary[] = [];
for (const f of files) {
const name = f.replace(/\.md$/, '');
const src = await readFile(join(featureDir(ctx.projectRoot), f), 'utf8');
const { frontmatter } = parseMd(src);
out.push(toSummary(name, frontmatter));
}
return out;
},
async get(ctx, { name }) {
checkName(name);
const file = pathFor(ctx.projectRoot, feature, name);
await assertContainedPath({
root: featureDir(ctx.projectRoot),
target: file,
boundaryRoot: ctx.projectRoot,
message: `path escapes ${feature} directory`,
});
try {
const src = await readFile(file, 'utf8');
const { frontmatter, body } = parseMd(src);
return { name, frontmatter, body };
} catch (e: unknown) {
const errno = (e as NodeJS.ErrnoException).code;
if (errno === 'ENOENT') {
throw new McpError('NOT_FOUND', `${feature} "${name}" not found`);
}
throw new McpError('IO_ERROR', `failed to read ${feature}`, { errno });
}
},
async create(ctx, { name, frontmatter, body, dry_run }) {
checkName(name);
const file = pathFor(ctx.projectRoot, feature, name);
// Assert containment BEFORE the existence probe so a symlinked feature dir
// cannot leak an out-of-project filename-existence oracle via ALREADY_EXISTS.
await assertContainedPath({
root: featureDir(ctx.projectRoot),
target: file,
boundaryRoot: ctx.projectRoot,
message: `path escapes ${feature} directory`,
});
const parsed = frontmatterSchema.safeParse(frontmatter);
if (!parsed.success) {
throw new McpError('VALIDATION_FAILED', 'invalid frontmatter', parsed.error.issues);
}
if (await exists(file)) throw new McpError('ALREADY_EXISTS', `${feature} "${name}" exists`);
const all = await listFiles(ctx.projectRoot);
if (all.length >= MAX_DIR_ENTRIES) {
throw new McpError('LIMIT_EXCEEDED', `${feature} dir at ${MAX_DIR_ENTRIES} entries`);
}
const content = serializeMd(frontmatter, body);
if (dry_run === true) return { path: file, written: false };
await safeWrite({
projectRoot: ctx.projectRoot,
feature,
relativePath: `${name}.md`,
content,
});
return { path: file, written: true };
},
async update(ctx, { name, frontmatter, body, merge, dry_run }) {
checkName(name);
const file = pathFor(ctx.projectRoot, feature, name);
let current: { frontmatter: Record<string, unknown>; body: string };
await assertContainedPath({
root: featureDir(ctx.projectRoot),
target: file,
boundaryRoot: ctx.projectRoot,
message: `path escapes ${feature} directory`,
});
try {
const src = await readFile(file, 'utf8');
current = parseMd(src);
} catch (e: unknown) {
const errno = (e as NodeJS.ErrnoException).code;
if (errno === 'ENOENT') {
throw new McpError('NOT_FOUND', `${feature} "${name}" not found`);
}
throw new McpError('IO_ERROR', `failed to read ${feature}`, { errno });
}
const nextFm =
frontmatter === undefined
? current.frontmatter
: merge === true
? { ...current.frontmatter, ...frontmatter }
: frontmatter;
const parsed = frontmatterSchema.safeParse(nextFm);
if (!parsed.success) {
throw new McpError('VALIDATION_FAILED', 'invalid frontmatter', parsed.error.issues);
}
const nextBody = body !== undefined ? body : current.body;
const content = serializeMd(nextFm, nextBody);
if (dry_run === true) return { path: file, written: false };
await safeWrite({
projectRoot: ctx.projectRoot,
feature,
relativePath: `${name}.md`,
content,
});
return { path: file, written: true };
},
async delete(ctx, { name, force, dry_run }) {
checkName(name);
if (PROTECTED_NAMES[feature] === name && force !== true) {
throw new McpError('PROTECTED_FILE', `${feature} "${name}" requires force: true`);
}
const file = pathFor(ctx.projectRoot, feature, name);
await assertContainedPath({
root: featureDir(ctx.projectRoot),
target: file,
boundaryRoot: ctx.projectRoot,
message: `path escapes ${feature} directory`,
});
if (!(await exists(file))) throw new McpError('NOT_FOUND', `${feature} "${name}" not found`);
if (dry_run === true) return { path: file, deleted: false };
await rm(file);
return { path: file, deleted: true };
},
};
}