@kortix/agent-tunnel 0.13.15 → 0.13.17
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 +28 -0
- package/dist/agent-cli.js +58 -23
- package/dist/client-cli.js +39 -1
- package/package.json +1 -1
- package/src/agent/capabilities/filesystem-integrity.test.ts +64 -0
- package/src/agent/capabilities/filesystem.ts +24 -6
- package/src/agent/security/path-validator.ts +5 -19
- package/src/client/cli.test.ts +14 -0
- package/src/client/cli.ts +34 -1
- package/src/client/tools.ts +4 -2
- package/src/index.ts +1 -0
- package/src/shared/filesystem-validation.ts +24 -0
- package/src/shared/index.ts +2 -0
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
|
|
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
|
-
|
|
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,
|
|
4565
|
+
await writeFile(path, bytes);
|
|
4540
4566
|
validateFilesystemPath(path, config, params);
|
|
4541
|
-
const
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
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");
|
package/dist/client-cli.js
CHANGED
|
@@ -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
|
@@ -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,
|
|
164
|
+
await writeFile(path, bytes);
|
|
155
165
|
validateFilesystemPath(path, config, params);
|
|
156
|
-
const
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
}
|
package/src/client/cli.test.ts
CHANGED
|
@@ -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;
|
package/src/client/tools.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|