cclaw-cli 0.25.0 → 0.26.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/dist/cli.js +2 -1
- package/dist/eval/agents/with-tools.d.ts +31 -0
- package/dist/eval/agents/with-tools.js +255 -0
- package/dist/eval/config-loader.js +34 -2
- package/dist/eval/llm-client.d.ts +10 -0
- package/dist/eval/llm-client.js +10 -1
- package/dist/eval/report.js +19 -0
- package/dist/eval/runner.js +50 -2
- package/dist/eval/sandbox.d.ts +38 -0
- package/dist/eval/sandbox.js +137 -0
- package/dist/eval/tools/glob.d.ts +2 -0
- package/dist/eval/tools/glob.js +163 -0
- package/dist/eval/tools/grep.d.ts +2 -0
- package/dist/eval/tools/grep.js +152 -0
- package/dist/eval/tools/index.d.ts +7 -0
- package/dist/eval/tools/index.js +35 -0
- package/dist/eval/tools/read.d.ts +2 -0
- package/dist/eval/tools/read.js +122 -0
- package/dist/eval/tools/types.d.ts +49 -0
- package/dist/eval/tools/types.js +41 -0
- package/dist/eval/tools/write.d.ts +2 -0
- package/dist/eval/tools/write.js +92 -0
- package/dist/eval/types.d.ts +35 -0
- package/package.json +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-case sandbox for the Tier B with-tools agent.
|
|
3
|
+
*
|
|
4
|
+
* Every case gets its own `os.tmpdir()/cclaw-eval-<uuid>/` directory. Any
|
|
5
|
+
* `contextFiles` the case declares are copied in relative to the project
|
|
6
|
+
* root, and every tool invocation resolves paths against the sandbox
|
|
7
|
+
* root with a defensive check that refuses symlinks and `..` escapes.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
*
|
|
11
|
+
* - The sandbox is intentionally tiny (one directory, no symlink
|
|
12
|
+
* creation, no executable bits). We rely on `fs.realpath` on every
|
|
13
|
+
* resolved path so hostile tool output that creates a symlink to
|
|
14
|
+
* `/etc/passwd` and then tries to read it still trips the boundary
|
|
15
|
+
* check.
|
|
16
|
+
* - Cleanup is handled by `dispose()`; callers (runner, tests) must
|
|
17
|
+
* invoke it in a `try/finally` so leftover temp directories never
|
|
18
|
+
* accumulate.
|
|
19
|
+
* - The sandbox does not preserve the project's directory structure
|
|
20
|
+
* verbatim. Each entry in `contextFiles` is copied flat into
|
|
21
|
+
* `sandboxRoot/<basename>` unless it contains path separators, in
|
|
22
|
+
* which case the full relative layout is recreated. That keeps demo
|
|
23
|
+
* cases portable while still letting richer cases place files under
|
|
24
|
+
* subdirectories (e.g. `.cclaw/skills/brainstorming/SKILL.md`).
|
|
25
|
+
*/
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import fs from "node:fs/promises";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
export class SandboxEscapeError extends Error {
|
|
31
|
+
requestedPath;
|
|
32
|
+
constructor(requestedPath, reason) {
|
|
33
|
+
super(`Sandbox refused path "${requestedPath}": ${reason}.`);
|
|
34
|
+
this.name = "SandboxEscapeError";
|
|
35
|
+
this.requestedPath = requestedPath;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Create and prep a fresh sandbox. Callers own cleanup via `dispose()`. */
|
|
39
|
+
export async function createSandbox(options) {
|
|
40
|
+
const baseDir = options.baseDir ?? os.tmpdir();
|
|
41
|
+
const id = options.idOverride ?? randomUUID();
|
|
42
|
+
const root = path.join(baseDir, `cclaw-eval-${id}`);
|
|
43
|
+
await fs.mkdir(root, { recursive: true });
|
|
44
|
+
const realRoot = await fs.realpath(root);
|
|
45
|
+
if (options.contextFiles && options.contextFiles.length > 0) {
|
|
46
|
+
for (const rel of options.contextFiles) {
|
|
47
|
+
await copyContextFile(options.projectRoot, realRoot, rel);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function resolveInside(requested, opts = {}) {
|
|
51
|
+
if (typeof requested !== "string" || requested.length === 0) {
|
|
52
|
+
throw new SandboxEscapeError(String(requested), "path must be a non-empty string");
|
|
53
|
+
}
|
|
54
|
+
if (path.isAbsolute(requested)) {
|
|
55
|
+
throw new SandboxEscapeError(requested, "absolute paths are not allowed");
|
|
56
|
+
}
|
|
57
|
+
if (requested.includes("\0")) {
|
|
58
|
+
throw new SandboxEscapeError(requested, "NUL byte in path");
|
|
59
|
+
}
|
|
60
|
+
const joined = path.resolve(realRoot, requested);
|
|
61
|
+
const relative = path.relative(realRoot, joined);
|
|
62
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
63
|
+
throw new SandboxEscapeError(requested, "resolves outside the sandbox");
|
|
64
|
+
}
|
|
65
|
+
let finalPath;
|
|
66
|
+
try {
|
|
67
|
+
finalPath = await fs.realpath(joined);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (!opts.allowMissing) {
|
|
71
|
+
throw new SandboxEscapeError(requested, `realpath failed: ${err.message}`);
|
|
72
|
+
}
|
|
73
|
+
const existingAncestor = await findExistingAncestor(joined, realRoot);
|
|
74
|
+
if (!existingAncestor) {
|
|
75
|
+
throw new SandboxEscapeError(requested, "no existing ancestor inside the sandbox");
|
|
76
|
+
}
|
|
77
|
+
const ancestorRel = path.relative(realRoot, existingAncestor.real);
|
|
78
|
+
if (ancestorRel.startsWith("..") || path.isAbsolute(ancestorRel)) {
|
|
79
|
+
throw new SandboxEscapeError(requested, "parent resolves outside the sandbox");
|
|
80
|
+
}
|
|
81
|
+
finalPath = path.join(existingAncestor.real, existingAncestor.trailing);
|
|
82
|
+
}
|
|
83
|
+
const finalRel = path.relative(realRoot, finalPath);
|
|
84
|
+
if (finalRel.startsWith("..") || path.isAbsolute(finalRel)) {
|
|
85
|
+
throw new SandboxEscapeError(requested, "realpath escapes the sandbox");
|
|
86
|
+
}
|
|
87
|
+
return finalPath;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
root: realRoot,
|
|
91
|
+
resolve: resolveInside,
|
|
92
|
+
async dispose() {
|
|
93
|
+
await fs.rm(realRoot, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function findExistingAncestor(target, stopAt) {
|
|
98
|
+
const segments = [];
|
|
99
|
+
let current = target;
|
|
100
|
+
while (true) {
|
|
101
|
+
try {
|
|
102
|
+
const real = await fs.realpath(current);
|
|
103
|
+
return { real, trailing: path.join(...segments.reverse()) };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
const parent = path.dirname(current);
|
|
107
|
+
if (parent === current)
|
|
108
|
+
return undefined;
|
|
109
|
+
segments.push(path.basename(current));
|
|
110
|
+
if (path.relative(stopAt, parent).startsWith(".."))
|
|
111
|
+
return undefined;
|
|
112
|
+
current = parent;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function copyContextFile(projectRoot, sandboxRoot, relPath) {
|
|
117
|
+
if (path.isAbsolute(relPath)) {
|
|
118
|
+
throw new Error(`context_files must be project-relative: ${relPath}`);
|
|
119
|
+
}
|
|
120
|
+
const src = path.resolve(projectRoot, relPath);
|
|
121
|
+
const srcReal = await fs.realpath(src);
|
|
122
|
+
const projectReal = await fs.realpath(projectRoot);
|
|
123
|
+
const inside = path.relative(projectReal, srcReal);
|
|
124
|
+
if (inside.startsWith("..") || path.isAbsolute(inside)) {
|
|
125
|
+
throw new Error(`context_files entry resolves outside the project: ${relPath}`);
|
|
126
|
+
}
|
|
127
|
+
const stat = await fs.stat(srcReal);
|
|
128
|
+
if (stat.isDirectory()) {
|
|
129
|
+
const dest = path.join(sandboxRoot, relPath);
|
|
130
|
+
await fs.mkdir(dest, { recursive: true });
|
|
131
|
+
await fs.cp(srcReal, dest, { recursive: true });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const dest = path.join(sandboxRoot, relPath);
|
|
135
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
136
|
+
await fs.copyFile(srcReal, dest);
|
|
137
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SandboxEscapeError } from "../sandbox.js";
|
|
4
|
+
import { parseArgs, requireString, truncatePayload } from "./types.js";
|
|
5
|
+
const DESCRIPTION = "List files inside the sandbox whose relative path matches a glob-style " +
|
|
6
|
+
"pattern. Supports `*` (any chars within a path segment) and `**` " +
|
|
7
|
+
"(any number of path segments). Returns matching paths, one per line.";
|
|
8
|
+
const MAX_MATCHES = 500;
|
|
9
|
+
export const globTool = {
|
|
10
|
+
descriptor: {
|
|
11
|
+
name: "glob",
|
|
12
|
+
description: DESCRIPTION,
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
required: ["pattern"],
|
|
17
|
+
properties: {
|
|
18
|
+
pattern: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Glob pattern, relative to the sandbox root."
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
async invoke(rawArgs, ctx) {
|
|
26
|
+
let args;
|
|
27
|
+
try {
|
|
28
|
+
args = parseArgs(rawArgs);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return { ok: false, name: this.descriptor.name, error: err.message };
|
|
32
|
+
}
|
|
33
|
+
let pattern;
|
|
34
|
+
try {
|
|
35
|
+
pattern = requireString(args, "pattern");
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return { ok: false, name: this.descriptor.name, error: err.message };
|
|
39
|
+
}
|
|
40
|
+
if (pattern.includes("\0")) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
name: this.descriptor.name,
|
|
44
|
+
error: '"pattern" must not contain NUL bytes'
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
let regex;
|
|
48
|
+
try {
|
|
49
|
+
regex = globToRegExp(pattern);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
name: this.descriptor.name,
|
|
55
|
+
error: err.message
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const matches = [];
|
|
59
|
+
try {
|
|
60
|
+
await walk(ctx.sandbox.root, "", matches, regex);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof SandboxEscapeError) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
name: this.descriptor.name,
|
|
67
|
+
error: err.message,
|
|
68
|
+
details: { deniedPath: pattern }
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
name: this.descriptor.name,
|
|
74
|
+
error: `walk failed: ${err.message}`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
matches.sort();
|
|
78
|
+
const capped = matches.slice(0, MAX_MATCHES);
|
|
79
|
+
const body = capped.length > 0
|
|
80
|
+
? capped.join("\n") +
|
|
81
|
+
(matches.length > capped.length
|
|
82
|
+
? `\n…[truncated at ${MAX_MATCHES} matches]`
|
|
83
|
+
: "")
|
|
84
|
+
: "(no matches)";
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
name: this.descriptor.name,
|
|
88
|
+
content: truncatePayload(body, ctx.maxResultBytes),
|
|
89
|
+
details: {
|
|
90
|
+
pattern,
|
|
91
|
+
matches: capped.length,
|
|
92
|
+
totalMatches: matches.length,
|
|
93
|
+
truncated: matches.length > capped.length
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
async function walk(root, rel, acc, regex) {
|
|
99
|
+
const dir = path.join(root, rel);
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = (await fs.readdir(dir, { withFileTypes: true }));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const childRel = rel ? path.join(rel, entry.name) : entry.name;
|
|
109
|
+
if (entry.isSymbolicLink())
|
|
110
|
+
continue;
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
await walk(root, childRel, acc, regex);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (entry.isFile() && regex.test(childRel.replace(/\\/g, "/"))) {
|
|
116
|
+
acc.push(childRel);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Minimal glob → regex: `**` matches zero or more path segments, `*`
|
|
122
|
+
* matches anything except `/`, `?` matches a single non-slash char.
|
|
123
|
+
* Everything else is escaped. Intentionally narrower than full
|
|
124
|
+
* bash-style expansion so behavior is easy to reason about.
|
|
125
|
+
*/
|
|
126
|
+
function globToRegExp(pattern) {
|
|
127
|
+
const normalized = pattern.replace(/\\/g, "/");
|
|
128
|
+
let src = "^";
|
|
129
|
+
let i = 0;
|
|
130
|
+
while (i < normalized.length) {
|
|
131
|
+
const c = normalized[i];
|
|
132
|
+
if (c === "*") {
|
|
133
|
+
if (normalized[i + 1] === "*") {
|
|
134
|
+
if (normalized[i + 2] === "/") {
|
|
135
|
+
src += "(?:.*/)?";
|
|
136
|
+
i += 3;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
src += ".*";
|
|
140
|
+
i += 2;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
src += "[^/]*";
|
|
145
|
+
i += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (c === "?") {
|
|
149
|
+
src += "[^/]";
|
|
150
|
+
i += 1;
|
|
151
|
+
}
|
|
152
|
+
else if ("+()|^$.{}[]\\".includes(c)) {
|
|
153
|
+
src += `\\${c}`;
|
|
154
|
+
i += 1;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
src += c;
|
|
158
|
+
i += 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
src += "$";
|
|
162
|
+
return new RegExp(src);
|
|
163
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SandboxEscapeError } from "../sandbox.js";
|
|
4
|
+
import { parseArgs, requireString, optionalNumber, truncatePayload } from "./types.js";
|
|
5
|
+
const DESCRIPTION = "Search the sandbox for a regular expression. Returns matching lines in " +
|
|
6
|
+
"`path:line:text` form. Accepts optional `caseInsensitive` and a per-call " +
|
|
7
|
+
"`maxMatches` cap (default 100, hard max 500).";
|
|
8
|
+
const HARD_MAX = 500;
|
|
9
|
+
export const grepTool = {
|
|
10
|
+
descriptor: {
|
|
11
|
+
name: "grep",
|
|
12
|
+
description: DESCRIPTION,
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
required: ["pattern"],
|
|
17
|
+
properties: {
|
|
18
|
+
pattern: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Regular expression compiled with JavaScript semantics."
|
|
21
|
+
},
|
|
22
|
+
caseInsensitive: {
|
|
23
|
+
type: "boolean",
|
|
24
|
+
description: "Match case-insensitively (default false)."
|
|
25
|
+
},
|
|
26
|
+
maxMatches: {
|
|
27
|
+
type: "integer",
|
|
28
|
+
minimum: 1,
|
|
29
|
+
description: "Stop after N matches (default 100, hard max 500)."
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
async invoke(rawArgs, ctx) {
|
|
35
|
+
let args;
|
|
36
|
+
try {
|
|
37
|
+
args = parseArgs(rawArgs);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return { ok: false, name: this.descriptor.name, error: err.message };
|
|
41
|
+
}
|
|
42
|
+
let pattern;
|
|
43
|
+
try {
|
|
44
|
+
pattern = requireString(args, "pattern");
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return { ok: false, name: this.descriptor.name, error: err.message };
|
|
48
|
+
}
|
|
49
|
+
const caseInsensitive = args.caseInsensitive === true;
|
|
50
|
+
let maxMatches;
|
|
51
|
+
try {
|
|
52
|
+
const raw = optionalNumber(args, "maxMatches");
|
|
53
|
+
maxMatches = raw === undefined ? 100 : Math.min(HARD_MAX, Math.max(1, Math.floor(raw)));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
name: this.descriptor.name,
|
|
59
|
+
error: err.message
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
let regex;
|
|
63
|
+
try {
|
|
64
|
+
regex = new RegExp(pattern, caseInsensitive ? "i" : "");
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
name: this.descriptor.name,
|
|
70
|
+
error: `invalid regex: ${err.message}`
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
let filesScanned = 0;
|
|
74
|
+
const hits = [];
|
|
75
|
+
try {
|
|
76
|
+
await walk(ctx.sandbox.root, "", async (relPath, abs) => {
|
|
77
|
+
if (hits.length >= maxMatches)
|
|
78
|
+
return false;
|
|
79
|
+
let content;
|
|
80
|
+
try {
|
|
81
|
+
content = await fs.readFile(abs, "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
filesScanned += 1;
|
|
87
|
+
const lines = content.split(/\r?\n/);
|
|
88
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
89
|
+
const line = lines[i];
|
|
90
|
+
if (regex.test(line)) {
|
|
91
|
+
hits.push(`${relPath}:${i + 1}:${line}`);
|
|
92
|
+
if (hits.length >= maxMatches)
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof SandboxEscapeError) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
name: this.descriptor.name,
|
|
104
|
+
error: err.message,
|
|
105
|
+
details: { deniedPath: pattern }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
name: this.descriptor.name,
|
|
111
|
+
error: `walk failed: ${err.message}`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const body = hits.length > 0 ? hits.join("\n") : "(no matches)";
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
name: this.descriptor.name,
|
|
118
|
+
content: truncatePayload(body, ctx.maxResultBytes),
|
|
119
|
+
details: {
|
|
120
|
+
pattern,
|
|
121
|
+
caseInsensitive,
|
|
122
|
+
matches: hits.length,
|
|
123
|
+
filesScanned,
|
|
124
|
+
truncated: hits.length >= maxMatches
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
async function walk(root, rel, visit) {
|
|
130
|
+
const dir = path.join(root, rel);
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = (await fs.readdir(dir, { withFileTypes: true }));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const childRel = rel ? path.join(rel, entry.name) : entry.name;
|
|
140
|
+
if (entry.isSymbolicLink())
|
|
141
|
+
continue;
|
|
142
|
+
if (entry.isDirectory()) {
|
|
143
|
+
await walk(root, childRel, visit);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (entry.isFile()) {
|
|
147
|
+
const keepGoing = await visit(childRel.replace(/\\/g, "/"), path.join(root, childRel));
|
|
148
|
+
if (keepGoing === false)
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SandboxTool } from "./types.js";
|
|
2
|
+
export { SandboxTool, ToolResult, ToolContext, truncatePayload } from "./types.js";
|
|
3
|
+
export declare const BUILTIN_TOOLS: SandboxTool[];
|
|
4
|
+
/** Build a lookup for the agent loop. */
|
|
5
|
+
export declare function toolsByName(tools?: SandboxTool[]): Map<string, SandboxTool>;
|
|
6
|
+
/** Shape a tool list for OpenAI-style `tools[]` in the chat request. */
|
|
7
|
+
export declare function toolsForRequest(tools?: SandboxTool[]): unknown[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of sandbox-confined tools used by the Tier B with-tools agent.
|
|
3
|
+
*
|
|
4
|
+
* The registry order defines the advertised schema order in the
|
|
5
|
+
* function-calling payload. Keeping it stable means judges reading
|
|
6
|
+
* generated traces can rely on predictable tool descriptions.
|
|
7
|
+
*/
|
|
8
|
+
import { globTool } from "./glob.js";
|
|
9
|
+
import { grepTool } from "./grep.js";
|
|
10
|
+
import { readTool } from "./read.js";
|
|
11
|
+
import { writeTool } from "./write.js";
|
|
12
|
+
export { truncatePayload } from "./types.js";
|
|
13
|
+
export const BUILTIN_TOOLS = [readTool, writeTool, globTool, grepTool];
|
|
14
|
+
/** Build a lookup for the agent loop. */
|
|
15
|
+
export function toolsByName(tools = BUILTIN_TOOLS) {
|
|
16
|
+
const map = new Map();
|
|
17
|
+
for (const tool of tools) {
|
|
18
|
+
if (map.has(tool.descriptor.name)) {
|
|
19
|
+
throw new Error(`duplicate tool name: ${tool.descriptor.name}`);
|
|
20
|
+
}
|
|
21
|
+
map.set(tool.descriptor.name, tool);
|
|
22
|
+
}
|
|
23
|
+
return map;
|
|
24
|
+
}
|
|
25
|
+
/** Shape a tool list for OpenAI-style `tools[]` in the chat request. */
|
|
26
|
+
export function toolsForRequest(tools = BUILTIN_TOOLS) {
|
|
27
|
+
return tools.map((tool) => ({
|
|
28
|
+
type: "function",
|
|
29
|
+
function: {
|
|
30
|
+
name: tool.descriptor.name,
|
|
31
|
+
description: tool.descriptor.description,
|
|
32
|
+
parameters: tool.descriptor.parameters
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { SandboxEscapeError } from "../sandbox.js";
|
|
3
|
+
import { parseArgs, requireString, optionalNumber, truncatePayload } from "./types.js";
|
|
4
|
+
const DESCRIPTION = "Read a UTF-8 text file from the sandbox. Returns the file contents. " +
|
|
5
|
+
"Supports optional 1-indexed `offset` and `limit` to read a slice.";
|
|
6
|
+
export const readTool = {
|
|
7
|
+
descriptor: {
|
|
8
|
+
name: "read_file",
|
|
9
|
+
description: DESCRIPTION,
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
required: ["path"],
|
|
14
|
+
properties: {
|
|
15
|
+
path: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Path relative to the sandbox root."
|
|
18
|
+
},
|
|
19
|
+
offset: {
|
|
20
|
+
type: "integer",
|
|
21
|
+
minimum: 1,
|
|
22
|
+
description: "1-indexed start line (inclusive)."
|
|
23
|
+
},
|
|
24
|
+
limit: {
|
|
25
|
+
type: "integer",
|
|
26
|
+
minimum: 1,
|
|
27
|
+
description: "Maximum number of lines to return."
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
async invoke(rawArgs, ctx) {
|
|
33
|
+
let args;
|
|
34
|
+
try {
|
|
35
|
+
args = parseArgs(rawArgs);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return { ok: false, name: this.descriptor.name, error: err.message };
|
|
39
|
+
}
|
|
40
|
+
let relPath;
|
|
41
|
+
try {
|
|
42
|
+
relPath = requireString(args, "path");
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return { ok: false, name: this.descriptor.name, error: err.message };
|
|
46
|
+
}
|
|
47
|
+
let offset;
|
|
48
|
+
let limit;
|
|
49
|
+
try {
|
|
50
|
+
offset = optionalNumber(args, "offset");
|
|
51
|
+
limit = optionalNumber(args, "limit");
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
name: this.descriptor.name,
|
|
57
|
+
error: err.message
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (offset !== undefined && (!Number.isInteger(offset) || offset < 1)) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
name: this.descriptor.name,
|
|
64
|
+
error: '"offset" must be a positive integer'
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
name: this.descriptor.name,
|
|
71
|
+
error: '"limit" must be a positive integer'
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
let abs;
|
|
75
|
+
try {
|
|
76
|
+
abs = await ctx.sandbox.resolve(relPath);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const denied = err instanceof SandboxEscapeError ? relPath : undefined;
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
name: this.descriptor.name,
|
|
83
|
+
error: err.message,
|
|
84
|
+
details: denied ? { deniedPath: denied } : undefined
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
raw = await fs.readFile(abs, "utf8");
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
name: this.descriptor.name,
|
|
95
|
+
error: `read failed: ${err.message}`,
|
|
96
|
+
details: { path: relPath }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
let content = raw;
|
|
100
|
+
let effectiveLines;
|
|
101
|
+
if (offset !== undefined || limit !== undefined) {
|
|
102
|
+
const lines = raw.split(/\r?\n/);
|
|
103
|
+
const start = Math.max(0, (offset ?? 1) - 1);
|
|
104
|
+
const end = limit !== undefined ? Math.min(lines.length, start + limit) : lines.length;
|
|
105
|
+
const slice = lines.slice(start, end);
|
|
106
|
+
content = slice.join("\n");
|
|
107
|
+
effectiveLines = slice.length;
|
|
108
|
+
}
|
|
109
|
+
const truncated = truncatePayload(content, ctx.maxResultBytes);
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
name: this.descriptor.name,
|
|
113
|
+
content: truncated,
|
|
114
|
+
details: {
|
|
115
|
+
path: relPath,
|
|
116
|
+
bytes: Buffer.byteLength(truncated, "utf8"),
|
|
117
|
+
truncated: truncated !== content,
|
|
118
|
+
...(effectiveLines !== undefined ? { lines: effectiveLines } : {})
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for Tier B sandbox-confined tools.
|
|
3
|
+
*
|
|
4
|
+
* Tools are plain async functions: they take validated arguments and a
|
|
5
|
+
* sandbox handle and return a structured result. The runner serializes
|
|
6
|
+
* results for the model as JSON; the `SandboxTool.invoke` wrapper keeps
|
|
7
|
+
* both the raw structured output (for tests/metrics) and the stringified
|
|
8
|
+
* model-facing payload.
|
|
9
|
+
*/
|
|
10
|
+
import type { Sandbox } from "../sandbox.js";
|
|
11
|
+
export interface ToolDescriptor {
|
|
12
|
+
/** Name the model calls (must match the function-calling schema). */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Human-readable prompt shown to the model. */
|
|
15
|
+
description: string;
|
|
16
|
+
/** JSON schema shipped with the OpenAI-style `tools[]` array. */
|
|
17
|
+
parameters: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
export interface ToolContext {
|
|
20
|
+
sandbox: Sandbox;
|
|
21
|
+
/**
|
|
22
|
+
* Maximum bytes the tool may return in `content`. Results longer than
|
|
23
|
+
* this are truncated with a trailing marker so the model sees the
|
|
24
|
+
* cutoff.
|
|
25
|
+
*/
|
|
26
|
+
maxResultBytes: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ToolSuccess {
|
|
29
|
+
ok: true;
|
|
30
|
+
name: string;
|
|
31
|
+
content: string;
|
|
32
|
+
details?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
export interface ToolFailure {
|
|
35
|
+
ok: false;
|
|
36
|
+
name: string;
|
|
37
|
+
error: string;
|
|
38
|
+
details?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
export type ToolResult = ToolSuccess | ToolFailure;
|
|
41
|
+
export interface SandboxTool {
|
|
42
|
+
descriptor: ToolDescriptor;
|
|
43
|
+
invoke(rawArgs: string, ctx: ToolContext): Promise<ToolResult>;
|
|
44
|
+
}
|
|
45
|
+
/** Truncate a result payload to `maxBytes` with a visible cutoff marker. */
|
|
46
|
+
export declare function truncatePayload(payload: string, maxBytes: number): string;
|
|
47
|
+
export declare function parseArgs(raw: string): Record<string, unknown>;
|
|
48
|
+
export declare function requireString(args: Record<string, unknown>, key: string): string;
|
|
49
|
+
export declare function optionalNumber(args: Record<string, unknown>, key: string): number | undefined;
|