@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 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
- return content;
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
@@ -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
- const lines = instructions.content.split('\n');
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 checkPath(path);
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 checkPath(path);
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}]`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juliangruber/harness",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "General purpose agent harness with fair research, for LLMs behind an OpenAI compatible API",
5
5
  "repository": {
6
6
  "type": "git",