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.
@@ -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>;
@@ -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; the server reads the file at that
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
- toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; type:"url" to read from a filesystem path; omit or pass type:"text" for UTF-8 text (default).', casAddArgs, ({ type, content }) => {
88
- let x;
89
- switch (type) {
90
- case 'url':
91
- x = readFile(content).step(([t, v]) => pure(t === 'error'
92
- ? `cannot read file: ${content}: ${v}`
93
- : v));
94
- break;
95
- case 'base64':
96
- const value = base64Decode(content);
97
- x = pure(value === null ? `invalid base64 content: ${content}` : value);
98
- break;
99
- default:
100
- x = pure(utf8(content));
101
- break;
102
- }
103
- return x.step(value => typeof value === 'string'
104
- ? pure(errorResult(value))
105
- : c.write(value).step(hash => pure(okResult(vecToCBase32(hash)))));
106
- }),
107
- 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 => {
108
- const key = cBase32ToVec(r.hash);
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
- const byteLength = Number(bitVecLength(value) / 8n);
117
- // Phase 1: magic-byte sniffing for known binary formats.
118
- const detectedMime = detect(value);
119
- if (detectedMime !== null) {
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: detectedMime,
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
- // Phase 2: UTF-8 validation — text if valid, octet-stream otherwise.
136
- const str = fromVec(value);
137
- const url = toUrl?.(key);
138
- if (str !== null) {
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)));
@@ -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
  };
@@ -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({ '/tmp/hello.txt': fileContent })([
217
+ const [addUrlResp] = runSessionWithFiles({ '/home/user/cas_upload/hello.txt': fileContent })([
218
218
  init, initialized,
219
- call(2, 'cas_add', { content: '/tmp/hello.txt', type: 'url' }),
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({ '/tmp/rt.txt': fileContent })([
226
+ const msgs = runSessionWithFiles({ '/home/user/cas_upload/rt.txt': fileContent })([
227
227
  init, initialized,
228
- call(2, 'cas_add', { content: '/tmp/rt.txt', type: 'url' }),
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({ '/tmp/rt.txt': fileContent })([
231
+ const msgs2 = runSessionWithFiles({ '/home/user/cas_upload/rt.txt': fileContent })([
232
232
  init, initialized,
233
- call(2, 'cas_add', { content: '/tmp/rt.txt', type: 'url' }),
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/path.txt', type: 'url' }),
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
  };
@@ -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
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "functionalscript",
3
- "version": "0.32.1",
3
+ "version": "0.32.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "**/*.js",