doer-agent 0.9.12 → 0.9.13

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.
@@ -5,17 +5,25 @@ import { StringCodec } from "nats";
5
5
  import { extract as extractTar } from "tar";
6
6
  import { validateImageBytes } from "./agent-runtime-utils.js";
7
7
  const fsRpcCodec = StringCodec();
8
- function normalizeFsRpcPath(workspaceRoot, rawPath) {
8
+ function isPathInsideRoot(root, target) {
9
+ return target === root || target.startsWith(root + path.sep);
10
+ }
11
+ export function normalizeFsRpcPath(workspaceRoot, rawPath, options = {}) {
9
12
  const raw = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
10
13
  const normalizedRaw = raw.replace(/\\/g, "/");
11
14
  const useAbsolute = path.isAbsolute(normalizedRaw);
12
15
  const rel = normalizedRaw.replace(/^\/+/, "") || ".";
13
- const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(workspaceRoot, rel);
14
- if (abs !== workspaceRoot && !abs.startsWith(workspaceRoot + path.sep)) {
16
+ const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
17
+ const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(resolvedWorkspaceRoot, rel);
18
+ if (!options.allowOutsideWorkspace && !isPathInsideRoot(resolvedWorkspaceRoot, abs)) {
15
19
  throw new Error("path escapes workspace root");
16
20
  }
17
21
  const formatPath = (target) => {
18
- return path.relative(workspaceRoot, target).split(path.sep).join("/") || ".";
22
+ const resolvedTarget = path.resolve(target);
23
+ if (!isPathInsideRoot(resolvedWorkspaceRoot, resolvedTarget)) {
24
+ return resolvedTarget.split(path.sep).join("/") || "/";
25
+ }
26
+ return path.relative(resolvedWorkspaceRoot, resolvedTarget).split(path.sep).join("/") || ".";
19
27
  };
20
28
  return { abs, formatPath };
21
29
  }
@@ -90,7 +98,13 @@ function sha256Hex(bytes) {
90
98
  }
91
99
  async function executeFsRpc(args) {
92
100
  const action = parseFsRpcAction(args.request.action);
93
- const { abs, formatPath } = normalizeFsRpcPath(args.workspaceRoot, args.request.path);
101
+ const allowOutsideWorkspace = action === "list" ||
102
+ action === "stat" ||
103
+ action === "upload_file" ||
104
+ action === "read_text";
105
+ const { abs, formatPath } = normalizeFsRpcPath(args.workspaceRoot, args.request.path, {
106
+ allowOutsideWorkspace,
107
+ });
94
108
  if (action === "stat") {
95
109
  const entry = await stat(abs);
96
110
  return {
@@ -0,0 +1,78 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { StringCodec } from "nats";
7
+ import { handleFsRpcMessage, normalizeFsRpcPath } from "./agent-fs-rpc.js";
8
+ const workspaceRoot = path.resolve("/tmp/doer-workspace");
9
+ const outsidePath = path.resolve("/tmp/outside/file.csv");
10
+ test("normalizes workspace paths as workspace-relative paths", () => {
11
+ const target = normalizeFsRpcPath(workspaceRoot, "folder/file.txt");
12
+ assert.equal(target.abs, path.join(workspaceRoot, "folder/file.txt"));
13
+ assert.equal(target.formatPath(target.abs), "folder/file.txt");
14
+ });
15
+ test("allows and preserves absolute paths outside the workspace for read operations", () => {
16
+ const target = normalizeFsRpcPath(workspaceRoot, outsidePath, {
17
+ allowOutsideWorkspace: true,
18
+ });
19
+ assert.equal(target.abs, outsidePath);
20
+ assert.equal(target.formatPath(target.abs), outsidePath.split(path.sep).join("/"));
21
+ });
22
+ test("allows parent traversal outside the workspace for read operations", () => {
23
+ const target = normalizeFsRpcPath(workspaceRoot, "../outside/file.csv", {
24
+ allowOutsideWorkspace: true,
25
+ });
26
+ assert.equal(target.abs, outsidePath);
27
+ assert.equal(target.formatPath(target.abs), outsidePath.split(path.sep).join("/"));
28
+ });
29
+ test("keeps paths outside the workspace blocked by default", () => {
30
+ assert.throws(() => normalizeFsRpcPath(workspaceRoot, outsidePath), /path escapes workspace root/);
31
+ assert.throws(() => normalizeFsRpcPath(workspaceRoot, "../outside/file.csv"), /path escapes workspace root/);
32
+ });
33
+ test("filesystem RPC lists and reads files outside the workspace but does not delete them", async () => {
34
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "doer-fs-rpc-"));
35
+ const rpcWorkspaceRoot = path.join(tempRoot, "workspace");
36
+ const externalDir = path.join(tempRoot, "external");
37
+ const externalFile = path.join(externalDir, "example.txt");
38
+ const codec = StringCodec();
39
+ await mkdir(rpcWorkspaceRoot);
40
+ await mkdir(externalDir);
41
+ await writeFile(externalFile, "outside workspace");
42
+ async function request(action, targetPath) {
43
+ let responseData;
44
+ const msg = {
45
+ data: codec.encode(JSON.stringify({ action, path: targetPath })),
46
+ respond(data) {
47
+ responseData = data;
48
+ return true;
49
+ },
50
+ };
51
+ await handleFsRpcMessage({
52
+ msg,
53
+ workspaceRoot: rpcWorkspaceRoot,
54
+ serverBaseUrl: "https://example.com",
55
+ agentId: "agent-1",
56
+ agentToken: "token",
57
+ onError: () => undefined,
58
+ });
59
+ assert.ok(responseData);
60
+ return JSON.parse(codec.decode(responseData));
61
+ }
62
+ try {
63
+ const listing = await request("list", externalDir);
64
+ assert.equal(listing.ok, true);
65
+ assert.equal(listing.path, externalDir.split(path.sep).join("/"));
66
+ assert.deepEqual(listing.items.map((item) => item.path), [externalFile.split(path.sep).join("/")]);
67
+ const text = await request("read_text", externalFile);
68
+ assert.equal(text.ok, true);
69
+ assert.equal(text.text, "outside workspace");
70
+ const deletion = await request("delete_path", externalFile);
71
+ assert.equal(deletion.ok, false);
72
+ assert.match(String(deletion.error), /path escapes workspace root/);
73
+ assert.equal(await readFile(externalFile, "utf8"), "outside workspace");
74
+ }
75
+ finally {
76
+ await rm(tempRoot, { recursive: true, force: true });
77
+ }
78
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.12",
3
+ "version": "0.9.13",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",