@stratusagent/tool-fs 0.6.0
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/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +385 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +75 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +162 -0
- package/dist/paths.js.map +1 -0
- package/package.json +96 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stratus Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @stratusagent/tool-fs
|
|
2
|
+
|
|
3
|
+
The `fs` toolset: an agent reads, lists, and searches inside directories you
|
|
4
|
+
chose, and writes only with the permission engine's blessing.
|
|
5
|
+
|
|
6
|
+
This is a **plugin** — it contributes a toolset, and nothing loads it unless
|
|
7
|
+
your config says so. See [`plugins.md`](../../docs/architecture/plugins.md)
|
|
8
|
+
for what that means.
|
|
9
|
+
|
|
10
|
+
## Install and enable
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @stratusagent/tool-fs
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```jsonc
|
|
17
|
+
// ~/.stratus/config.json
|
|
18
|
+
{
|
|
19
|
+
"plugins": {
|
|
20
|
+
"@stratusagent/tool-fs": {
|
|
21
|
+
"enabled": true,
|
|
22
|
+
"roots": ["~/notes"],
|
|
23
|
+
"agents": {
|
|
24
|
+
"ava": { "roots": ["~/work/ava"] },
|
|
25
|
+
"juno": { "roots": ["~/work/juno", "~/shared"] }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Read from a **trusted** config only — `~/.stratus/config.json`, or a file you
|
|
33
|
+
passed with `--config`. A project-local `stratus.config.json` ships in any
|
|
34
|
+
repository somebody clones, and cannot decide which code runs in your daemon.
|
|
35
|
+
|
|
36
|
+
Installing the plugin grants no agent anything. The soul's allowlist is the
|
|
37
|
+
second gate, and the per-identity one:
|
|
38
|
+
|
|
39
|
+
```markdown
|
|
40
|
+
---
|
|
41
|
+
id: ava
|
|
42
|
+
tools: [fs.read, fs.search] # or fs.* for the whole toolset
|
|
43
|
+
---
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Risk model
|
|
47
|
+
|
|
48
|
+
| Tool | Risk | What approval mode does |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `fs.read` | `safe` | Runs unattended. Nothing outside the agent's roots is readable, which is what makes reading safe rather than merely convenient. |
|
|
51
|
+
| `fs.list` | `safe` | Runs unattended. Symlinks are listed as symlinks and never followed. |
|
|
52
|
+
| `fs.search` | `safe` | Runs unattended. Matches **literal text** (optionally `wholeWord`), never a regular expression — see below. Given a directory it walks it without following symlinks, skipping `.git`, `node_modules`, and files over 1 MB; given a file it searches that file and no others. |
|
|
53
|
+
| `fs.write` | `gated` | `interactive` asks at the terminal, `remote` asks in Slack, `headless` refuses. Writing where other people read is the thing worth a person's attention. |
|
|
54
|
+
|
|
55
|
+
## Settings
|
|
56
|
+
|
|
57
|
+
| Key | Default | What |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `roots` | none | Directories this agent may reach. **No roots means no filesystem** — nothing is readable, rather than everything. |
|
|
60
|
+
| `maxBytes` | `64000` | Cap on one `fs.read`, before the `truncated` marker. |
|
|
61
|
+
| `maxMatches` | `100` | Cap on `fs.search` matches. |
|
|
62
|
+
| `maxEntries` | `500` | Cap on `fs.list` entries. |
|
|
63
|
+
|
|
64
|
+
Every key can be set per agent in the `agents` sub-block, over the defaults
|
|
65
|
+
above it, and `roots` is why the sub-block exists: a flat list would give
|
|
66
|
+
every agent enabling `fs.*` the same roots, which is one agent reading
|
|
67
|
+
another's files.
|
|
68
|
+
|
|
69
|
+
## `fs.search` takes literal text, not a pattern
|
|
70
|
+
|
|
71
|
+
A regular expression written by a model is untrusted input compiled into
|
|
72
|
+
V8's backtracking engine and run on the process's only thread. `(a+)+$`
|
|
73
|
+
against a long line does not return in any time worth waiting for, and while
|
|
74
|
+
it runs every session in the daemon is stopped — for a tool classified
|
|
75
|
+
`safe`, which means nobody is watching.
|
|
76
|
+
|
|
77
|
+
There is no timeout to give a running regex and no way to interrupt one from
|
|
78
|
+
the thread it is on, so the expressive form is gone rather than bounded.
|
|
79
|
+
`wholeWord` covers what it was mostly wanted for. Anything genuinely
|
|
80
|
+
regex-shaped is a `grep` away through
|
|
81
|
+
[`@stratusagent/tool-shell`](../tool-shell), where a human approves the
|
|
82
|
+
command and the executor can kill it.
|
|
83
|
+
|
|
84
|
+
## The containment rule
|
|
85
|
+
|
|
86
|
+
Roots are an **access boundary between agents**, so they are resolved on
|
|
87
|
+
every call from `session.agent.id` — never read once when the plugin is
|
|
88
|
+
constructed. A plugin that closed over its roots at setup would hand every
|
|
89
|
+
agent whichever set resolved first.
|
|
90
|
+
|
|
91
|
+
Containment is decided between **real** paths, not strings:
|
|
92
|
+
|
|
93
|
+
- The requested path is normalized, then the deepest part of it that exists
|
|
94
|
+
is canonicalized, and *that* must be inside a canonicalized root. A
|
|
95
|
+
lexical check would catch `../../.stratus/credentials.json` and miss a
|
|
96
|
+
symlink inside the root pointing at the same file.
|
|
97
|
+
- For a path that does not exist yet, the canonicalized part is its nearest
|
|
98
|
+
existing ancestor — which is what makes writing `root/link/planted.txt`
|
|
99
|
+
through a symlinked `link` a refusal rather than a write into `/etc`.
|
|
100
|
+
- Opens use `O_NOFOLLOW`, and a file that existed at check time is compared
|
|
101
|
+
by inode after opening, so a name repointed in between fails instead of
|
|
102
|
+
being followed.
|
|
103
|
+
|
|
104
|
+
A path inside a root that is not a regular file — a fifo, a socket, a device
|
|
105
|
+
— is refused rather than opened. Opening one of those does not fail, it
|
|
106
|
+
*blocks*, and a tool call that never returns is worse than one that says no.
|
|
107
|
+
The opens carry `O_NONBLOCK` as well, so missing that check would be a
|
|
108
|
+
returned error rather than a turn that never ends.
|
|
109
|
+
|
|
110
|
+
What this does not do is walk the path one directory at a time holding each
|
|
111
|
+
open — that needs `openat`, which Node does not expose — so a swap of an
|
|
112
|
+
intermediate directory remains possible in principle. The roots you choose
|
|
113
|
+
are the boundary; this module is what stops the ordinary escapes from
|
|
114
|
+
crossing it.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JsonObject, Plugin } from '@stratusagent/core';
|
|
2
|
+
export { canonicalRoots, expandHome, openContained, PathOutsideRootError, resolveWithinRoots, } from './paths.ts';
|
|
3
|
+
export interface FsPluginConfig extends JsonObject {
|
|
4
|
+
/** Directories this agent may reach. Nothing outside them is readable. */
|
|
5
|
+
roots?: string[];
|
|
6
|
+
/** Cap on a single read, before truncation. */
|
|
7
|
+
maxBytes?: number;
|
|
8
|
+
/** Cap on `fs.search` matches. */
|
|
9
|
+
maxMatches?: number;
|
|
10
|
+
/** Cap on `fs.list` entries. */
|
|
11
|
+
maxEntries?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The filesystem toolset.
|
|
15
|
+
*
|
|
16
|
+
* Reads are `safe` and writes are `gated`, which is the risk model applied
|
|
17
|
+
* rather than a compromise: reading inside a directory a human chose is
|
|
18
|
+
* work an agent does on its own, and writing where other people read is the
|
|
19
|
+
* thing worth a person's attention. The roots are what make the first half
|
|
20
|
+
* true, so they are an access boundary and not a preference — resolved from
|
|
21
|
+
* `session.agent.id` on every call.
|
|
22
|
+
*/
|
|
23
|
+
export declare const createFsPlugin: (config?: JsonObject) => Plugin;
|
|
24
|
+
/** The loader's ABI. See `docs/architecture/plugins.md`. */
|
|
25
|
+
export declare const createPlugin: (config?: JsonObject) => Plugin;
|
|
26
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAa,MAAM,EAAiB,MAAM,oBAAoB,CAAC;AAUvF,OAAO,EACL,cAAc,EACd,UAAU,EACV,aAAa,EACb,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAQpB,MAAM,WAAW,cAAe,SAAQ,UAAU;IAChD,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kCAAkC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gCAAgC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA+XD;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,GAAI,SAAQ,UAAe,KAAG,MAQvD,CAAC;AAEH,4DAA4D;AAC5D,eAAO,MAAM,YAAY,YAXc,UAAU,KAAQ,MAWf,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { resolvePluginAgentConfig } from '@stratusagent/plugins';
|
|
6
|
+
import { isRealDirectory, openContained, resolveWithinRoots, } from "./paths.js";
|
|
7
|
+
export { canonicalRoots, expandHome, openContained, PathOutsideRootError, resolveWithinRoots, } from "./paths.js";
|
|
8
|
+
/** Defaults chosen so a tool result is something a model can actually read. */
|
|
9
|
+
const DEFAULT_MAX_BYTES = 64_000;
|
|
10
|
+
const DEFAULT_MAX_MATCHES = 100;
|
|
11
|
+
const DEFAULT_MAX_SEARCH_FILE_BYTES = 1_000_000;
|
|
12
|
+
const DEFAULT_MAX_ENTRIES = 500;
|
|
13
|
+
const asStrings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
|
|
14
|
+
const asNumber = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
15
|
+
const requireString = (input, key) => {
|
|
16
|
+
const value = input[key];
|
|
17
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
18
|
+
throw new Error(`${key} is required and must be a string.`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* What this agent may reach — resolved **per call**, from the session.
|
|
24
|
+
*
|
|
25
|
+
* Not a convenience: two agents with different roots is the whole point of
|
|
26
|
+
* the per-agent block, and a plugin that read its roots once at setup would
|
|
27
|
+
* hand every agent whichever set happened to be resolved first. That is one
|
|
28
|
+
* agent reading another's files, and it is the failure this signature
|
|
29
|
+
* exists to make impossible to write.
|
|
30
|
+
*/
|
|
31
|
+
const settingsFor = (config, session) => {
|
|
32
|
+
const resolved = resolvePluginAgentConfig(config, session.agent.id);
|
|
33
|
+
return {
|
|
34
|
+
roots: asStrings(resolved.roots),
|
|
35
|
+
maxBytes: asNumber(resolved.maxBytes, DEFAULT_MAX_BYTES),
|
|
36
|
+
maxMatches: asNumber(resolved.maxMatches, DEFAULT_MAX_MATCHES),
|
|
37
|
+
maxEntries: asNumber(resolved.maxEntries, DEFAULT_MAX_ENTRIES),
|
|
38
|
+
home: typeof resolved.home === 'string' ? resolved.home : os.homedir(),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Whether a buffer is text, decided the way `grep` decides it: a NUL byte
|
|
43
|
+
* means binary. Returning a megabyte of mojibake to a model helps nobody,
|
|
44
|
+
* and returning "this is binary" is an answer it can act on.
|
|
45
|
+
*/
|
|
46
|
+
const looksBinary = (buffer) => buffer.includes(0);
|
|
47
|
+
const relativeTo = (root, target) => path.relative(root, target) || '.';
|
|
48
|
+
const readContained = async (resolved, maxBytes) => {
|
|
49
|
+
if (resolved.kind === 'directory') {
|
|
50
|
+
throw new Error(`${resolved.path} is a directory; use fs.list.`);
|
|
51
|
+
}
|
|
52
|
+
if (resolved.kind === 'other') {
|
|
53
|
+
// A FIFO, a socket, a device. Opening one does not fail — it *blocks*,
|
|
54
|
+
// and a tool call that never returns is worse than one that refuses.
|
|
55
|
+
throw new Error(`${resolved.path} is not a regular file.`);
|
|
56
|
+
}
|
|
57
|
+
// `O_NONBLOCK` alongside the check above, and not instead of it: the check
|
|
58
|
+
// gives a refusal an agent can read, and this makes the failure mode of
|
|
59
|
+
// *missing* one a returned error rather than a turn that never ends. It
|
|
60
|
+
// has no effect on a regular file.
|
|
61
|
+
const handle = await openContained(resolved, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
62
|
+
try {
|
|
63
|
+
const info = await handle.stat();
|
|
64
|
+
if (info.isDirectory()) {
|
|
65
|
+
throw new Error(`${resolved.path} is a directory; use fs.list.`);
|
|
66
|
+
}
|
|
67
|
+
const length = Math.min(info.size, maxBytes);
|
|
68
|
+
const buffer = Buffer.alloc(length);
|
|
69
|
+
const { bytesRead } = await handle.read(buffer, 0, length, 0);
|
|
70
|
+
const slice = buffer.subarray(0, bytesRead);
|
|
71
|
+
if (looksBinary(slice)) {
|
|
72
|
+
return { content: '', truncated: false, bytes: bytesRead, size: info.size, binary: true };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
content: slice.toString('utf8'),
|
|
76
|
+
truncated: info.size > bytesRead,
|
|
77
|
+
bytes: bytesRead,
|
|
78
|
+
size: info.size,
|
|
79
|
+
binary: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await handle.close();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const createReadTool = (config) => ({
|
|
87
|
+
name: 'fs.read',
|
|
88
|
+
description: 'Read a UTF-8 text file inside one of this agent’s roots.',
|
|
89
|
+
risk: 'safe',
|
|
90
|
+
parameters: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
path: { type: 'string', description: 'Absolute, or relative to the agent’s first root.' },
|
|
94
|
+
maxBytes: { type: 'number', description: 'Stop after this many bytes.' },
|
|
95
|
+
},
|
|
96
|
+
required: ['path'],
|
|
97
|
+
},
|
|
98
|
+
async execute(input, session) {
|
|
99
|
+
const settings = settingsFor(config, session);
|
|
100
|
+
const requested = requireString(input, 'path');
|
|
101
|
+
const resolved = await resolveWithinRoots(settings.roots, requested, { home: settings.home });
|
|
102
|
+
const maxBytes = asNumber(input.maxBytes, settings.maxBytes);
|
|
103
|
+
const result = await readContained(resolved, maxBytes);
|
|
104
|
+
return {
|
|
105
|
+
path: relativeTo(resolved.root, resolved.path),
|
|
106
|
+
absolutePath: resolved.path,
|
|
107
|
+
...(result.binary
|
|
108
|
+
? { binary: true, bytes: result.size }
|
|
109
|
+
: {
|
|
110
|
+
content: result.content,
|
|
111
|
+
bytes: result.bytes,
|
|
112
|
+
size: result.size,
|
|
113
|
+
// Said outright rather than left for the model to notice: a
|
|
114
|
+
// silently clipped file reads as a complete one, and a summary
|
|
115
|
+
// written from half a document is confidently wrong.
|
|
116
|
+
truncated: result.truncated,
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const entryKind = (entry) => {
|
|
122
|
+
if (entry.isSymbolicLink())
|
|
123
|
+
return 'symlink';
|
|
124
|
+
if (entry.isDirectory())
|
|
125
|
+
return 'directory';
|
|
126
|
+
if (entry.isFile())
|
|
127
|
+
return 'file';
|
|
128
|
+
return 'other';
|
|
129
|
+
};
|
|
130
|
+
const createListTool = (config) => ({
|
|
131
|
+
name: 'fs.list',
|
|
132
|
+
description: 'List a directory inside one of this agent’s roots.',
|
|
133
|
+
risk: 'safe',
|
|
134
|
+
parameters: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
path: { type: 'string', description: 'Defaults to the agent’s first root.' },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
async execute(input, session) {
|
|
141
|
+
const settings = settingsFor(config, session);
|
|
142
|
+
const requested = typeof input.path === 'string' && input.path.length > 0 ? input.path : '.';
|
|
143
|
+
const resolved = await resolveWithinRoots(settings.roots, requested, { home: settings.home });
|
|
144
|
+
if (!(await isRealDirectory(resolved.path))) {
|
|
145
|
+
throw new Error(`${requested} is not a directory; use fs.read.`);
|
|
146
|
+
}
|
|
147
|
+
const entries = await readdir(resolved.path, { withFileTypes: true });
|
|
148
|
+
const listed = entries.slice(0, settings.maxEntries);
|
|
149
|
+
const detailed = await Promise.all(listed.map(async (entry) => {
|
|
150
|
+
const full = path.join(resolved.path, entry.name);
|
|
151
|
+
let size;
|
|
152
|
+
if (entry.isFile()) {
|
|
153
|
+
try {
|
|
154
|
+
size = (await stat(full)).size;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
size = undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
name: entry.name,
|
|
162
|
+
// Symlinks are reported as symlinks and never followed here: a
|
|
163
|
+
// listing that resolved them would describe files outside the root
|
|
164
|
+
// as if they were inside it.
|
|
165
|
+
type: entryKind(entry),
|
|
166
|
+
...(size === undefined ? {} : { size }),
|
|
167
|
+
};
|
|
168
|
+
}));
|
|
169
|
+
return {
|
|
170
|
+
path: relativeTo(resolved.root, resolved.path),
|
|
171
|
+
entries: detailed,
|
|
172
|
+
truncated: entries.length > listed.length,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
177
|
+
/**
|
|
178
|
+
* The matcher for one search.
|
|
179
|
+
*
|
|
180
|
+
* Built from an **escaped literal**, always. `fs.search` is `safe` — it
|
|
181
|
+
* runs unattended, on the process's only thread, with a pattern a model
|
|
182
|
+
* wrote — and V8's regex engine backtracks: `(a+)+$` against a long line of
|
|
183
|
+
* `a`s does not return in any time worth waiting for, and while it runs
|
|
184
|
+
* every session in the daemon is stopped. There is no timeout to give it
|
|
185
|
+
* and no way to interrupt it on this thread.
|
|
186
|
+
*
|
|
187
|
+
* So the expressive form is gone rather than bounded. `wholeWord` covers
|
|
188
|
+
* the case it was mostly wanted for; anything genuinely regex-shaped is a
|
|
189
|
+
* `shell.run` away, where a human approves the command and the executor
|
|
190
|
+
* can kill it.
|
|
191
|
+
*/
|
|
192
|
+
const matcherFor = (query, options) => {
|
|
193
|
+
const literal = escapeRegExp(query);
|
|
194
|
+
return new RegExp(options.wholeWord ? `\\b${literal}\\b` : literal, options.caseSensitive ? '' : 'i');
|
|
195
|
+
};
|
|
196
|
+
const walkFiles = async function* (directory, depth = 0) {
|
|
197
|
+
if (depth > 12) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
let entries;
|
|
201
|
+
try {
|
|
202
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
// Never through a symlink: following one would walk out of the root,
|
|
209
|
+
// and the containment check only ran on the directory we were given.
|
|
210
|
+
if (entry.isSymbolicLink()) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const full = path.join(directory, entry.name);
|
|
214
|
+
if (entry.isDirectory()) {
|
|
215
|
+
if (entry.name === '.git' || entry.name === 'node_modules') {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
yield* walkFiles(full, depth + 1);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (entry.isFile()) {
|
|
222
|
+
yield full;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const createSearchTool = (config) => ({
|
|
227
|
+
name: 'fs.search',
|
|
228
|
+
description: 'Search file contents for literal text under one of this agent’s roots.',
|
|
229
|
+
risk: 'safe',
|
|
230
|
+
parameters: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
query: { type: 'string', description: 'Literal text to find. Not a regular expression.' },
|
|
234
|
+
path: { type: 'string', description: 'Where to search. Defaults to the agent’s first root.' },
|
|
235
|
+
wholeWord: { type: 'boolean', description: 'Match only whole words.' },
|
|
236
|
+
caseSensitive: { type: 'boolean' },
|
|
237
|
+
maxMatches: { type: 'number' },
|
|
238
|
+
},
|
|
239
|
+
required: ['query'],
|
|
240
|
+
},
|
|
241
|
+
async execute(input, session) {
|
|
242
|
+
const settings = settingsFor(config, session);
|
|
243
|
+
const query = requireString(input, 'query');
|
|
244
|
+
const requested = typeof input.path === 'string' && input.path.length > 0 ? input.path : '.';
|
|
245
|
+
const resolved = await resolveWithinRoots(settings.roots, requested, { home: settings.home });
|
|
246
|
+
const limit = asNumber(input.maxMatches, settings.maxMatches);
|
|
247
|
+
const pattern = matcherFor(query, {
|
|
248
|
+
caseSensitive: input.caseSensitive === true,
|
|
249
|
+
wholeWord: input.wholeWord === true,
|
|
250
|
+
});
|
|
251
|
+
const matches = [];
|
|
252
|
+
let truncated = false;
|
|
253
|
+
// A file is searched as itself. Falling back to its parent would answer
|
|
254
|
+
// a question nobody asked — `{ path: 'notes/today.md' }` coming back
|
|
255
|
+
// with hits from every other note is a wrong answer, not a generous one.
|
|
256
|
+
if (resolved.kind === 'other') {
|
|
257
|
+
// The same trap `fs.read` has: `readFile` on a fifo with no writer
|
|
258
|
+
// does not fail, it waits — and `fs.search` is `safe`, so it waits
|
|
259
|
+
// with nobody watching. The walk below cannot hit this (a directory
|
|
260
|
+
// entry is only yielded when it is a regular file); a path handed to
|
|
261
|
+
// us directly can.
|
|
262
|
+
throw new Error(`${resolved.path} is not a regular file.`);
|
|
263
|
+
}
|
|
264
|
+
const files = resolved.kind === 'directory'
|
|
265
|
+
? walkFiles(resolved.path)
|
|
266
|
+
: (async function* single() {
|
|
267
|
+
yield resolved.path;
|
|
268
|
+
})();
|
|
269
|
+
for await (const file of files) {
|
|
270
|
+
if (matches.length >= limit) {
|
|
271
|
+
truncated = true;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
let contents;
|
|
275
|
+
try {
|
|
276
|
+
const info = await stat(file);
|
|
277
|
+
if (info.size > DEFAULT_MAX_SEARCH_FILE_BYTES) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
contents = await readFile(file);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (looksBinary(contents)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const lines = contents.toString('utf8').split('\n');
|
|
289
|
+
for (const [index, line] of lines.entries()) {
|
|
290
|
+
if (!pattern.test(line)) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (matches.length >= limit) {
|
|
294
|
+
truncated = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
matches.push({
|
|
298
|
+
path: relativeTo(resolved.root, file),
|
|
299
|
+
line: index + 1,
|
|
300
|
+
text: line.length > 400 ? `${line.slice(0, 400)}…` : line,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { query, matches, truncated };
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
const createWriteTool = (config) => ({
|
|
308
|
+
name: 'fs.write',
|
|
309
|
+
description: 'Write a UTF-8 text file inside one of this agent’s roots.',
|
|
310
|
+
// Gated, not safe: it acts on the world outside Stratus and outlives the
|
|
311
|
+
// turn. Reading inside a root a human chose is the thing that does not.
|
|
312
|
+
risk: 'gated',
|
|
313
|
+
parameters: {
|
|
314
|
+
type: 'object',
|
|
315
|
+
properties: {
|
|
316
|
+
path: { type: 'string' },
|
|
317
|
+
content: { type: 'string' },
|
|
318
|
+
append: { type: 'boolean' },
|
|
319
|
+
},
|
|
320
|
+
required: ['path', 'content'],
|
|
321
|
+
},
|
|
322
|
+
async execute(input, session) {
|
|
323
|
+
const settings = settingsFor(config, session);
|
|
324
|
+
const requested = requireString(input, 'path');
|
|
325
|
+
const content = typeof input.content === 'string' ? input.content : '';
|
|
326
|
+
const resolved = await resolveWithinRoots(settings.roots, requested, {
|
|
327
|
+
home: settings.home,
|
|
328
|
+
allowMissing: true,
|
|
329
|
+
});
|
|
330
|
+
if (!resolved.exists) {
|
|
331
|
+
// The parent is created only after the resolution above put it inside
|
|
332
|
+
// a root — `root/link/notes/x.md` through a symlinked `link` never
|
|
333
|
+
// gets this far, so nothing is created outside the boundary either.
|
|
334
|
+
await mkdir(path.dirname(resolved.path), { recursive: true });
|
|
335
|
+
}
|
|
336
|
+
else if (resolved.kind !== 'file') {
|
|
337
|
+
// Same reason as the read path: writing into a directory is a mistake
|
|
338
|
+
// worth naming, and writing into a fifo or a device blocks forever.
|
|
339
|
+
throw new Error(resolved.kind === 'directory'
|
|
340
|
+
? `${resolved.path} is a directory.`
|
|
341
|
+
: `${resolved.path} is not a regular file.`);
|
|
342
|
+
}
|
|
343
|
+
const flags = input.append === true
|
|
344
|
+
? constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND
|
|
345
|
+
: constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC;
|
|
346
|
+
// O_NOFOLLOW is applied by openContained: a symlink sitting at the
|
|
347
|
+
// destination must fail rather than write through to its target, which
|
|
348
|
+
// is the write half of the escape a read-only check would miss.
|
|
349
|
+
const handle = await openContained(resolved, flags | (constants.O_NONBLOCK ?? 0), 0o644);
|
|
350
|
+
try {
|
|
351
|
+
await handle.writeFile(content, 'utf8');
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
await handle.close();
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
path: relativeTo(resolved.root, resolved.path),
|
|
358
|
+
absolutePath: resolved.path,
|
|
359
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
360
|
+
appended: input.append === true,
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
/**
|
|
365
|
+
* The filesystem toolset.
|
|
366
|
+
*
|
|
367
|
+
* Reads are `safe` and writes are `gated`, which is the risk model applied
|
|
368
|
+
* rather than a compromise: reading inside a directory a human chose is
|
|
369
|
+
* work an agent does on its own, and writing where other people read is the
|
|
370
|
+
* thing worth a person's attention. The roots are what make the first half
|
|
371
|
+
* true, so they are an access boundary and not a preference — resolved from
|
|
372
|
+
* `session.agent.id` on every call.
|
|
373
|
+
*/
|
|
374
|
+
export const createFsPlugin = (config = {}) => ({
|
|
375
|
+
name: '@stratusagent/tool-fs',
|
|
376
|
+
setup(context) {
|
|
377
|
+
context.tools.register(createReadTool(config));
|
|
378
|
+
context.tools.register(createListTool(config));
|
|
379
|
+
context.tools.register(createSearchTool(config));
|
|
380
|
+
context.tools.register(createWriteTool(config));
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
/** The loader's ABI. See `docs/architecture/plugins.md`. */
|
|
384
|
+
export const createPlugin = createFsPlugin;
|
|
385
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
/** A path that is not inside anything this agent was given. */
|
|
3
|
+
export declare class PathOutsideRootError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
/** `~` and `~/x` mean the same thing here as they do in a shell. */
|
|
7
|
+
export declare const expandHome: (value: string, home?: string) => string;
|
|
8
|
+
/**
|
|
9
|
+
* The roots an agent may reach, canonicalized.
|
|
10
|
+
*
|
|
11
|
+
* A root can itself be a symlink — `~/work` pointing at a volume is
|
|
12
|
+
* ordinary — so containment is decided between real paths on both sides.
|
|
13
|
+
* Comparing a real target against a symlinked root would refuse every
|
|
14
|
+
* legitimate read; comparing a lexical target against a real root would
|
|
15
|
+
* accept every escape.
|
|
16
|
+
*/
|
|
17
|
+
export declare const canonicalRoots: (roots: readonly string[], home?: string) => Promise<string[]>;
|
|
18
|
+
export interface ResolvedPath {
|
|
19
|
+
/** The canonical path to act on. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** The canonical root it was found inside. */
|
|
22
|
+
root: string;
|
|
23
|
+
/** True when something is already there. */
|
|
24
|
+
exists: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The file this resolution was decided about, identified by inode rather
|
|
27
|
+
* than by name — see `openContained`.
|
|
28
|
+
*/
|
|
29
|
+
identity?: {
|
|
30
|
+
dev: number;
|
|
31
|
+
ino: number;
|
|
32
|
+
};
|
|
33
|
+
/** What is actually there: a regular file, a directory, or something else. */
|
|
34
|
+
kind?: 'file' | 'directory' | 'other';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a requested path to a real one inside a real root, or refuse it.
|
|
38
|
+
*
|
|
39
|
+
* Lexical checks are not enough and this is the reason the whole module
|
|
40
|
+
* exists: `notes/../../.stratus/credentials.json` is caught by normalizing,
|
|
41
|
+
* but a *symlink* inside an allowed root pointing at the credential store
|
|
42
|
+
* is not — the string never leaves the root. So resolution canonicalizes
|
|
43
|
+
* the deepest part of the path that exists, and requires **that** to be
|
|
44
|
+
* inside the root.
|
|
45
|
+
*
|
|
46
|
+
* For a path that does not exist yet, the canonicalized part is its nearest
|
|
47
|
+
* existing ancestor, which is what makes the write-through-parent case a
|
|
48
|
+
* refusal: writing `root/link/passwd` where `link` points at `/etc`
|
|
49
|
+
* resolves to `/etc`, and `/etc` is not inside the root however innocent
|
|
50
|
+
* the requested string looks.
|
|
51
|
+
*/
|
|
52
|
+
export declare const resolveWithinRoots: (roots: readonly string[], requested: string, options?: {
|
|
53
|
+
home?: string;
|
|
54
|
+
allowMissing?: boolean;
|
|
55
|
+
}) => Promise<ResolvedPath>;
|
|
56
|
+
/**
|
|
57
|
+
* Open a resolved path without following a symlink, and prove afterwards
|
|
58
|
+
* that what opened is what was checked.
|
|
59
|
+
*
|
|
60
|
+
* Between resolution and open there is a window in which the name can be
|
|
61
|
+
* repointed at something else — the classic symlink-swap race. Two things
|
|
62
|
+
* close it as far as this platform allows: `O_NOFOLLOW`, so a name that
|
|
63
|
+
* *became* a symlink fails instead of being followed, and an inode
|
|
64
|
+
* comparison after the fact, because a name that was replaced with a
|
|
65
|
+
* different real file is a different inode even though the string matches.
|
|
66
|
+
*
|
|
67
|
+
* What this cannot do is walk the path a directory at a time holding each
|
|
68
|
+
* one open — that needs `openat`, which Node does not expose. A swap of an
|
|
69
|
+
* intermediate directory therefore remains possible in principle, which is
|
|
70
|
+
* why the roots themselves are the boundary and not this function alone.
|
|
71
|
+
*/
|
|
72
|
+
export declare const openContained: (resolved: ResolvedPath, flags: number, mode?: number) => Promise<Awaited<ReturnType<typeof open>>>;
|
|
73
|
+
/** Whether a directory entry is a real directory (never through a symlink). */
|
|
74
|
+
export declare const isRealDirectory: (target: string) => Promise<boolean>;
|
|
75
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AACA,OAAO,EAAS,IAAI,EAAkB,MAAM,kBAAkB,CAAC;AAI/D,+DAA+D;AAC/D,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,OAAO,EAAE,MAAM;CAI5B;AAED,oEAAoE;AACpE,eAAO,MAAM,UAAU,GAAI,OAAO,MAAM,EAAE,aAAmB,KAAG,MAQ/D,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GAAU,OAAO,SAAS,MAAM,EAAE,EAAE,aAAmB,KAAG,OAAO,CAAC,MAAM,EAAE,CAapG,CAAC;AAKF,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,MAAM,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,QAAQ,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,8EAA8E;IAC9E,IAAI,CAAC,EAAE,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC;CACvC;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,kBAAkB,GAC7B,OAAO,SAAS,MAAM,EAAE,EACxB,WAAW,MAAM,EACjB,UAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAO,KACtD,OAAO,CAAC,YAAY,CAsEtB,CAAC;AAIF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,aAAa,GACxB,UAAU,YAAY,EACtB,OAAO,MAAM,EACb,OAAO,MAAM,KACZ,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC,CAY1C,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,eAAe,GAAU,QAAQ,MAAM,KAAG,OAAO,CAAC,OAAO,CAMrE,CAAC"}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
/** A path that is not inside anything this agent was given. */
|
|
6
|
+
export class PathOutsideRootError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'PathOutsideRootError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** `~` and `~/x` mean the same thing here as they do in a shell. */
|
|
13
|
+
export const expandHome = (value, home = os.homedir()) => {
|
|
14
|
+
if (value === '~') {
|
|
15
|
+
return home;
|
|
16
|
+
}
|
|
17
|
+
if (value.startsWith('~/')) {
|
|
18
|
+
return path.join(home, value.slice(2));
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The roots an agent may reach, canonicalized.
|
|
24
|
+
*
|
|
25
|
+
* A root can itself be a symlink — `~/work` pointing at a volume is
|
|
26
|
+
* ordinary — so containment is decided between real paths on both sides.
|
|
27
|
+
* Comparing a real target against a symlinked root would refuse every
|
|
28
|
+
* legitimate read; comparing a lexical target against a real root would
|
|
29
|
+
* accept every escape.
|
|
30
|
+
*/
|
|
31
|
+
export const canonicalRoots = async (roots, home = os.homedir()) => {
|
|
32
|
+
const resolved = [];
|
|
33
|
+
for (const root of roots) {
|
|
34
|
+
const absolute = path.resolve(expandHome(root, home));
|
|
35
|
+
try {
|
|
36
|
+
resolved.push(await realpath(absolute));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// A root that does not exist yet grants nothing, rather than
|
|
40
|
+
// granting its lexical prefix.
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return resolved;
|
|
45
|
+
};
|
|
46
|
+
const contains = (root, candidate) => candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a requested path to a real one inside a real root, or refuse it.
|
|
49
|
+
*
|
|
50
|
+
* Lexical checks are not enough and this is the reason the whole module
|
|
51
|
+
* exists: `notes/../../.stratus/credentials.json` is caught by normalizing,
|
|
52
|
+
* but a *symlink* inside an allowed root pointing at the credential store
|
|
53
|
+
* is not — the string never leaves the root. So resolution canonicalizes
|
|
54
|
+
* the deepest part of the path that exists, and requires **that** to be
|
|
55
|
+
* inside the root.
|
|
56
|
+
*
|
|
57
|
+
* For a path that does not exist yet, the canonicalized part is its nearest
|
|
58
|
+
* existing ancestor, which is what makes the write-through-parent case a
|
|
59
|
+
* refusal: writing `root/link/passwd` where `link` points at `/etc`
|
|
60
|
+
* resolves to `/etc`, and `/etc` is not inside the root however innocent
|
|
61
|
+
* the requested string looks.
|
|
62
|
+
*/
|
|
63
|
+
export const resolveWithinRoots = async (roots, requested, options = {}) => {
|
|
64
|
+
const home = options.home ?? os.homedir();
|
|
65
|
+
const canonical = await canonicalRoots(roots, home);
|
|
66
|
+
if (canonical.length === 0) {
|
|
67
|
+
throw new PathOutsideRootError('No filesystem roots are configured for this agent, so nothing is readable. '
|
|
68
|
+
+ 'Set roots for it under plugins["@stratusagent/tool-fs"].');
|
|
69
|
+
}
|
|
70
|
+
const expanded = expandHome(requested, home);
|
|
71
|
+
// A relative path is relative to the agent's first root, which is what an
|
|
72
|
+
// agent means by `notes/today.md`. It is normalized before anything else,
|
|
73
|
+
// so `../` is spent against the root rather than against the daemon's cwd.
|
|
74
|
+
const absolute = path.isAbsolute(expanded)
|
|
75
|
+
? path.normalize(expanded)
|
|
76
|
+
: path.resolve(canonical[0], expanded);
|
|
77
|
+
const missing = [];
|
|
78
|
+
let cursor = absolute;
|
|
79
|
+
let real;
|
|
80
|
+
for (;;) {
|
|
81
|
+
try {
|
|
82
|
+
real = await realpath(cursor);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code !== 'ENOENT') {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
const parent = path.dirname(cursor);
|
|
90
|
+
if (parent === cursor) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
missing.unshift(path.basename(cursor));
|
|
94
|
+
cursor = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (real === undefined) {
|
|
98
|
+
throw new PathOutsideRootError(`Path does not resolve to anything inside an allowed root: ${requested}`);
|
|
99
|
+
}
|
|
100
|
+
const root = canonical.find((candidate) => contains(candidate, real));
|
|
101
|
+
if (!root) {
|
|
102
|
+
throw new PathOutsideRootError(`Refusing ${requested}: it resolves to ${real}${missing.length > 0 ? `/${missing.join('/')}` : ''}, `
|
|
103
|
+
+ `which is outside every root this agent may reach (${canonical.join(', ')}).`);
|
|
104
|
+
}
|
|
105
|
+
if (missing.length > 0) {
|
|
106
|
+
const resolved = path.join(real, ...missing);
|
|
107
|
+
if (!options.allowMissing) {
|
|
108
|
+
const error = new Error(`No such file or directory: ${requested}`);
|
|
109
|
+
error.code = 'ENOENT';
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return { path: resolved, root, exists: false };
|
|
113
|
+
}
|
|
114
|
+
const resolved = real;
|
|
115
|
+
const info = await lstat(resolved);
|
|
116
|
+
return {
|
|
117
|
+
path: resolved,
|
|
118
|
+
root,
|
|
119
|
+
exists: true,
|
|
120
|
+
identity: { dev: info.dev, ino: info.ino },
|
|
121
|
+
// A realpath never ends at a symlink, so this is what the file is.
|
|
122
|
+
kind: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
126
|
+
/**
|
|
127
|
+
* Open a resolved path without following a symlink, and prove afterwards
|
|
128
|
+
* that what opened is what was checked.
|
|
129
|
+
*
|
|
130
|
+
* Between resolution and open there is a window in which the name can be
|
|
131
|
+
* repointed at something else — the classic symlink-swap race. Two things
|
|
132
|
+
* close it as far as this platform allows: `O_NOFOLLOW`, so a name that
|
|
133
|
+
* *became* a symlink fails instead of being followed, and an inode
|
|
134
|
+
* comparison after the fact, because a name that was replaced with a
|
|
135
|
+
* different real file is a different inode even though the string matches.
|
|
136
|
+
*
|
|
137
|
+
* What this cannot do is walk the path a directory at a time holding each
|
|
138
|
+
* one open — that needs `openat`, which Node does not expose. A swap of an
|
|
139
|
+
* intermediate directory therefore remains possible in principle, which is
|
|
140
|
+
* why the roots themselves are the boundary and not this function alone.
|
|
141
|
+
*/
|
|
142
|
+
export const openContained = async (resolved, flags, mode) => {
|
|
143
|
+
const handle = await open(resolved.path, flags | O_NOFOLLOW, mode);
|
|
144
|
+
if (resolved.identity) {
|
|
145
|
+
const info = await handle.stat();
|
|
146
|
+
if (info.dev !== resolved.identity.dev || info.ino !== resolved.identity.ino) {
|
|
147
|
+
await handle.close();
|
|
148
|
+
throw new PathOutsideRootError(`Refusing ${resolved.path}: it changed between the containment check and the open.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return handle;
|
|
152
|
+
};
|
|
153
|
+
/** Whether a directory entry is a real directory (never through a symlink). */
|
|
154
|
+
export const isRealDirectory = async (target) => {
|
|
155
|
+
try {
|
|
156
|
+
return (await stat(target)).isDirectory();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.js","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stratusagent/tool-fs",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.13 <23 || >=23.4"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@stratusagent/core": "0.6.0",
|
|
22
|
+
"@stratusagent/plugins": "0.6.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^24.6.0",
|
|
26
|
+
"typescript": "^5.9.3"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/stratuslabs/agent.git",
|
|
32
|
+
"directory": "packages/tool-fs"
|
|
33
|
+
},
|
|
34
|
+
"description": "Filesystem tools for Stratus agents: read, list, search, and write inside per-agent roots, with symlink-safe containment",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"stratus",
|
|
37
|
+
"agent",
|
|
38
|
+
"ai",
|
|
39
|
+
"llm",
|
|
40
|
+
"stratus-plugin",
|
|
41
|
+
"tools",
|
|
42
|
+
"filesystem"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"stratus": {
|
|
48
|
+
"pluginVersion": 1,
|
|
49
|
+
"contributes": {
|
|
50
|
+
"tools": [
|
|
51
|
+
{
|
|
52
|
+
"name": "fs.read",
|
|
53
|
+
"risk": "safe"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "fs.list",
|
|
57
|
+
"risk": "safe"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "fs.search",
|
|
61
|
+
"risk": "safe"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "fs.write",
|
|
65
|
+
"risk": "gated"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"config": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"roots": {
|
|
73
|
+
"type": "array",
|
|
74
|
+
"items": {
|
|
75
|
+
"type": "string"
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"maxBytes": {
|
|
79
|
+
"type": "integer"
|
|
80
|
+
},
|
|
81
|
+
"maxMatches": {
|
|
82
|
+
"type": "integer"
|
|
83
|
+
},
|
|
84
|
+
"maxEntries": {
|
|
85
|
+
"type": "integer"
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"additionalProperties": false
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"scripts": {
|
|
92
|
+
"build": "tsc -b",
|
|
93
|
+
"typecheck": "tsc --noEmit --project tsconfig.test.json",
|
|
94
|
+
"test": "node --test --experimental-strip-types"
|
|
95
|
+
}
|
|
96
|
+
}
|