@juliangruber/harness 1.0.0 → 1.0.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/dist/debug.js +5 -4
- package/dist/instructions.js +4 -1
- package/dist/markdown.js +6 -1
- package/dist/tools.js +23 -4
- package/package.json +1 -1
package/dist/debug.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stderr } from 'node:process';
|
|
2
2
|
import { styleText } from 'node:util';
|
|
3
|
-
import { renderMarkdown } from './markdown.js';
|
|
3
|
+
import { renderMarkdown, stripControl } from './markdown.js';
|
|
4
4
|
const style = (format, text) => styleText(format, text, { stream: stderr });
|
|
5
5
|
const indent = (text, spaces) => text.split('\n').map(line => line && ' '.repeat(spaces) + line).join('\n');
|
|
6
6
|
// Printed between the debug log and the rendered answer
|
|
@@ -17,7 +17,7 @@ function formatArguments(args) {
|
|
|
17
17
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
18
18
|
return indent(JSON.stringify(parsed, null, 2), 2);
|
|
19
19
|
return Object.entries(parsed).map(([key, value]) => {
|
|
20
|
-
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
20
|
+
const text = stripControl(typeof value === 'string' ? value : JSON.stringify(value));
|
|
21
21
|
return text.includes('\n')
|
|
22
22
|
? ` ${style('dim', `${key}:`)}\n${indent(text, 4)}`
|
|
23
23
|
: ` ${style('dim', `${key}:`)} ${text}`;
|
|
@@ -26,11 +26,12 @@ function formatArguments(args) {
|
|
|
26
26
|
function formatResult(content) {
|
|
27
27
|
if (/^\s*[[{]/.test(content)) {
|
|
28
28
|
try {
|
|
29
|
-
return JSON.stringify(JSON.parse(content), null, 2);
|
|
29
|
+
return stripControl(JSON.stringify(JSON.parse(content), null, 2));
|
|
30
30
|
}
|
|
31
31
|
catch { }
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
// Tool results (web pages, MCP output) are untrusted, strip terminal escapes
|
|
34
|
+
return stripControl(content);
|
|
34
35
|
}
|
|
35
36
|
// Wraps a client to log everything exchanged with the model, including
|
|
36
37
|
// implicit context like the system prompt, tool definitions and tool results
|
package/dist/instructions.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { stripControl } from './markdown.js';
|
|
3
4
|
// In order of preference, matched case insensitively
|
|
4
5
|
const FILENAMES = ['agents.md', 'agent.md', 'claude.md'];
|
|
5
6
|
const PREVIEW_LINES = 10;
|
|
@@ -22,7 +23,9 @@ export async function resolveInstructions(cwd, { trust, ask, warn }) {
|
|
|
22
23
|
warn(`Ignoring ${instructions.path}: can't ask whether to trust it. Pass --trust to use it.`);
|
|
23
24
|
return;
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
+
// Strip control characters, so hidden or line-rewriting text can't disguise
|
|
27
|
+
// what the file actually says in the preview the user approves
|
|
28
|
+
const lines = stripControl(instructions.content).split('\n');
|
|
26
29
|
const preview = lines.slice(0, PREVIEW_LINES).map(line => ` ${line}`).join('\n');
|
|
27
30
|
const more = lines.length > PREVIEW_LINES ? `\n [${lines.length - PREVIEW_LINES} more lines]` : '';
|
|
28
31
|
const answer = await ask(`Found ${instructions.path} for review:\n${preview}${more}\nUse these instructions? [y/N] `);
|
package/dist/markdown.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { lexer } from 'marked';
|
|
2
2
|
import { stripVTControlCharacters, styleText } from 'node:util';
|
|
3
|
+
// Removes terminal control characters (ANSI escapes, carriage returns) but
|
|
4
|
+
// keeps newlines and tabs, so model or tool text can't rewrite lines, hide
|
|
5
|
+
// itself, or change the terminal title when printed. Not printable: 0x00-0x08,
|
|
6
|
+
// 0x0b-0x1f, 0x7f-0x9f. Kept: tab (0x09), newline (0x0a).
|
|
7
|
+
export const stripControl = (text) => text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '');
|
|
3
8
|
// Renders markdown for the terminal: marked parses, util.styleText styles
|
|
4
9
|
export function renderMarkdown(markdown, { color, stream, highlight } = {}) {
|
|
5
10
|
const style = (format, text) => color === false ? text : styleText(format, text, { validateStream: color === undefined, stream });
|
|
@@ -61,7 +66,7 @@ export function renderMarkdown(markdown, { color, stream, highlight } = {}) {
|
|
|
61
66
|
const divider = style('dim', widths.map(w => '─'.repeat(w)).join('─┼─'));
|
|
62
67
|
return [line(header), divider, ...rows.map(line)].join('\n');
|
|
63
68
|
};
|
|
64
|
-
return block(lexer(markdown));
|
|
69
|
+
return block(lexer(stripControl(markdown)));
|
|
65
70
|
}
|
|
66
71
|
const width = (text = '') => stripVTControlCharacters(text).length;
|
|
67
72
|
const indent = (text, n) => text.split('\n').map(line => line && ' '.repeat(n) + line).join('\n');
|
package/dist/tools.js
CHANGED
|
@@ -3,6 +3,8 @@ import { glob as fsGlob, mkdir, readFile, realpath, stat, writeFile } from 'node
|
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
const MAX_OUTPUT = 30_000;
|
|
5
5
|
const MAX_RESULTS = 200;
|
|
6
|
+
// Longest line grep runs a pattern against, to bound regex backtracking
|
|
7
|
+
const MAX_LINE = 1000;
|
|
6
8
|
const truncate = (text) => text.length > MAX_OUTPUT
|
|
7
9
|
? `${text.slice(0, MAX_OUTPUT)}\n[truncated ${text.length - MAX_OUTPUT} chars]`
|
|
8
10
|
: text;
|
|
@@ -41,6 +43,17 @@ async function checkPath(path) {
|
|
|
41
43
|
throw new Error(`${path} is outside the working directory ${fileRoot}, file tools can only access files inside it`);
|
|
42
44
|
}
|
|
43
45
|
}
|
|
46
|
+
// Writing into .git would let a planted hook run on the next git command,
|
|
47
|
+
// outside the harness. Refused even inside the working directory.
|
|
48
|
+
async function checkWritePath(path) {
|
|
49
|
+
await checkPath(path);
|
|
50
|
+
if (fileRoot === undefined)
|
|
51
|
+
return;
|
|
52
|
+
const real = await realPathOf(resolve(path));
|
|
53
|
+
if (real.split(sep).includes('.git')) {
|
|
54
|
+
throw new Error(`${path} is inside a .git directory, which file tools don't write to`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
44
57
|
export const read = {
|
|
45
58
|
name: 'read',
|
|
46
59
|
description: 'Read a text file. For large files, use offset and limit to read a range of lines.',
|
|
@@ -72,7 +85,7 @@ export const write = {
|
|
|
72
85
|
required: ['path', 'content']
|
|
73
86
|
},
|
|
74
87
|
async run({ path, content }) {
|
|
75
|
-
await
|
|
88
|
+
await checkWritePath(path);
|
|
76
89
|
await mkdir(dirname(path), { recursive: true });
|
|
77
90
|
await writeFile(path, content);
|
|
78
91
|
return `Wrote ${content.length} chars to ${path}`;
|
|
@@ -94,7 +107,7 @@ export const edit = {
|
|
|
94
107
|
async run({ path, old_string: oldString, new_string: newString, replace_all: replaceAll = false }) {
|
|
95
108
|
if (!oldString)
|
|
96
109
|
throw new Error('old_string must not be empty');
|
|
97
|
-
await
|
|
110
|
+
await checkWritePath(path);
|
|
98
111
|
const parts = (await readFile(path, 'utf8')).split(oldString);
|
|
99
112
|
const count = parts.length - 1;
|
|
100
113
|
if (count === 0)
|
|
@@ -182,6 +195,9 @@ export const grep = {
|
|
|
182
195
|
async run({ pattern, path = '.', include = '**/*' }) {
|
|
183
196
|
const regex = new RegExp(pattern);
|
|
184
197
|
await checkPath(path);
|
|
198
|
+
// Cap the input each match sees, so a catastrophic-backtracking pattern
|
|
199
|
+
// can't hang on a very long line (a minified file, say)
|
|
200
|
+
const forMatch = (line) => line.length > MAX_LINE ? line.slice(0, MAX_LINE) : line;
|
|
185
201
|
const files = (await stat(path)).isFile()
|
|
186
202
|
? [path]
|
|
187
203
|
: (await walk(include, path)).map(file => join(path, file));
|
|
@@ -199,7 +215,7 @@ export const grep = {
|
|
|
199
215
|
if (content.includes('\0'))
|
|
200
216
|
continue;
|
|
201
217
|
content.split('\n').forEach((line, i) => {
|
|
202
|
-
if (regex.test(line))
|
|
218
|
+
if (regex.test(forMatch(line)))
|
|
203
219
|
matches.push(`${file}:${i + 1}: ${line.slice(0, 500)}`);
|
|
204
220
|
});
|
|
205
221
|
if (matches.length > MAX_RESULTS)
|
|
@@ -208,6 +224,9 @@ export const grep = {
|
|
|
208
224
|
return matches.length ? limitResults(matches) : 'No matches found';
|
|
209
225
|
}
|
|
210
226
|
};
|
|
227
|
+
// Keeps secrets, like the LLM API key, out of commands the model runs
|
|
228
|
+
const SECRET_ENV = /KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH/i;
|
|
229
|
+
const scrubbedEnv = () => Object.fromEntries(Object.entries(process.env).filter(([name]) => !SECRET_ENV.test(name)));
|
|
211
230
|
export const bash = {
|
|
212
231
|
name: 'bash',
|
|
213
232
|
description: 'Run a bash command in the working directory. Returns stdout and stderr. Prefer the dedicated tools when one fits.',
|
|
@@ -218,7 +237,7 @@ export const bash = {
|
|
|
218
237
|
},
|
|
219
238
|
run({ command }) {
|
|
220
239
|
return new Promise(resolve => {
|
|
221
|
-
execFile('bash', ['-c', command], { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
240
|
+
execFile('bash', ['-c', command], { timeout: 120_000, maxBuffer: 10 * 1024 * 1024, env: scrubbedEnv() }, (err, stdout, stderr) => {
|
|
222
241
|
let output = stdout + stderr;
|
|
223
242
|
if (err)
|
|
224
243
|
output += `\n[exit ${err.code ?? err.signal}]`;
|