@render-harness/cap-filesystem 0.1.1
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 +68 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +283 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/skills/filesystem.md +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# `@render-harness/cap-filesystem`
|
|
2
|
+
|
|
3
|
+
Path-scoped filesystem tools for agents in the Render harness. Opt-in per agent.
|
|
4
|
+
|
|
5
|
+
## Why this isn't a core builtin
|
|
6
|
+
|
|
7
|
+
The production worker pserv is multi-tenant: one Node process holds every tenant's env vars and runs every tenant's agent against a shared filesystem. A core-default filesystem tool would let any prompt-injected agent exfiltrate secrets from `/proc/self/environ` or read another tenant's files off a mounted disk.
|
|
8
|
+
|
|
9
|
+
This pack makes filesystem access an explicit per-agent decision: you opt in, you choose the root path, and you decide whether writes are allowed.
|
|
10
|
+
|
|
11
|
+
When a future per-run sandboxed runtime exists, an unscoped filesystem tool becomes safe by default. Until then, every agent that needs files uses this pack.
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
All paths are joined to the configured `root` and rejected if they escape it (including via `..` traversal or symlinks pointing outside).
|
|
16
|
+
|
|
17
|
+
| Tool | Description |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `fs.read_file({ path })` | Read a UTF-8 text file. Output capped at `maxBytes`; full body recoverable via `fetch_full_result`. |
|
|
20
|
+
| `fs.list_dir({ path })` | List directory entries with type (`dir` / `file` / `link` / `other`) and file sizes. |
|
|
21
|
+
| `fs.write_file({ path, content })` | Write a UTF-8 file. Auto-creates parent directories. Disabled when `readOnly: true`. |
|
|
22
|
+
| `fs.delete_file({ path })` | Delete a file. Refuses to delete directories. Disabled when `readOnly: true`. |
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
capabilities:
|
|
28
|
+
- pack: "@render-harness/cap-filesystem"
|
|
29
|
+
config:
|
|
30
|
+
root: "/var/data/agent-workspace" # required, absolute path
|
|
31
|
+
readOnly: false # optional, default false
|
|
32
|
+
maxBytes: 1048576 # optional, default 1 MB
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Key | Type | Default | Notes |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| `root` | string | — | **Required.** Absolute path the agent can read/write under. |
|
|
38
|
+
| `readOnly` | boolean | `false` | When true, only `fs.read_file` and `fs.list_dir` register. |
|
|
39
|
+
| `maxBytes` | integer | `1048576` | Cap for read truncation and write rejection. |
|
|
40
|
+
|
|
41
|
+
## On Render
|
|
42
|
+
|
|
43
|
+
On Render, point `root` at a [persistent disk](https://render.com/docs/disks) mount (e.g. `/var/data`) so the files survive across deploys. Without a disk, `root` must point at the container's ephemeral filesystem, which is wiped on every redeploy.
|
|
44
|
+
|
|
45
|
+
## Multi-tenant safety
|
|
46
|
+
|
|
47
|
+
This pack alone does NOT make the worker multi-tenant safe — it scopes the *path*, not the *tenant*. If you run a multi-tenant agent and want each tenant to see only their own files, set the root per tenant at agent-definition time:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
defineAgent({
|
|
51
|
+
// ...
|
|
52
|
+
capabilities: [
|
|
53
|
+
{
|
|
54
|
+
pack: "@render-harness/cap-filesystem",
|
|
55
|
+
config: { root: `/var/data/tenants/${tenantId}` },
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The harness runs one `defineAgent()` call per tenant in this shape; the pack instances don't share scopes.
|
|
62
|
+
|
|
63
|
+
## Test commands
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
pnpm --filter @render-harness/cap-filesystem build
|
|
67
|
+
pnpm --filter @render-harness/cap-filesystem test
|
|
68
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as _render_harness_registry from '@render-harness/registry';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cap-filesystem — path-scoped filesystem tools for the agent.
|
|
5
|
+
*
|
|
6
|
+
* Provides four tools, all hard-scoped to a configured `root` directory:
|
|
7
|
+
*
|
|
8
|
+
* - `fs.read_file({ path })` — read a UTF-8 text file.
|
|
9
|
+
* - `fs.list_dir({ path })` — list a directory's entries.
|
|
10
|
+
* - `fs.write_file({ path, content })` — write a file (creates parents).
|
|
11
|
+
* - `fs.delete_file({ path })` — delete a file.
|
|
12
|
+
*
|
|
13
|
+
* Tool names get prefixed with the pack name on registration, so the
|
|
14
|
+
* model sees them as `cap-filesystem.fs.read_file` etc.
|
|
15
|
+
*
|
|
16
|
+
* Why this is NOT a core builtin: the production worker pserv is
|
|
17
|
+
* multi-tenant. Filesystem access on a process holding every tenant's
|
|
18
|
+
* env vars and disk-mounted user data is unsafe by default. This pack
|
|
19
|
+
* makes it explicit and per-agent: you opt in, you choose the root, and
|
|
20
|
+
* you decide whether writes are allowed.
|
|
21
|
+
*
|
|
22
|
+
* Usage in render-harness.yaml:
|
|
23
|
+
*
|
|
24
|
+
* capabilities:
|
|
25
|
+
* - pack: "@render-harness/cap-filesystem"
|
|
26
|
+
* config:
|
|
27
|
+
* root: "/var/data/agent-workspace" # required; absolute path
|
|
28
|
+
* readOnly: false # optional; default false
|
|
29
|
+
* maxBytes: 1048576 # optional read/write cap
|
|
30
|
+
*
|
|
31
|
+
* Path scoping enforces:
|
|
32
|
+
* - Inputs are joined to root and resolved.
|
|
33
|
+
* - The resolved path must remain under root (`..` traversal blocked).
|
|
34
|
+
* - Symlinks are followed via realpath and re-checked against root, so
|
|
35
|
+
* a symlink under root pointing at /etc/passwd cannot escape.
|
|
36
|
+
*/
|
|
37
|
+
declare const pack: _render_harness_registry.CapabilityPack;
|
|
38
|
+
|
|
39
|
+
export { pack as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { dirname, join, resolve, sep } from 'path';
|
|
2
|
+
import { realpath, mkdir, writeFile, stat, rm, readFile, readdir } from 'fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import '@render-harness/core';
|
|
5
|
+
import { definePack } from '@render-harness/registry';
|
|
6
|
+
|
|
7
|
+
// src/index.ts
|
|
8
|
+
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
var SKILLS_DIR = join(HERE, "..", "skills");
|
|
10
|
+
var DEFAULT_MAX_BYTES = 1048576;
|
|
11
|
+
var pack = definePack({
|
|
12
|
+
name: "cap-filesystem",
|
|
13
|
+
version: "0.1.0",
|
|
14
|
+
envSchema: [],
|
|
15
|
+
async localTools(ctx) {
|
|
16
|
+
const cfg = ctx.config ?? {};
|
|
17
|
+
if (!cfg.root || typeof cfg.root !== "string") {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"cap-filesystem: `config.root` is required (absolute path the agent can read/write)."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const resolvedRoot = resolve(cfg.root);
|
|
23
|
+
let root;
|
|
24
|
+
try {
|
|
25
|
+
root = await realpath(resolvedRoot);
|
|
26
|
+
} catch {
|
|
27
|
+
root = resolvedRoot;
|
|
28
|
+
}
|
|
29
|
+
const readOnly = cfg.readOnly === true;
|
|
30
|
+
const maxBytes = clampPositive(cfg.maxBytes, DEFAULT_MAX_BYTES);
|
|
31
|
+
const tools = [readFileTool(root, maxBytes), listDirTool(root)];
|
|
32
|
+
if (!readOnly) {
|
|
33
|
+
tools.push(writeFileTool(root, maxBytes), deleteFileTool(root));
|
|
34
|
+
}
|
|
35
|
+
return tools;
|
|
36
|
+
},
|
|
37
|
+
skills(_ctx) {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
name: "filesystem",
|
|
41
|
+
description: "Use the path-scoped filesystem tools to read, list, write, and delete files.",
|
|
42
|
+
whenToUse: "When the user asks you to inspect, edit, or save files. All paths are scoped to a configured root; you cannot escape it.",
|
|
43
|
+
contentPath: join(SKILLS_DIR, "filesystem.md")
|
|
44
|
+
}
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
var src_default = pack;
|
|
49
|
+
function resolveScoped(root, requested) {
|
|
50
|
+
if (typeof requested !== "string" || requested.length === 0) {
|
|
51
|
+
return { ok: false, message: "path must be a non-empty string" };
|
|
52
|
+
}
|
|
53
|
+
const joined = resolve(root, requested);
|
|
54
|
+
if (!isUnder(joined, root)) {
|
|
55
|
+
return { ok: false, message: `path "${requested}" escapes the configured root` };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, abs: joined };
|
|
58
|
+
}
|
|
59
|
+
function isUnder(child, parent) {
|
|
60
|
+
const rel = parent.endsWith(sep) ? parent : parent + sep;
|
|
61
|
+
return child === parent || child.startsWith(rel);
|
|
62
|
+
}
|
|
63
|
+
async function realpathScoped(root, abs) {
|
|
64
|
+
let real;
|
|
65
|
+
try {
|
|
66
|
+
real = await realpath(abs);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (isNotFound(err)) {
|
|
69
|
+
return { ok: false, message: "ENOENT" };
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, message: err.message };
|
|
72
|
+
}
|
|
73
|
+
if (!isUnder(real, root)) {
|
|
74
|
+
return { ok: false, message: `symlink target "${real}" escapes the configured root` };
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, abs: real };
|
|
77
|
+
}
|
|
78
|
+
function isNotFound(err) {
|
|
79
|
+
return err instanceof Error && err.code === "ENOENT";
|
|
80
|
+
}
|
|
81
|
+
function readFileTool(root, maxBytes) {
|
|
82
|
+
return {
|
|
83
|
+
definition: {
|
|
84
|
+
name: "fs.read_file",
|
|
85
|
+
description: `Read a UTF-8 text file under the configured root. Returns the file contents (capped at ${maxBytes} bytes).`,
|
|
86
|
+
source: "pack:cap-filesystem",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: "object",
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
properties: {
|
|
91
|
+
path: {
|
|
92
|
+
type: "string",
|
|
93
|
+
description: "Path relative to (or absolute under) the configured root."
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
required: ["path"]
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
handler: async ({ input }) => {
|
|
100
|
+
const path = input?.path ?? "";
|
|
101
|
+
const r = resolveScoped(root, path);
|
|
102
|
+
if (!r.ok) return { content: `fs.read_file: ${r.message}`, isError: true };
|
|
103
|
+
const real = await realpathScoped(root, r.abs);
|
|
104
|
+
if (!real.ok) {
|
|
105
|
+
if (real.message === "ENOENT") {
|
|
106
|
+
return { content: `fs.read_file: file not found: ${path}`, isError: true };
|
|
107
|
+
}
|
|
108
|
+
return { content: `fs.read_file: ${real.message}`, isError: true };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const buf = await readFile(real.abs);
|
|
112
|
+
if (buf.byteLength > maxBytes) {
|
|
113
|
+
const truncated = buf.subarray(0, maxBytes).toString("utf8");
|
|
114
|
+
return {
|
|
115
|
+
content: `${truncated}
|
|
116
|
+
|
|
117
|
+
[... truncated at ${maxBytes} bytes; full file was ${buf.byteLength} bytes ...]`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { content: buf.toString("utf8") };
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return {
|
|
123
|
+
content: `fs.read_file: ${err.message}`,
|
|
124
|
+
isError: true
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function listDirTool(root) {
|
|
131
|
+
return {
|
|
132
|
+
definition: {
|
|
133
|
+
name: "fs.list_dir",
|
|
134
|
+
description: "List entries in a directory under the configured root.",
|
|
135
|
+
source: "pack:cap-filesystem",
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: "object",
|
|
138
|
+
additionalProperties: false,
|
|
139
|
+
properties: {
|
|
140
|
+
path: {
|
|
141
|
+
type: "string",
|
|
142
|
+
description: "Path relative to the configured root. Use '.' for root itself."
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
handler: async ({ input }) => {
|
|
148
|
+
const path = input?.path ?? ".";
|
|
149
|
+
const r = resolveScoped(root, path);
|
|
150
|
+
if (!r.ok) return { content: `fs.list_dir: ${r.message}`, isError: true };
|
|
151
|
+
const real = await realpathScoped(root, r.abs);
|
|
152
|
+
if (!real.ok) {
|
|
153
|
+
if (real.message === "ENOENT") {
|
|
154
|
+
return { content: `fs.list_dir: directory not found: ${path}`, isError: true };
|
|
155
|
+
}
|
|
156
|
+
return { content: `fs.list_dir: ${real.message}`, isError: true };
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const entries = await readdir(real.abs, { withFileTypes: true });
|
|
160
|
+
if (entries.length === 0) return { content: "(empty directory)" };
|
|
161
|
+
const lines = await Promise.all(
|
|
162
|
+
entries.map(async (e) => {
|
|
163
|
+
const full = join(real.abs, e.name);
|
|
164
|
+
const kind = e.isDirectory() ? "dir" : e.isSymbolicLink() ? "link" : e.isFile() ? "file" : "other";
|
|
165
|
+
if (kind === "file") {
|
|
166
|
+
try {
|
|
167
|
+
const s = await stat(full);
|
|
168
|
+
return `${kind} ${e.name} ${s.size}b`;
|
|
169
|
+
} catch {
|
|
170
|
+
return `${kind} ${e.name}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return `${kind} ${e.name}`;
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
return { content: lines.join("\n") };
|
|
177
|
+
} catch (err) {
|
|
178
|
+
return { content: `fs.list_dir: ${err.message}`, isError: true };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function writeFileTool(root, maxBytes) {
|
|
184
|
+
return {
|
|
185
|
+
definition: {
|
|
186
|
+
name: "fs.write_file",
|
|
187
|
+
description: `Write a UTF-8 text file under the configured root. Creates parent directories. Cap: ${maxBytes} bytes.`,
|
|
188
|
+
source: "pack:cap-filesystem",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: "object",
|
|
191
|
+
additionalProperties: false,
|
|
192
|
+
properties: {
|
|
193
|
+
path: { type: "string", description: "Path relative to the configured root." },
|
|
194
|
+
content: { type: "string", description: "File content (UTF-8)." }
|
|
195
|
+
},
|
|
196
|
+
required: ["path", "content"]
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
handler: async ({ input }) => {
|
|
200
|
+
const args = input ?? {};
|
|
201
|
+
if (!args.path || typeof args.path !== "string") {
|
|
202
|
+
return { content: "fs.write_file: missing `path`", isError: true };
|
|
203
|
+
}
|
|
204
|
+
if (typeof args.content !== "string") {
|
|
205
|
+
return { content: "fs.write_file: missing `content` (must be a string)", isError: true };
|
|
206
|
+
}
|
|
207
|
+
if (Buffer.byteLength(args.content, "utf8") > maxBytes) {
|
|
208
|
+
return {
|
|
209
|
+
content: `fs.write_file: content exceeds cap of ${maxBytes} bytes`,
|
|
210
|
+
isError: true
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const r = resolveScoped(root, args.path);
|
|
214
|
+
if (!r.ok) return { content: `fs.write_file: ${r.message}`, isError: true };
|
|
215
|
+
const existing = await realpathScoped(root, r.abs);
|
|
216
|
+
let target = r.abs;
|
|
217
|
+
if (existing.ok) {
|
|
218
|
+
target = existing.abs;
|
|
219
|
+
} else if (existing.message !== "ENOENT") {
|
|
220
|
+
return { content: `fs.write_file: ${existing.message}`, isError: true };
|
|
221
|
+
} else {
|
|
222
|
+
const parent = await realpathScoped(root, dirname(r.abs));
|
|
223
|
+
if (!parent.ok && parent.message !== "ENOENT") {
|
|
224
|
+
return { content: `fs.write_file: ${parent.message}`, isError: true };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
await mkdir(dirname(target), { recursive: true });
|
|
229
|
+
await writeFile(target, args.content, "utf8");
|
|
230
|
+
return { content: `fs.write_file: wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}` };
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return { content: `fs.write_file: ${err.message}`, isError: true };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function deleteFileTool(root) {
|
|
238
|
+
return {
|
|
239
|
+
definition: {
|
|
240
|
+
name: "fs.delete_file",
|
|
241
|
+
description: "Delete a file under the configured root. Refuses to delete directories.",
|
|
242
|
+
source: "pack:cap-filesystem",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
type: "object",
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
properties: {
|
|
247
|
+
path: { type: "string", description: "Path relative to the configured root." }
|
|
248
|
+
},
|
|
249
|
+
required: ["path"]
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
handler: async ({ input }) => {
|
|
253
|
+
const path = input?.path ?? "";
|
|
254
|
+
const r = resolveScoped(root, path);
|
|
255
|
+
if (!r.ok) return { content: `fs.delete_file: ${r.message}`, isError: true };
|
|
256
|
+
const real = await realpathScoped(root, r.abs);
|
|
257
|
+
if (!real.ok) {
|
|
258
|
+
if (real.message === "ENOENT") {
|
|
259
|
+
return { content: `fs.delete_file: file not found: ${path}`, isError: true };
|
|
260
|
+
}
|
|
261
|
+
return { content: `fs.delete_file: ${real.message}`, isError: true };
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const s = await stat(real.abs);
|
|
265
|
+
if (s.isDirectory()) {
|
|
266
|
+
return { content: `fs.delete_file: refusing to delete directory: ${path}`, isError: true };
|
|
267
|
+
}
|
|
268
|
+
await rm(real.abs);
|
|
269
|
+
return { content: `fs.delete_file: deleted ${path}` };
|
|
270
|
+
} catch (err) {
|
|
271
|
+
return { content: `fs.delete_file: ${err.message}`, isError: true };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function clampPositive(raw, fallback) {
|
|
277
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return fallback;
|
|
278
|
+
return Math.floor(raw);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export { src_default as default };
|
|
282
|
+
//# sourceMappingURL=index.js.map
|
|
283
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["urlDirname","urlJoin"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * cap-filesystem — path-scoped filesystem tools for the agent.\n *\n * Provides four tools, all hard-scoped to a configured `root` directory:\n *\n * - `fs.read_file({ path })` — read a UTF-8 text file.\n * - `fs.list_dir({ path })` — list a directory's entries.\n * - `fs.write_file({ path, content })` — write a file (creates parents).\n * - `fs.delete_file({ path })` — delete a file.\n *\n * Tool names get prefixed with the pack name on registration, so the\n * model sees them as `cap-filesystem.fs.read_file` etc.\n *\n * Why this is NOT a core builtin: the production worker pserv is\n * multi-tenant. Filesystem access on a process holding every tenant's\n * env vars and disk-mounted user data is unsafe by default. This pack\n * makes it explicit and per-agent: you opt in, you choose the root, and\n * you decide whether writes are allowed.\n *\n * Usage in render-harness.yaml:\n *\n * capabilities:\n * - pack: \"@render-harness/cap-filesystem\"\n * config:\n * root: \"/var/data/agent-workspace\" # required; absolute path\n * readOnly: false # optional; default false\n * maxBytes: 1048576 # optional read/write cap\n *\n * Path scoping enforces:\n * - Inputs are joined to root and resolved.\n * - The resolved path must remain under root (`..` traversal blocked).\n * - Symlinks are followed via realpath and re-checked against root, so\n * a symlink under root pointing at /etc/passwd cannot escape.\n */\n\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport { realpath, mkdir, readdir, readFile, rm, stat, writeFile } from \"node:fs/promises\";\nimport { dirname as urlDirname, join as urlJoin } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { type LocalToolHandler, type SkillMetadata } from \"@render-harness/core\";\nimport { definePack, type PackContext } from \"@render-harness/registry\";\n\nconst HERE = urlDirname(fileURLToPath(import.meta.url));\nconst SKILLS_DIR = urlJoin(HERE, \"..\", \"skills\");\n\ninterface FsConfig {\n root?: string;\n readOnly?: boolean;\n maxBytes?: number;\n}\n\nconst DEFAULT_MAX_BYTES = 1_048_576; // 1 MB\n\nconst pack = definePack({\n name: \"cap-filesystem\",\n version: \"0.1.0\",\n envSchema: [],\n async localTools(ctx: PackContext): Promise<LocalToolHandler[]> {\n const cfg = (ctx.config ?? {}) as FsConfig;\n if (!cfg.root || typeof cfg.root !== \"string\") {\n throw new Error(\n 'cap-filesystem: `config.root` is required (absolute path the agent can read/write).',\n );\n }\n // Resolve symlinks on the root once so subsequent realpath() checks of\n // children can be compared apples-to-apples. On macOS in particular,\n // /var → /private/var, and without this every legit path would look\n // like a symlink-escape.\n const resolvedRoot = resolve(cfg.root);\n let root: string;\n try {\n root = await realpath(resolvedRoot);\n } catch {\n // If the root doesn't exist yet, fall back to the literal path. The\n // tools will still try realpath on each access; once the dir gets\n // created the comparison will work.\n root = resolvedRoot;\n }\n const readOnly = cfg.readOnly === true;\n const maxBytes = clampPositive(cfg.maxBytes, DEFAULT_MAX_BYTES);\n\n const tools: LocalToolHandler[] = [readFileTool(root, maxBytes), listDirTool(root)];\n if (!readOnly) {\n tools.push(writeFileTool(root, maxBytes), deleteFileTool(root));\n }\n return tools;\n },\n skills(_ctx: PackContext): SkillMetadata[] {\n return [\n {\n name: \"filesystem\",\n description: \"Use the path-scoped filesystem tools to read, list, write, and delete files.\",\n whenToUse:\n \"When the user asks you to inspect, edit, or save files. All paths are scoped to a configured root; you cannot escape it.\",\n contentPath: urlJoin(SKILLS_DIR, \"filesystem.md\"),\n },\n ];\n },\n});\n\nexport default pack;\n\n// --------------------------------------------------------------------\n// Path scoping\n// --------------------------------------------------------------------\n\ninterface ResolveOk {\n ok: true;\n abs: string;\n}\ninterface ResolveErr {\n ok: false;\n message: string;\n}\ntype ResolveResult = ResolveOk | ResolveErr;\n\nfunction resolveScoped(root: string, requested: string): ResolveResult {\n if (typeof requested !== \"string\" || requested.length === 0) {\n return { ok: false, message: \"path must be a non-empty string\" };\n }\n // Normalize and join. resolve() collapses `..` segments deterministically.\n const joined = resolve(root, requested);\n if (!isUnder(joined, root)) {\n return { ok: false, message: `path \"${requested}\" escapes the configured root` };\n }\n return { ok: true, abs: joined };\n}\n\nfunction isUnder(child: string, parent: string): boolean {\n const rel = parent.endsWith(sep) ? parent : parent + sep;\n return child === parent || child.startsWith(rel);\n}\n\n/**\n * Resolve symlinks and re-check the result is still under root. Used after\n * a successful read or before an existing-file write — a symlink under\n * root pointing at /etc/passwd otherwise escapes the scope.\n */\nasync function realpathScoped(root: string, abs: string): Promise<ResolveResult> {\n let real: string;\n try {\n real = await realpath(abs);\n } catch (err) {\n if (isNotFound(err)) {\n // For writes against new files, realpath fails — caller must check\n // the parent dir instead. Surface a sentinel.\n return { ok: false, message: \"ENOENT\" };\n }\n return { ok: false, message: (err as Error).message };\n }\n if (!isUnder(real, root)) {\n return { ok: false, message: `symlink target \"${real}\" escapes the configured root` };\n }\n return { ok: true, abs: real };\n}\n\nfunction isNotFound(err: unknown): boolean {\n return err instanceof Error && (err as NodeJS.ErrnoException).code === \"ENOENT\";\n}\n\n// --------------------------------------------------------------------\n// Tools\n// --------------------------------------------------------------------\n\nfunction readFileTool(root: string, maxBytes: number): LocalToolHandler {\n return {\n definition: {\n name: \"fs.read_file\",\n description: `Read a UTF-8 text file under the configured root. Returns the file contents (capped at ${maxBytes} bytes).`,\n source: \"pack:cap-filesystem\",\n inputSchema: {\n type: \"object\",\n additionalProperties: false,\n properties: {\n path: {\n type: \"string\",\n description: \"Path relative to (or absolute under) the configured root.\",\n },\n },\n required: [\"path\"],\n },\n },\n handler: async ({ input }) => {\n const path = (input as { path?: string } | null)?.path ?? \"\";\n const r = resolveScoped(root, path);\n if (!r.ok) return { content: `fs.read_file: ${r.message}`, isError: true };\n\n const real = await realpathScoped(root, r.abs);\n if (!real.ok) {\n if (real.message === \"ENOENT\") {\n return { content: `fs.read_file: file not found: ${path}`, isError: true };\n }\n return { content: `fs.read_file: ${real.message}`, isError: true };\n }\n\n try {\n const buf = await readFile(real.abs);\n if (buf.byteLength > maxBytes) {\n const truncated = buf.subarray(0, maxBytes).toString(\"utf8\");\n return {\n content: `${truncated}\\n\\n[... truncated at ${maxBytes} bytes; full file was ${buf.byteLength} bytes ...]`,\n };\n }\n return { content: buf.toString(\"utf8\") };\n } catch (err) {\n return {\n content: `fs.read_file: ${(err as Error).message}`,\n isError: true,\n };\n }\n },\n };\n}\n\nfunction listDirTool(root: string): LocalToolHandler {\n return {\n definition: {\n name: \"fs.list_dir\",\n description: \"List entries in a directory under the configured root.\",\n source: \"pack:cap-filesystem\",\n inputSchema: {\n type: \"object\",\n additionalProperties: false,\n properties: {\n path: {\n type: \"string\",\n description: \"Path relative to the configured root. Use '.' for root itself.\",\n },\n },\n },\n },\n handler: async ({ input }) => {\n const path = (input as { path?: string } | null)?.path ?? \".\";\n const r = resolveScoped(root, path);\n if (!r.ok) return { content: `fs.list_dir: ${r.message}`, isError: true };\n\n const real = await realpathScoped(root, r.abs);\n if (!real.ok) {\n if (real.message === \"ENOENT\") {\n return { content: `fs.list_dir: directory not found: ${path}`, isError: true };\n }\n return { content: `fs.list_dir: ${real.message}`, isError: true };\n }\n\n try {\n const entries = await readdir(real.abs, { withFileTypes: true });\n if (entries.length === 0) return { content: \"(empty directory)\" };\n const lines = await Promise.all(\n entries.map(async (e) => {\n const full = join(real.abs, e.name);\n const kind = e.isDirectory()\n ? \"dir\"\n : e.isSymbolicLink()\n ? \"link\"\n : e.isFile()\n ? \"file\"\n : \"other\";\n if (kind === \"file\") {\n try {\n const s = await stat(full);\n return `${kind}\\t${e.name}\\t${s.size}b`;\n } catch {\n return `${kind}\\t${e.name}`;\n }\n }\n return `${kind}\\t${e.name}`;\n }),\n );\n return { content: lines.join(\"\\n\") };\n } catch (err) {\n return { content: `fs.list_dir: ${(err as Error).message}`, isError: true };\n }\n },\n };\n}\n\nfunction writeFileTool(root: string, maxBytes: number): LocalToolHandler {\n return {\n definition: {\n name: \"fs.write_file\",\n description: `Write a UTF-8 text file under the configured root. Creates parent directories. Cap: ${maxBytes} bytes.`,\n source: \"pack:cap-filesystem\",\n inputSchema: {\n type: \"object\",\n additionalProperties: false,\n properties: {\n path: { type: \"string\", description: \"Path relative to the configured root.\" },\n content: { type: \"string\", description: \"File content (UTF-8).\" },\n },\n required: [\"path\", \"content\"],\n },\n },\n handler: async ({ input }) => {\n const args = (input ?? {}) as { path?: string; content?: string };\n if (!args.path || typeof args.path !== \"string\") {\n return { content: \"fs.write_file: missing `path`\", isError: true };\n }\n if (typeof args.content !== \"string\") {\n return { content: \"fs.write_file: missing `content` (must be a string)\", isError: true };\n }\n if (Buffer.byteLength(args.content, \"utf8\") > maxBytes) {\n return {\n content: `fs.write_file: content exceeds cap of ${maxBytes} bytes`,\n isError: true,\n };\n }\n\n const r = resolveScoped(root, args.path);\n if (!r.ok) return { content: `fs.write_file: ${r.message}`, isError: true };\n\n // If the file already exists, follow symlinks and re-check.\n // For new files, check the parent directory.\n const existing = await realpathScoped(root, r.abs);\n let target = r.abs;\n if (existing.ok) {\n target = existing.abs;\n } else if (existing.message !== \"ENOENT\") {\n return { content: `fs.write_file: ${existing.message}`, isError: true };\n } else {\n const parent = await realpathScoped(root, dirname(r.abs));\n if (!parent.ok && parent.message !== \"ENOENT\") {\n return { content: `fs.write_file: ${parent.message}`, isError: true };\n }\n }\n\n try {\n await mkdir(dirname(target), { recursive: true });\n await writeFile(target, args.content, \"utf8\");\n return { content: `fs.write_file: wrote ${Buffer.byteLength(args.content, \"utf8\")} bytes to ${args.path}` };\n } catch (err) {\n return { content: `fs.write_file: ${(err as Error).message}`, isError: true };\n }\n },\n };\n}\n\nfunction deleteFileTool(root: string): LocalToolHandler {\n return {\n definition: {\n name: \"fs.delete_file\",\n description: \"Delete a file under the configured root. Refuses to delete directories.\",\n source: \"pack:cap-filesystem\",\n inputSchema: {\n type: \"object\",\n additionalProperties: false,\n properties: {\n path: { type: \"string\", description: \"Path relative to the configured root.\" },\n },\n required: [\"path\"],\n },\n },\n handler: async ({ input }) => {\n const path = (input as { path?: string } | null)?.path ?? \"\";\n const r = resolveScoped(root, path);\n if (!r.ok) return { content: `fs.delete_file: ${r.message}`, isError: true };\n\n const real = await realpathScoped(root, r.abs);\n if (!real.ok) {\n if (real.message === \"ENOENT\") {\n return { content: `fs.delete_file: file not found: ${path}`, isError: true };\n }\n return { content: `fs.delete_file: ${real.message}`, isError: true };\n }\n\n try {\n const s = await stat(real.abs);\n if (s.isDirectory()) {\n return { content: `fs.delete_file: refusing to delete directory: ${path}`, isError: true };\n }\n await rm(real.abs);\n return { content: `fs.delete_file: deleted ${path}` };\n } catch (err) {\n return { content: `fs.delete_file: ${(err as Error).message}`, isError: true };\n }\n },\n };\n}\n\nfunction clampPositive(raw: number | undefined, fallback: number): number {\n if (typeof raw !== \"number\" || !Number.isFinite(raw) || raw <= 0) return fallback;\n return Math.floor(raw);\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@render-harness/cap-filesystem",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Path-scoped filesystem tools for the Render agent harness. Opt-in per agent; the worker pserv is multi-tenant by default so this is NOT a core builtin.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"skills"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"render-harness-cap",
|
|
21
|
+
"render-harness",
|
|
22
|
+
"filesystem"
|
|
23
|
+
],
|
|
24
|
+
"renderHarness": {
|
|
25
|
+
"gallery": {
|
|
26
|
+
"label": "Filesystem (path-scoped fs tools)",
|
|
27
|
+
"envHint": null
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@render-harness/core": "workspace:*",
|
|
37
|
+
"@render-harness/registry": "workspace:*"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^25.6.2",
|
|
41
|
+
"tsup": "^8.5.1",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vitest": "^4.1.5"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: filesystem
|
|
3
|
+
description: Read, list, write, and delete files under a configured root directory.
|
|
4
|
+
when_to_use: When the user asks you to inspect, edit, or save files. All paths are scoped — you cannot escape the configured root.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Filesystem skill
|
|
8
|
+
|
|
9
|
+
You have four file tools, all path-scoped to one root directory the operator configured at deploy time:
|
|
10
|
+
|
|
11
|
+
- `fs.read_file({ path })` — read a UTF-8 text file. Large files are truncated; `fetch_full_result` recovers the full body.
|
|
12
|
+
- `fs.list_dir({ path })` — list directory entries with type and (for files) size.
|
|
13
|
+
- `fs.write_file({ path, content })` — write a UTF-8 file. Auto-creates parent directories.
|
|
14
|
+
- `fs.delete_file({ path })` — remove a file. Refuses to delete directories.
|
|
15
|
+
|
|
16
|
+
`fs.write_file` and `fs.delete_file` are only available when the pack is configured with `readOnly: false`.
|
|
17
|
+
|
|
18
|
+
## Path rules
|
|
19
|
+
|
|
20
|
+
- All `path` arguments are joined to the configured root.
|
|
21
|
+
- `..` segments cannot escape the root — attempts return an error.
|
|
22
|
+
- Symlinks are resolved with `realpath` and rejected if their target is outside the root.
|
|
23
|
+
- Paths can be relative (`docs/notes.md`) or absolute under the root (`/var/data/agent-workspace/docs/notes.md` if root is `/var/data/agent-workspace`).
|
|
24
|
+
|
|
25
|
+
## Patterns
|
|
26
|
+
|
|
27
|
+
- **List then read.** Don't assume a path exists — call `fs.list_dir` first when you don't know the layout.
|
|
28
|
+
- **Read before write.** When editing an existing file, read it first so you understand the current content.
|
|
29
|
+
- **Mention the path in your reply.** When you write or delete, tell the user exactly what changed and where.
|
|
30
|
+
|
|
31
|
+
## What to avoid
|
|
32
|
+
|
|
33
|
+
- Don't try to read `/etc/passwd`, `/proc/...`, or anything outside the root. The scope check will refuse.
|
|
34
|
+
- Don't write large files (over 1 MB by default) — they're rejected. Split or summarize instead.
|
|
35
|
+
- Don't store secrets in files unless the user explicitly asked. The disk may be shared across runs.
|