@kortix/agent-tunnel 0.13.15 → 0.13.16

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/README.md CHANGED
@@ -63,3 +63,31 @@ locally before enabling Computer Use. The tunnel uses an existing binary from
63
63
  `CUA_DRIVER_BIN`, `~/.local/bin`, `/usr/local/bin`, or `/opt/homebrew/bin`.
64
64
  Treat that binary as trusted local code. Agent Tunnel does not verify or update
65
65
  it.
66
+
67
+ ### Transfer a binary file without copying base64
68
+
69
+ Run the client where the source file exists. Set `TUNNEL_API_URL`, `TUNNEL_TOKEN`,
70
+ and `TUNNEL_ID` for the target connection, then run:
71
+
72
+ ```sh
73
+ agent-tunnel-cli fs_upload '{"source":"/tmp/report.xlsx","path":"/Users/me/Desktop/report.xlsx"}'
74
+ ```
75
+
76
+ `fs_upload` reads bytes from `source`, computes SHA-256, and sends the bytes
77
+ programmatically over the authenticated tunnel. It requires filesystem write
78
+ permission. A pending approval returns `success: false` and exit code 1; retry
79
+ only after approval. The command accepts regular files up to 3 MiB, which leaves
80
+ room under the relay's 5 MiB message limit after base64 encoding.
81
+
82
+ The connected agent checks the supplied `sha256` before modifying the destination.
83
+ It reads the file after writing and returns its persisted `sha256` and `size`.
84
+ The CLI succeeds only when both match the source. An older agent without checksum
85
+ support causes verification to fail; the file may already exist. Update the agent
86
+ before retrying.
87
+
88
+ For raw `fs.write`, `sha256` is optional for compatibility. Supply it for binary
89
+ content. Never copy an opaque base64 payload from model context. Generate the file
90
+ on the destination when a programmatic transfer is unavailable. A matching hash
91
+ proves byte integrity, not format validity: validate XLSX/ZIP structure and workbook
92
+ contents at the source. File size and magic bytes are insufficient. Do not use
93
+ public file-host relays for this workflow.
package/dist/agent-cli.js CHANGED
@@ -4369,6 +4369,39 @@ function createDesktopCapability() {
4369
4369
 
4370
4370
  // src/agent/capabilities/filesystem.ts
4371
4371
  import { open, writeFile, readdir, stat, unlink, mkdir } from "fs/promises";
4372
+ import { createHash as createHash2 } from "node:crypto";
4373
+
4374
+ // src/shared/filesystem-validation.ts
4375
+ function validateFilesystemParams(method, params) {
4376
+ if (!["fs.read", "fs.write", "fs.list", "fs.stat", "fs.delete"].includes(method))
4377
+ return null;
4378
+ if (typeof params.path !== "string" || !params.path.trim() || params.path.includes("\x00")) {
4379
+ return "path must be a non-empty string without null bytes";
4380
+ }
4381
+ if (method !== "fs.read" && method !== "fs.write")
4382
+ return null;
4383
+ if (params.encoding !== undefined && !["utf8", "utf-8", "base64"].includes(params.encoding)) {
4384
+ return 'Encoding must be "utf-8" or "base64"';
4385
+ }
4386
+ if (method !== "fs.write")
4387
+ return null;
4388
+ if (typeof params.content !== "string")
4389
+ return "Content must be a string";
4390
+ if (params.sha256 !== undefined && (typeof params.sha256 !== "string" || !/^[a-fA-F0-9]{64}$/.test(params.sha256))) {
4391
+ return "sha256 must be a 64-character hexadecimal SHA-256 digest";
4392
+ }
4393
+ if (params.encoding === "base64") {
4394
+ const content = params.content;
4395
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(content)) {
4396
+ return "Content must be canonical padded base64";
4397
+ }
4398
+ if (Buffer.from(content, "base64").toString("base64") !== content)
4399
+ return "Content must be canonical padded base64";
4400
+ }
4401
+ return null;
4402
+ }
4403
+
4404
+ // src/agent/capabilities/filesystem.ts
4372
4405
  import { join as join5, dirname as dirname3 } from "path";
4373
4406
 
4374
4407
  // src/agent/security/path-validator.ts
@@ -4377,7 +4410,7 @@ import { realpathSync as realpathSync2 } from "fs";
4377
4410
  function resolveExistingRoot(path) {
4378
4411
  const normalized = normalize(resolve(path));
4379
4412
  try {
4380
- return realpathSync2(normalized);
4413
+ return resolvePathForValidation(normalized);
4381
4414
  } catch {
4382
4415
  return normalized;
4383
4416
  }
@@ -4427,22 +4460,7 @@ function validatePath(path, allowedPaths, blockedPaths = []) {
4427
4460
  return resolved;
4428
4461
  }
4429
4462
  function validateWritePath(path, allowedPaths, blockedPaths = []) {
4430
- const resolved = validatePath(path, allowedPaths, blockedPaths);
4431
- let parent = dirname2(normalize(resolve(path)));
4432
- while (parent && parent !== dirname2(parent)) {
4433
- try {
4434
- const resolvedParent = realpathSync2(parent);
4435
- assertAllowedResolvedPath(path, resolvedParent, allowedPaths, blockedPaths);
4436
- return resolved;
4437
- } catch (err) {
4438
- const code = err.code;
4439
- if (code !== "ENOENT") {
4440
- throw new Error(`Access denied: cannot resolve parent for "${path}" (${code})`);
4441
- }
4442
- parent = dirname2(parent);
4443
- }
4444
- }
4445
- throw new Error(`Access denied: cannot resolve parent for "${path}"`);
4463
+ return validatePath(path, allowedPaths, blockedPaths);
4446
4464
  }
4447
4465
 
4448
4466
  // src/agent/capabilities/filesystem.ts
@@ -4522,6 +4540,9 @@ function createFilesystemCapability(config) {
4522
4540
  });
4523
4541
  methods.set("fs.write", async (params) => {
4524
4542
  assertFilesystemOperation(params, "fs.write");
4543
+ const validationError = validateFilesystemParams("fs.write", params);
4544
+ if (validationError)
4545
+ throw new Error(validationError);
4525
4546
  const path = params.path;
4526
4547
  const content = params.content;
4527
4548
  const encoding = parseEncoding(params.encoding);
@@ -4534,15 +4555,29 @@ function createFilesystemCapability(config) {
4534
4555
  if (contentBytes > maxFileSize) {
4535
4556
  throw new Error(`Content exceeds max size (${contentBytes} > ${maxFileSize})`);
4536
4557
  }
4558
+ const bytes = Buffer.from(content, encoding);
4559
+ const expectedHash = createHash2("sha256").update(bytes).digest("hex");
4560
+ if (params.sha256 !== undefined && params.sha256.toLowerCase() !== expectedHash) {
4561
+ throw new Error("SHA-256 mismatch: content differs from the source; destination was not modified");
4562
+ }
4537
4563
  await mkdir(dirname3(path), { recursive: true });
4538
4564
  validateFilesystemPath(path, config, params, true);
4539
- await writeFile(path, content, { encoding });
4565
+ await writeFile(path, bytes);
4540
4566
  validateFilesystemPath(path, config, params);
4541
- const stats = await stat(path);
4542
- return {
4543
- size: stats.size,
4544
- path
4545
- };
4567
+ const handle = await open(path, "r");
4568
+ try {
4569
+ const stats = await handle.stat();
4570
+ const persistedHash = createHash2("sha256").update(await handle.readFile()).digest("hex");
4571
+ if (persistedHash !== expectedHash)
4572
+ throw new Error("SHA-256 mismatch after write; destination verification failed");
4573
+ return {
4574
+ sha256: persistedHash,
4575
+ size: stats.size,
4576
+ path
4577
+ };
4578
+ } finally {
4579
+ await handle.close();
4580
+ }
4546
4581
  });
4547
4582
  methods.set("fs.list", async (params) => {
4548
4583
  assertFilesystemOperation(params, "fs.list");
@@ -2,6 +2,8 @@
2
2
 
3
3
  // src/client/cli.ts
4
4
  import { readFileSync } from "fs";
5
+ import { open } from "node:fs/promises";
6
+ import { createHash } from "node:crypto";
5
7
 
6
8
  // src/client/tunnel-client.ts
7
9
  function trimTrailingSlashes(value) {
@@ -205,6 +207,7 @@ var ALL_COMMANDS = [
205
207
  "status",
206
208
  "fs_read",
207
209
  "fs_write",
210
+ "fs_upload",
208
211
  "fs_list",
209
212
  "shell",
210
213
  "cua_ensure",
@@ -317,12 +320,44 @@ async function fsWrite(args) {
317
320
  const result = await call("fs.write", {
318
321
  path: args.path,
319
322
  content: args.content,
323
+ sha256: args.sha256,
320
324
  encoding: args.encoding || "utf-8"
321
325
  });
322
326
  if (result === null)
323
327
  return;
324
328
  const data = result;
325
- out({ success: true, path: data.path, size: data.size });
329
+ out({ success: true, path: data.path, size: data.size, sha256: data.sha256 });
330
+ }
331
+ async function fsUpload(args) {
332
+ if (typeof args.source !== "string" || !args.source)
333
+ return fail("source is required");
334
+ if (typeof args.path !== "string" || !args.path)
335
+ return fail("path is required");
336
+ const handle = await open(args.source, "r");
337
+ let bytes;
338
+ try {
339
+ const stats = await handle.stat();
340
+ if (!stats.isFile())
341
+ return fail("source must be a regular file");
342
+ if (stats.size > 3 * 1024 * 1024)
343
+ return fail("source exceeds the 3 MiB upload limit");
344
+ bytes = await handle.readFile();
345
+ if (bytes.length > 3 * 1024 * 1024)
346
+ return fail("source exceeds the 3 MiB upload limit");
347
+ } finally {
348
+ await handle.close();
349
+ }
350
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
351
+ const result = await call("fs.write", { path: args.path, content: bytes.toString("base64"), encoding: "base64", sha256 });
352
+ if (result === null) {
353
+ process.exitCode = 1;
354
+ return;
355
+ }
356
+ const data = result;
357
+ if (data.sha256 !== sha256 || data.size !== bytes.length) {
358
+ return fail("Destination verification failed. Update the connected agent if its response has no sha256. The file may have been written.");
359
+ }
360
+ out({ success: true, path: data.path, size: data.size, sha256 });
326
361
  }
327
362
  async function fsList(args) {
328
363
  const result = await call("fs.list", {
@@ -403,6 +438,9 @@ try {
403
438
  case "fs_write":
404
439
  await fsWrite(args);
405
440
  break;
441
+ case "fs_upload":
442
+ await fsUpload(args);
443
+ break;
406
444
  case "fs_list":
407
445
  await fsList(args);
408
446
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.13.15",
3
+ "version": "0.13.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Tunnel relay between cloud AI agents and local machines — server relay, local agent, client SDK, JSON-RPC, HMAC signing",
@@ -0,0 +1,64 @@
1
+ import { afterEach, expect, test } from 'bun:test';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { createFilesystemCapability } from './filesystem';
7
+ import type { TunnelConfig } from '../config';
8
+
9
+ const roots: string[] = [];
10
+ afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
11
+ async function fixture() {
12
+ const root = await mkdtemp(join(tmpdir(), 'tunnel-integrity-'));
13
+ roots.push(root);
14
+ const config: TunnelConfig = {
15
+ token: '', tunnelId: '', apiUrl: 'http://localhost', wsPath: '/ws',
16
+ maxFileSize: 1024 * 1024, allowedPaths: [root], blockedPaths: [],
17
+ allowedCommands: [], blockedCommands: [], workingDir: root,
18
+ shellTimeout: 1000, shellMaxTimeout: 1000, shellMaxOutputSize: 1024, shellEnvPassthrough: [],
19
+ };
20
+ return { path: join(root, 'artifact.xlsx'), write: createFilesystemCapability(config).methods.get('fs.write')!,
21
+ __permission: { permissionId: 'test', capability: 'filesystem', scope: { paths: [root], operations: ['write'] } } };
22
+ }
23
+ const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex');
24
+
25
+ test('binary write returns the persisted SHA-256 and preserves every byte', async () => {
26
+ const { write, ...params } = await fixture();
27
+ const bytes = Buffer.from(Array.from({ length: 65536 }, (_, i) => i % 256));
28
+ const result = await write({ ...params, content: bytes.toString('base64'), encoding: 'base64', sha256: sha256(bytes) });
29
+ expect(result).toMatchObject({ size: bytes.length, sha256: sha256(bytes) });
30
+ expect(await readFile(params.path)).toEqual(bytes);
31
+ });
32
+
33
+ test('same-length corruption fails before overwriting an existing destination', async () => {
34
+ const { write, ...params } = await fixture();
35
+ await writeFile(params.path, 'preserve me');
36
+ const source = Buffer.from('PK valid-looking archive');
37
+ const corrupt = Buffer.from(source); corrupt[10] ^= 1;
38
+ await expect(write({ ...params, content: corrupt.toString('base64'), encoding: 'base64', sha256: sha256(source) })).rejects.toThrow('SHA-256 mismatch');
39
+ expect(await readFile(params.path, 'utf8')).toBe('preserve me');
40
+ });
41
+
42
+ test('malformed base64 fails before writing', async () => {
43
+ const { write, ...params } = await fixture();
44
+ await expect(write({ ...params, content: 'aGVsbG8=!', encoding: 'base64' })).rejects.toThrow('base64');
45
+ expect(await Bun.file(params.path).exists()).toBe(false);
46
+ });
47
+
48
+ test('an exact-file approval permits that file and rejects its sibling', async () => {
49
+ const { write, ...params } = await fixture();
50
+ params.__permission.scope.paths = [params.path];
51
+ await write({ ...params, content: 'approved' });
52
+ expect(await readFile(params.path, 'utf8')).toBe('approved');
53
+ await expect(write({ ...params, path: params.path + '.other', content: 'denied' })).rejects.toThrow('outside allowed directories');
54
+ });
55
+
56
+ test('an allowed directory symlink cannot redirect a write outside the local ceiling', async () => {
57
+ const { symlink } = await import('node:fs/promises');
58
+ const { write, ...params } = await fixture();
59
+ const other = await fixture();
60
+ const link = params.path + '.link';
61
+ await symlink(dirname(other.path), link);
62
+ await expect(write({ ...params, path: join(link, 'escape.xlsx'), content: 'denied' })).rejects.toThrow('outside allowed directories');
63
+ expect(await Bun.file(dirname(other.path) + '/escape.xlsx').exists()).toBe(false);
64
+ });
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import { open, writeFile, readdir, stat, unlink, mkdir } from 'fs/promises';
9
+ import { createHash } from 'node:crypto';
10
+ import { validateFilesystemParams } from '../../shared/filesystem-validation';
9
11
  import { join, dirname } from 'path';
10
12
  import type { Capability, RpcHandler } from './index';
11
13
  import { validatePath, validateWritePath } from '../security/path-validator';
@@ -131,6 +133,8 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
131
133
 
132
134
  methods.set('fs.write', async (params) => {
133
135
  assertFilesystemOperation(params, 'fs.write');
136
+ const validationError = validateFilesystemParams('fs.write', params);
137
+ if (validationError) throw new Error(validationError);
134
138
  const path = params.path as string;
135
139
  const content = params.content as string;
136
140
  const encoding = parseEncoding(params.encoding);
@@ -148,17 +152,31 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
148
152
  throw new Error(`Content exceeds max size (${contentBytes} > ${maxFileSize})`);
149
153
  }
150
154
 
155
+ const bytes = Buffer.from(content, encoding);
156
+ const expectedHash = createHash('sha256').update(bytes).digest('hex');
157
+ if (params.sha256 !== undefined && (params.sha256 as string).toLowerCase() !== expectedHash) {
158
+ throw new Error('SHA-256 mismatch: content differs from the source; destination was not modified');
159
+ }
160
+
151
161
  await mkdir(dirname(path), { recursive: true });
152
162
  validateFilesystemPath(path, config, params, true);
153
163
 
154
- await writeFile(path, content, { encoding });
164
+ await writeFile(path, bytes);
155
165
  validateFilesystemPath(path, config, params);
156
- const stats = await stat(path);
166
+ const handle = await open(path, 'r');
167
+ try {
168
+ const stats = await handle.stat();
169
+ const persistedHash = createHash('sha256').update(await handle.readFile()).digest('hex');
170
+ if (persistedHash !== expectedHash) throw new Error('SHA-256 mismatch after write; destination verification failed');
157
171
 
158
- return {
159
- size: stats.size,
160
- path,
161
- };
172
+ return {
173
+ sha256: persistedHash,
174
+ size: stats.size,
175
+ path,
176
+ };
177
+ } finally {
178
+ await handle.close();
179
+ }
162
180
  });
163
181
 
164
182
  methods.set('fs.list', async (params) => {
@@ -14,7 +14,7 @@ import { realpathSync } from 'fs';
14
14
  function resolveExistingRoot(path: string): string {
15
15
  const normalized = normalize(resolve(path));
16
16
  try {
17
- return realpathSync(normalized);
17
+ return resolvePathForValidation(normalized);
18
18
  } catch {
19
19
  return normalized;
20
20
  }
@@ -86,22 +86,8 @@ export function validateWritePath(
86
86
  allowedPaths: string[],
87
87
  blockedPaths: string[] = [],
88
88
  ): string {
89
- const resolved = validatePath(path, allowedPaths, blockedPaths);
90
-
91
- let parent = dirname(normalize(resolve(path)));
92
- while (parent && parent !== dirname(parent)) {
93
- try {
94
- const resolvedParent = realpathSync(parent);
95
- assertAllowedResolvedPath(path, resolvedParent, allowedPaths, blockedPaths);
96
- return resolved;
97
- } catch (err) {
98
- const code = (err as NodeJS.ErrnoException).code;
99
- if (code !== 'ENOENT') {
100
- throw new Error(`Access denied: cannot resolve parent for "${path}" (${code})`);
101
- }
102
- parent = dirname(parent);
103
- }
104
- }
105
-
106
- throw new Error(`Access denied: cannot resolve parent for "${path}"`);
89
+ // Resolve the complete destination through its nearest existing ancestor.
90
+ // Checking the parent against an exact-file allowlist would reject the file
91
+ // that the user approved. validatePath also resolves missing allowlist roots.
92
+ return validatePath(path, allowedPaths, blockedPaths);
107
93
  }
@@ -184,6 +184,20 @@ describe('Agent Tunnel CLI', () => {
184
184
  expect(r.json!.size).toBe(11);
185
185
  });
186
186
 
187
+ test('fs_upload refuses an older agent response without checksum proof', async () => {
188
+ const source = resolve(import.meta.dir, '../../../../tests/fixtures/tunnel-integrity.xlsx');
189
+ const r = await runCli('fs_upload', JSON.stringify({ source, path: '/tmp/report.xlsx' }));
190
+ expect(r.exitCode).toBe(1);
191
+ expect(r.json?.success).toBe(false);
192
+ expect(r.json?.error).toContain('Destination verification failed');
193
+ });
194
+
195
+ test('fs_upload rejects a missing source before calling the server', async () => {
196
+ const r = await runCli('fs_upload', JSON.stringify({ path: '/tmp/report.xlsx' }));
197
+ expect(r.exitCode).toBe(1);
198
+ expect(r.json?.error).toBe('source is required');
199
+ });
200
+
187
201
  test('fs_list returns entries', async () => {
188
202
  const r = await runCli('fs_list', '{"path":"/tmp"}');
189
203
  expect(r.exitCode).toBe(0);
package/src/client/cli.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import { readFileSync } from 'fs';
8
+ import { open } from 'node:fs/promises';
9
+ import { createHash } from 'node:crypto';
8
10
  import { TunnelClient, TunnelClientError } from './tunnel-client';
9
11
 
10
12
  const S6_ENV_DIR = process.env.S6_ENV_DIR || '/run/s6/container_environment';
@@ -14,6 +16,7 @@ const ALL_COMMANDS = [
14
16
  'status',
15
17
  'fs_read',
16
18
  'fs_write',
19
+ 'fs_upload',
17
20
  'fs_list',
18
21
  'shell',
19
22
  'cua_ensure',
@@ -144,11 +147,38 @@ async function fsWrite(args: Record<string, unknown>) {
144
147
  const result = await call('fs.write', {
145
148
  path: args.path,
146
149
  content: args.content,
150
+ sha256: args.sha256,
147
151
  encoding: (args.encoding as string) || 'utf-8',
148
152
  });
149
153
  if (result === null) return;
150
154
  const data = result as Record<string, unknown>;
151
- out({ success: true, path: data.path, size: data.size });
155
+ out({ success: true, path: data.path, size: data.size, sha256: data.sha256 });
156
+ }
157
+
158
+ /** Read bytes locally: opaque payloads never pass through model output. */
159
+ async function fsUpload(args: Record<string, unknown>) {
160
+ if (typeof args.source !== 'string' || !args.source) return fail('source is required');
161
+ if (typeof args.path !== 'string' || !args.path) return fail('path is required');
162
+ const handle = await open(args.source, 'r');
163
+ let bytes: Buffer;
164
+ try {
165
+ const stats = await handle.stat();
166
+ if (!stats.isFile()) return fail('source must be a regular file');
167
+ // The relay and agent cap frames at 5 MiB. Leave room for base64 and RPC metadata.
168
+ if (stats.size > 3 * 1024 * 1024) return fail('source exceeds the 3 MiB upload limit');
169
+ bytes = await handle.readFile();
170
+ if (bytes.length > 3 * 1024 * 1024) return fail('source exceeds the 3 MiB upload limit');
171
+ } finally {
172
+ await handle.close();
173
+ }
174
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
175
+ const result = await call('fs.write', { path: args.path, content: bytes.toString('base64'), encoding: 'base64', sha256 });
176
+ if (result === null) { process.exitCode = 1; return; }
177
+ const data = result as Record<string, unknown>;
178
+ if (data.sha256 !== sha256 || data.size !== bytes.length) {
179
+ return fail('Destination verification failed. Update the connected agent if its response has no sha256. The file may have been written.');
180
+ }
181
+ out({ success: true, path: data.path, size: data.size, sha256 });
152
182
  }
153
183
 
154
184
  async function fsList(args: Record<string, unknown>) {
@@ -238,6 +268,9 @@ try {
238
268
  case 'fs_write':
239
269
  await fsWrite(args);
240
270
  break;
271
+ case 'fs_upload':
272
+ await fsUpload(args);
273
+ break;
241
274
  case 'fs_list':
242
275
  await fsList(args);
243
276
  break;
@@ -88,22 +88,24 @@ export function createTunnelTools(client: TunnelClient): TunnelToolDefinition[]
88
88
  },
89
89
  {
90
90
  name: 'tunnel_fs_write',
91
- description: `Write a file to a connected computer via Agent Tunnel. Creates parent directories if needed. Requires filesystem write permission.`,
91
+ description: `Write a file to a connected computer via Agent Tunnel. Creates parent directories if needed. Requires filesystem write permission. Never transcribe binary base64; use agent-tunnel-cli fs_upload with source and path, or generate on the destination.`,
92
92
  parameters: {
93
93
  tunnel_id: tunnelIdParam,
94
94
  path: { type: 'string', description: 'Absolute path for the file on the connected computer', required: true },
95
95
  content: { type: 'string', description: 'File content to write', required: true },
96
+ sha256: { type: 'string', description: 'Source SHA-256; mismatch rejects the write before changing the destination', required: false },
96
97
  encoding: { type: 'string', description: 'File encoding (default: utf-8)', required: false },
97
98
  },
98
99
  async execute(args) {
99
100
  const result = await client.rpcWithPermissionFlow('fs.write', {
100
101
  path: args.path,
101
102
  content: args.content,
103
+ sha256: args.sha256,
102
104
  encoding: (args.encoding as string) || 'utf-8',
103
105
  });
104
106
  if (typeof result === 'string') return result;
105
107
  const data = result as Record<string, unknown>;
106
- return `File written: ${data.path} (${data.size} bytes)`;
108
+ return `File written: ${data.path} (${data.size} bytes), SHA-256: ${data.sha256}`;
107
109
  },
108
110
  },
109
111
  {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  isTunnelCapability,
14
14
  operationForMethod,
15
15
  validateTunnelPermissionScope,
16
+ validateFilesystemParams,
16
17
  } from './shared';
17
18
 
18
19
  export type {
@@ -0,0 +1,24 @@
1
+ /** Validate file arguments before requesting permission or touching the destination. */
2
+ export function validateFilesystemParams(method: string, params: Record<string, unknown>): string | null {
3
+ if (!['fs.read', 'fs.write', 'fs.list', 'fs.stat', 'fs.delete'].includes(method)) return null;
4
+ if (typeof params.path !== 'string' || !params.path.trim() || params.path.includes('\0')) {
5
+ return 'path must be a non-empty string without null bytes';
6
+ }
7
+ if (method !== 'fs.read' && method !== 'fs.write') return null;
8
+ if (params.encoding !== undefined && !['utf8', 'utf-8', 'base64'].includes(params.encoding as string)) {
9
+ return 'Encoding must be "utf-8" or "base64"';
10
+ }
11
+ if (method !== 'fs.write') return null;
12
+ if (typeof params.content !== 'string') return 'Content must be a string';
13
+ if (params.sha256 !== undefined && (typeof params.sha256 !== 'string' || !/^[a-fA-F0-9]{64}$/.test(params.sha256))) {
14
+ return 'sha256 must be a 64-character hexadecimal SHA-256 digest';
15
+ }
16
+ if (params.encoding === 'base64') {
17
+ const content = params.content;
18
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(content)) {
19
+ return 'Content must be canonical padded base64';
20
+ }
21
+ if (Buffer.from(content, 'base64').toString('base64') !== content) return 'Content must be canonical padded base64';
22
+ }
23
+ return null;
24
+ }
@@ -41,3 +41,5 @@ export {
41
41
  validateTunnelPermissionScope,
42
42
  } from './permissions';
43
43
  export type { PermissionScopeValidationResult } from './permissions';
44
+
45
+ export { validateFilesystemParams } from './filesystem-validation';