functionalscript 0.32.1 → 0.32.2
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/fs/cas/mcp/module.f.d.ts +2 -2
- package/fs/cas/mcp/module.f.js +79 -70
- package/fs/cas/mcp/proof.f.d.ts +3 -0
- package/fs/cas/mcp/proof.f.js +46 -16
- package/fs/fjs/module.f.js +1 -1
- package/package.json +1 -1
package/fs/cas/mcp/module.f.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export declare const casListArgs: {};
|
|
|
24
24
|
* the blob on the local filesystem. When absent (e.g. memory-backed tests),
|
|
25
25
|
* `url` is omitted.
|
|
26
26
|
*/
|
|
27
|
-
export declare const casMcpHandlers: <O extends Operation>(c: Cas<O>, toUrl?: (hash: Vec) => string) => McpHandlers<ReadFile | O>;
|
|
27
|
+
export declare const casMcpHandlers: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => McpHandlers<ReadFile | O>;
|
|
28
28
|
/**
|
|
29
29
|
* Static MCP configuration for the CAS server: advertises the `tools`
|
|
30
30
|
* capability, identifies the server, and pins the protocol version.
|
|
@@ -37,4 +37,4 @@ export declare const casConfig: McpConfig;
|
|
|
37
37
|
*
|
|
38
38
|
* When `toUrl` is provided, `cas_get` includes the blob's filesystem URL.
|
|
39
39
|
*/
|
|
40
|
-
export declare const casMcpServer: <O extends Operation>(c: Cas<O>, toUrl?: (hash: Vec) => string) => Effect<Read | Write | MemOp | ReadFile | O, void>;
|
|
40
|
+
export declare const casMcpServer: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => Effect<Read | Write | MemOp | ReadFile | O, void>;
|
package/fs/cas/mcp/module.f.js
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* without any encoding step.
|
|
24
24
|
* - `'base64'`: `content` is RFC 4648 base64, decoded to bytes before storage.
|
|
25
25
|
* Use this for pre-encoded binary payloads.
|
|
26
|
-
* - `'url'`: `content` is a filesystem path
|
|
27
|
-
* path and stores its raw bytes.
|
|
26
|
+
* - `'url'`: `content` is a filesystem path within `$HOME/cas_upload/`; the server
|
|
27
|
+
* reads the file at that path and stores its raw bytes. Paths outside
|
|
28
|
+
* `$HOME/cas_upload/` or containing `..` are rejected for security.
|
|
28
29
|
*
|
|
29
30
|
* ## `cas_get` output
|
|
30
31
|
*
|
|
@@ -83,44 +84,79 @@ export const casGetArgs = { hash: string, content: option(boolean) };
|
|
|
83
84
|
export const casListArgs = {};
|
|
84
85
|
// ── Tool registry ──────────────────────────────────────────────────────────────
|
|
85
86
|
/** Registry of all CAS tools. */
|
|
86
|
-
const casToolRegistry = (c, toUrl) =>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (key === null) {
|
|
110
|
-
return pure(errorResult(`invalid cBase32 hash: ${r.hash}`));
|
|
111
|
-
}
|
|
112
|
-
return c.read(key).step(value => {
|
|
113
|
-
if (value === undefined) {
|
|
114
|
-
return pure(errorResult(`no such hash: ${r.hash}`));
|
|
87
|
+
const casToolRegistry = (c, home, toUrl) => {
|
|
88
|
+
const casUploadDir = `${home}/cas_upload`;
|
|
89
|
+
return [
|
|
90
|
+
toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; type:"url" to read from a filesystem path within $HOME/cas_upload/; omit or pass type:"text" for UTF-8 text (default).', casAddArgs, ({ type, content }) => {
|
|
91
|
+
let x;
|
|
92
|
+
switch (type) {
|
|
93
|
+
case 'url':
|
|
94
|
+
if (!content.startsWith(`${casUploadDir}/`) || content.includes('..')) {
|
|
95
|
+
x = pure(`cas_add type:url paths must be within ${casUploadDir}/ — got: ${content}`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
x = readFile(content).step(([t, v]) => pure(t === 'error'
|
|
99
|
+
? `cannot read file: ${content}: ${v}`
|
|
100
|
+
: v));
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
case 'base64':
|
|
104
|
+
const value = base64Decode(content);
|
|
105
|
+
x = pure(value === null ? `invalid base64 content: ${content}` : value);
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
x = pure(utf8(content));
|
|
109
|
+
break;
|
|
115
110
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
111
|
+
return x.step(value => typeof value === 'string'
|
|
112
|
+
? pure(errorResult(value))
|
|
113
|
+
: c.write(value).step(hash => pure(okResult(vecToCBase32(hash)))));
|
|
114
|
+
}),
|
|
115
|
+
toolEntry('cas_get', 'Inspect a blob by hash. Always returns JSON {length,mime_type,type[,url]} where type is "text" or "base64". Pass content:true to also include the inline content string.', casGetArgs, r => {
|
|
116
|
+
const key = cBase32ToVec(r.hash);
|
|
117
|
+
if (key === null) {
|
|
118
|
+
return pure(errorResult(`invalid cBase32 hash: ${r.hash}`));
|
|
119
|
+
}
|
|
120
|
+
return c.read(key).step(value => {
|
|
121
|
+
if (value === undefined) {
|
|
122
|
+
return pure(errorResult(`no such hash: ${r.hash}`));
|
|
123
|
+
}
|
|
124
|
+
const byteLength = Number(bitVecLength(value) / 8n);
|
|
125
|
+
// Phase 1: magic-byte sniffing for known binary formats.
|
|
126
|
+
const detectedMime = detect(value);
|
|
127
|
+
if (detectedMime !== null) {
|
|
128
|
+
const url = toUrl?.(key);
|
|
129
|
+
const meta = {
|
|
130
|
+
length: byteLength,
|
|
131
|
+
mime_type: detectedMime,
|
|
132
|
+
type: 'base64',
|
|
133
|
+
...(url !== undefined && { url })
|
|
134
|
+
};
|
|
135
|
+
if (r.content === true) {
|
|
136
|
+
const blob = base64Encode(value);
|
|
137
|
+
return pure(blob === null
|
|
138
|
+
? errorResult(`content is not byte-aligned: ${r.hash}`)
|
|
139
|
+
: okResult(JSON.stringify({ ...meta, content: blob })));
|
|
140
|
+
}
|
|
141
|
+
return pure(okResult(JSON.stringify(meta)));
|
|
142
|
+
}
|
|
143
|
+
// Phase 2: UTF-8 validation — text if valid, octet-stream otherwise.
|
|
144
|
+
const str = fromVec(value);
|
|
120
145
|
const url = toUrl?.(key);
|
|
146
|
+
if (str !== null) {
|
|
147
|
+
const meta = {
|
|
148
|
+
length: byteLength,
|
|
149
|
+
mime_type: 'text/plain',
|
|
150
|
+
type: 'text',
|
|
151
|
+
...(url !== undefined && { url })
|
|
152
|
+
};
|
|
153
|
+
return pure(r.content === true
|
|
154
|
+
? okResult(JSON.stringify({ ...meta, content: str }))
|
|
155
|
+
: okResult(JSON.stringify(meta)));
|
|
156
|
+
}
|
|
121
157
|
const meta = {
|
|
122
158
|
length: byteLength,
|
|
123
|
-
mime_type:
|
|
159
|
+
mime_type: 'application/octet-stream',
|
|
124
160
|
type: 'base64',
|
|
125
161
|
...(url !== undefined && { url })
|
|
126
162
|
};
|
|
@@ -131,38 +167,11 @@ const casToolRegistry = (c, toUrl) => [
|
|
|
131
167
|
: okResult(JSON.stringify({ ...meta, content: blob })));
|
|
132
168
|
}
|
|
133
169
|
return pure(okResult(JSON.stringify(meta)));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const meta = {
|
|
140
|
-
length: byteLength,
|
|
141
|
-
mime_type: 'text/plain',
|
|
142
|
-
type: 'text',
|
|
143
|
-
...(url !== undefined && { url })
|
|
144
|
-
};
|
|
145
|
-
return pure(r.content === true
|
|
146
|
-
? okResult(JSON.stringify({ ...meta, content: str }))
|
|
147
|
-
: okResult(JSON.stringify(meta)));
|
|
148
|
-
}
|
|
149
|
-
const meta = {
|
|
150
|
-
length: byteLength,
|
|
151
|
-
mime_type: 'application/octet-stream',
|
|
152
|
-
type: 'base64',
|
|
153
|
-
...(url !== undefined && { url })
|
|
154
|
-
};
|
|
155
|
-
if (r.content === true) {
|
|
156
|
-
const blob = base64Encode(value);
|
|
157
|
-
return pure(blob === null
|
|
158
|
-
? errorResult(`content is not byte-aligned: ${r.hash}`)
|
|
159
|
-
: okResult(JSON.stringify({ ...meta, content: blob })));
|
|
160
|
-
}
|
|
161
|
-
return pure(okResult(JSON.stringify(meta)));
|
|
162
|
-
});
|
|
163
|
-
}),
|
|
164
|
-
toolEntry('cas_list', 'List all stored content hashes (cBase32), one per line.', casListArgs, () => c.list().step(hashes => pure(okResult(hashes.map(vecToCBase32).join('\n'))))),
|
|
165
|
-
];
|
|
170
|
+
});
|
|
171
|
+
}),
|
|
172
|
+
toolEntry('cas_list', 'List all stored content hashes (cBase32), one per line.', casListArgs, () => c.list().step(hashes => pure(okResult(hashes.map(vecToCBase32).join('\n'))))),
|
|
173
|
+
];
|
|
174
|
+
};
|
|
166
175
|
// ── Result helpers ──────────────────────────────────────────────────────────────
|
|
167
176
|
/** A successful single-text-block tool result. */
|
|
168
177
|
const okResult = (text) => ({ content: [{ type: 'text', text }] });
|
|
@@ -175,7 +184,7 @@ const okResult = (text) => ({ content: [{ type: 'text', text }] });
|
|
|
175
184
|
* the blob on the local filesystem. When absent (e.g. memory-backed tests),
|
|
176
185
|
* `url` is omitted.
|
|
177
186
|
*/
|
|
178
|
-
export const casMcpHandlers = (c, toUrl) => fromRegistry(casToolRegistry(c, toUrl));
|
|
187
|
+
export const casMcpHandlers = (c, home, toUrl) => fromRegistry(casToolRegistry(c, home, toUrl));
|
|
179
188
|
// ── Session configuration ───────────────────────────────────────────────────────
|
|
180
189
|
/**
|
|
181
190
|
* Static MCP configuration for the CAS server: advertises the `tools`
|
|
@@ -194,4 +203,4 @@ export const casConfig = {
|
|
|
194
203
|
*
|
|
195
204
|
* When `toUrl` is provided, `cas_get` includes the blob's filesystem URL.
|
|
196
205
|
*/
|
|
197
|
-
export const casMcpServer = (c, toUrl) => create(uninitializedState).step(key => stdioTransport(mcpStep(casConfig)(casMcpHandlers(c, toUrl))(key)));
|
|
206
|
+
export const casMcpServer = (c, home, toUrl) => create(uninitializedState).step(key => stdioTransport(mcpStep(casConfig)(casMcpHandlers(c, home, toUrl))(key)));
|
package/fs/cas/mcp/proof.f.d.ts
CHANGED
|
@@ -27,4 +27,7 @@ export declare const proof: {
|
|
|
27
27
|
getMetaNoUrlWhenToUrlAbsent: () => void;
|
|
28
28
|
getMetaMissingHashIsError: () => void;
|
|
29
29
|
getMetaInvalidHashIsError: () => void;
|
|
30
|
+
addUrlFromApprovedDirectorySucceeds: () => void;
|
|
31
|
+
addUrlFromRandomDirectoryIsRejected: () => void;
|
|
32
|
+
addUrlWithPathTraversalIsRejected: () => void;
|
|
30
33
|
};
|
package/fs/cas/mcp/proof.f.js
CHANGED
|
@@ -49,15 +49,15 @@ const feed = (step) => (msgs) => {
|
|
|
49
49
|
return go(0, []);
|
|
50
50
|
};
|
|
51
51
|
// Runs a full session over a fresh in-memory CAS, returning all responses.
|
|
52
|
-
const runSession = (msgs) => runMem(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
|
|
52
|
+
const runSession = (msgs, home = '/home/user') => runMem(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
|
|
53
53
|
const c = cas(sha256)(memKvStore(mapKey));
|
|
54
|
-
const step = mcpStep(casConfig)(casMcpHandlers(c))(sessionKey);
|
|
54
|
+
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
55
55
|
return feed(step)(msgs);
|
|
56
56
|
})));
|
|
57
57
|
// Runs a session with a mocked filesystem (for cas_add with type:'url' tests).
|
|
58
|
-
const runSessionWithFiles = (files) => (msgs) => runMemWithFiles(files)(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
|
|
58
|
+
const runSessionWithFiles = (files, home = '/home/user') => (msgs) => runMemWithFiles(files)(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
|
|
59
59
|
const c = cas(sha256)(memKvStore(mapKey));
|
|
60
|
-
const step = mcpStep(casConfig)(casMcpHandlers(c))(sessionKey);
|
|
60
|
+
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
61
61
|
return feed(step)(msgs);
|
|
62
62
|
})));
|
|
63
63
|
// ── Messages ────────────────────────────────────────────────────────────────────
|
|
@@ -211,26 +211,26 @@ export const proof = {
|
|
|
211
211
|
assert(!('error' in resp));
|
|
212
212
|
assert('result' in resp);
|
|
213
213
|
},
|
|
214
|
-
// cas_add with type:'url' reads a file and stores it.
|
|
214
|
+
// cas_add with type:'url' reads a file from /home/user/cas_upload/ and stores it.
|
|
215
215
|
addUrlStoresFileAndReturnsHash: () => {
|
|
216
216
|
const fileContent = utf8('hello from file');
|
|
217
|
-
const [addUrlResp] = runSessionWithFiles({ '/
|
|
217
|
+
const [addUrlResp] = runSessionWithFiles({ '/home/user/cas_upload/hello.txt': fileContent })([
|
|
218
218
|
init, initialized,
|
|
219
|
-
call(2, 'cas_add', { content: '/
|
|
219
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/hello.txt', type: 'url' }),
|
|
220
220
|
]).slice(2);
|
|
221
221
|
assert(!resultOf(addUrlResp).isError);
|
|
222
222
|
assert(textOf(addUrlResp).length > 0);
|
|
223
223
|
},
|
|
224
224
|
addUrlRoundTrips: () => {
|
|
225
225
|
const fileContent = utf8('round-trip content');
|
|
226
|
-
const msgs = runSessionWithFiles({ '/
|
|
226
|
+
const msgs = runSessionWithFiles({ '/home/user/cas_upload/rt.txt': fileContent })([
|
|
227
227
|
init, initialized,
|
|
228
|
-
call(2, 'cas_add', { content: '/
|
|
228
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
|
|
229
229
|
]).slice(2);
|
|
230
230
|
const hash = textOf(msgs[0]);
|
|
231
|
-
const msgs2 = runSessionWithFiles({ '/
|
|
231
|
+
const msgs2 = runSessionWithFiles({ '/home/user/cas_upload/rt.txt': fileContent })([
|
|
232
232
|
init, initialized,
|
|
233
|
-
call(2, 'cas_add', { content: '/
|
|
233
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
|
|
234
234
|
call(3, 'cas_get', { hash, content: true }),
|
|
235
235
|
]).slice(2);
|
|
236
236
|
assert(!resultOf(msgs2[1]).isError);
|
|
@@ -241,21 +241,21 @@ export const proof = {
|
|
|
241
241
|
addUrlMissingFileIsError: () => {
|
|
242
242
|
const [resp] = runSessionWithFiles({})([
|
|
243
243
|
init, initialized,
|
|
244
|
-
call(2, 'cas_add', { content: '/nonexistent
|
|
244
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/nonexistent.txt', type: 'url' }),
|
|
245
245
|
]).slice(2);
|
|
246
246
|
assertEq(resultOf(resp).isError, true);
|
|
247
247
|
},
|
|
248
248
|
// cas_get without content:true returns only metadata.
|
|
249
249
|
getMetaReturnsLengthAndMimeType: () => {
|
|
250
250
|
const fileContent = utf8('text content');
|
|
251
|
-
const [addResp] = runSessionWithFiles({ '/f': fileContent })([
|
|
251
|
+
const [addResp] = runSessionWithFiles({ '/home/user/cas_upload/f': fileContent })([
|
|
252
252
|
init, initialized,
|
|
253
|
-
call(2, 'cas_add', { content: '/f', type: 'url' }),
|
|
253
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
|
|
254
254
|
]).slice(2);
|
|
255
255
|
const hash = textOf(addResp);
|
|
256
|
-
const [, metaResp2] = runSessionWithFiles({ '/f': fileContent })([
|
|
256
|
+
const [, metaResp2] = runSessionWithFiles({ '/home/user/cas_upload/f': fileContent })([
|
|
257
257
|
init, initialized,
|
|
258
|
-
call(2, 'cas_add', { content: '/f', type: 'url' }),
|
|
258
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
|
|
259
259
|
call(3, 'cas_get', { hash }),
|
|
260
260
|
]).slice(2);
|
|
261
261
|
assert(!resultOf(metaResp2).isError);
|
|
@@ -307,4 +307,34 @@ export const proof = {
|
|
|
307
307
|
const [resp] = session(call(2, 'cas_get', { hash: 'bad!' }));
|
|
308
308
|
assertEq(resultOf(resp).isError, true);
|
|
309
309
|
},
|
|
310
|
+
// cas_add with type:'url' accepts paths within /home/user/cas_upload/
|
|
311
|
+
addUrlFromApprovedDirectorySucceeds: () => {
|
|
312
|
+
const fileContent = utf8('approved file');
|
|
313
|
+
const [resp] = runSessionWithFiles({ '/home/user/cas_upload/test.txt': fileContent })([
|
|
314
|
+
init, initialized,
|
|
315
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/test.txt', type: 'url' }),
|
|
316
|
+
]).slice(2);
|
|
317
|
+
assert(!resultOf(resp).isError);
|
|
318
|
+
assert(textOf(resp).length > 0);
|
|
319
|
+
},
|
|
320
|
+
// cas_add with type:'url' rejects paths outside /home/user/cas_upload/
|
|
321
|
+
addUrlFromRandomDirectoryIsRejected: () => {
|
|
322
|
+
const fileContent = utf8('forbidden file');
|
|
323
|
+
const [resp] = runSessionWithFiles({ '/tmp/secret.txt': fileContent })([
|
|
324
|
+
init, initialized,
|
|
325
|
+
call(2, 'cas_add', { content: '/tmp/secret.txt', type: 'url' }),
|
|
326
|
+
]).slice(2);
|
|
327
|
+
assert(resultOf(resp).isError === true);
|
|
328
|
+
assert(textOf(resp).includes('/home/user/cas_upload/'));
|
|
329
|
+
},
|
|
330
|
+
// cas_add with type:'url' rejects path traversal attempts with ..
|
|
331
|
+
addUrlWithPathTraversalIsRejected: () => {
|
|
332
|
+
const fileContent = utf8('secret content');
|
|
333
|
+
const [resp] = runSessionWithFiles({ '/home/user/cas_upload/../../etc/passwd': fileContent })([
|
|
334
|
+
init, initialized,
|
|
335
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/../../etc/passwd', type: 'url' }),
|
|
336
|
+
]).slice(2);
|
|
337
|
+
assert(resultOf(resp).isError === true);
|
|
338
|
+
assert(textOf(resp).includes('/home/user/cas_upload/'));
|
|
339
|
+
},
|
|
310
340
|
};
|
package/fs/fjs/module.f.js
CHANGED
|
@@ -35,7 +35,7 @@ const commands = [
|
|
|
35
35
|
description: 'Run an MCP server over stdio exposing the CAS as tools',
|
|
36
36
|
handler: ({ home }) => {
|
|
37
37
|
const c = cas(sha256)(fileKvStore(home));
|
|
38
|
-
return casMcpServer(c, hash => join(home, toPath(hash))).step(() => pure(0));
|
|
38
|
+
return casMcpServer(c, home, hash => join(home, toPath(hash))).step(() => pure(0));
|
|
39
39
|
},
|
|
40
40
|
},
|
|
41
41
|
{
|