klyro 1.0.16 → 1.0.18

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.
Files changed (92) hide show
  1. package/dist/agent/anthropic-adapter.js +26 -1
  2. package/dist/agent/provider-adapter.d.ts +7 -0
  3. package/dist/agent/provider-adapter.js +26 -1
  4. package/dist/agent/runtime.d.ts +14 -0
  5. package/dist/agent/runtime.js +59 -22
  6. package/dist/chat.js +16 -56
  7. package/dist/checkpoints/store.d.ts +15 -1
  8. package/dist/checkpoints/store.js +7 -2
  9. package/dist/cli/args.d.ts +7 -0
  10. package/dist/cli/args.js +10 -0
  11. package/dist/cli/completion.js +0 -3
  12. package/dist/cli/doctor.d.ts +13 -0
  13. package/dist/cli/doctor.js +133 -1
  14. package/dist/cli/errors.js +1 -1
  15. package/dist/cli/eval.d.ts +32 -0
  16. package/dist/cli/eval.js +83 -6
  17. package/dist/cli/markdown.js +1 -1
  18. package/dist/cli/repl.js +13 -24
  19. package/dist/cli/run.d.ts +37 -0
  20. package/dist/cli/run.js +81 -9
  21. package/dist/cli/update.js +7 -1
  22. package/dist/context/accounting.js +1 -1
  23. package/dist/context/klyro-md.d.ts +11 -0
  24. package/dist/context/klyro-md.js +59 -4
  25. package/dist/context/level7.d.ts +4 -0
  26. package/dist/context/level7.js +20 -1
  27. package/dist/context/project-map.js +14 -13
  28. package/dist/eval/baseline.d.ts +47 -0
  29. package/dist/eval/baseline.js +55 -0
  30. package/dist/eval/harness.d.ts +14 -2
  31. package/dist/eval/harness.js +42 -4
  32. package/dist/index.js +57 -3
  33. package/dist/persistence/store.js +2 -2
  34. package/dist/policy/approval.js +3 -0
  35. package/dist/policy/path-guard.d.ts +9 -4
  36. package/dist/policy/path-guard.js +47 -23
  37. package/dist/providers/endpoints.js +8 -0
  38. package/dist/providers/model-info.d.ts +1 -0
  39. package/dist/providers/model-info.js +19 -0
  40. package/dist/providers.js +6 -2
  41. package/dist/renderers/terminal.d.ts +7 -0
  42. package/dist/renderers/terminal.js +52 -0
  43. package/dist/repl.d.ts +9 -0
  44. package/dist/repl.js +24 -5
  45. package/dist/shared/index.d.ts +1 -0
  46. package/dist/shared/index.js +1 -0
  47. package/dist/shared/proxy.d.ts +37 -0
  48. package/dist/shared/proxy.js +360 -0
  49. package/dist/tools/agent/task-list.d.ts +1 -1
  50. package/dist/tools/fs/apply-patch.js +4 -4
  51. package/dist/tools/fs/edit-file.js +2 -4
  52. package/dist/tools/fs/list-dir.js +1 -1
  53. package/dist/tools/fs/multi-edit.js +2 -2
  54. package/dist/tools/fs/read-file.js +1 -1
  55. package/dist/tools/fs/write-file.js +2 -2
  56. package/dist/tools/git/git-blame.d.ts +5 -0
  57. package/dist/tools/git/git-blame.js +22 -0
  58. package/dist/tools/git/git-diff.js +3 -20
  59. package/dist/tools/git/git-log.js +5 -19
  60. package/dist/tools/git/git-show.d.ts +5 -0
  61. package/dist/tools/git/git-show.js +22 -0
  62. package/dist/tools/git/git-status.js +4 -21
  63. package/dist/tools/git/run-git.d.ts +5 -0
  64. package/dist/tools/git/run-git.js +18 -0
  65. package/dist/tools/lsp/diagnostics.js +2 -3
  66. package/dist/tools/plan/todo-write.d.ts +1 -1
  67. package/dist/tools/registry.js +4 -0
  68. package/dist/tools/search/dependencies.d.ts +4 -4
  69. package/dist/tools/search/dependencies.js +1 -1
  70. package/dist/tools/search/glob.js +9 -4
  71. package/dist/tools/search/grep.js +11 -4
  72. package/dist/tools/search/ignore.d.ts +8 -0
  73. package/dist/tools/search/ignore.js +49 -0
  74. package/dist/tools/search/recent-files.js +1 -1
  75. package/dist/tools/search/search-files.js +11 -4
  76. package/dist/tools/web/web-fetch.js +2 -1
  77. package/dist/tools/web/web-search.js +3 -1
  78. package/dist/tui/app.js +117 -10
  79. package/dist/tui/app.test.js +65 -4
  80. package/dist/tui/measure.js +1 -0
  81. package/dist/tui/mouse.d.ts +7 -5
  82. package/dist/tui/mouse.js +9 -6
  83. package/dist/tui/scroll-flow.test.js +0 -1
  84. package/dist/tui/transcript-commands.d.ts +14 -0
  85. package/dist/tui/transcript-commands.js +31 -0
  86. package/dist/util/log.d.ts +19 -10
  87. package/dist/util/log.js +87 -52
  88. package/dist/verification/classify.d.ts +1 -1
  89. package/dist/verification/classify.js +2 -2
  90. package/dist/verification/engine.js +0 -6
  91. package/dist/verification/scoped.js +6 -4
  92. package/package.json +6 -1
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Proxy support (1.3): `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` (either
3
+ * case) are honored for provider, web, and update fetches. No dependencies:
4
+ * plain-HTTP goes through the proxy as a forward absolute-URI request and
5
+ * HTTPS is tunneled with CONNECT + TLS, all on node:http/https/net/tls.
6
+ *
7
+ * Loopback destinations are never proxied. `NO_PROXY` supports exact hosts,
8
+ * leading-dot and bare suffixes, `*`, and IPv4 CIDR ranges. Only `http://`
9
+ * proxy URLs are supported (the standard CONNECT-proxy shape); anything
10
+ * else is treated as unconfigured.
11
+ */
12
+ import * as net from 'node:net';
13
+ export interface ProxyEndpoint {
14
+ host: string;
15
+ port: number;
16
+ /** `user:password` from the proxy URL userinfo, when present. */
17
+ auth?: string;
18
+ }
19
+ export interface ProxiedInit extends RequestInit {
20
+ /** Total-operation timeout (CONNECT, TLS handshake, and response). */
21
+ timeoutMs?: number;
22
+ }
23
+ /** Resolve the proxy for a URL, or null for a direct connection. */
24
+ export declare function proxyForUrl(rawUrl: string, env?: NodeJS.ProcessEnv): ProxyEndpoint | null;
25
+ /**
26
+ * Open a CONNECT tunnel to targetHost:targetPort through the proxy.
27
+ * Resolves with the tunnelled socket (paused); rejects on timeout, abort,
28
+ * proxy errors, or non-200 responses.
29
+ */
30
+ export declare function tunnelSocket(targetHost: string, targetPort: number, proxy: ProxyEndpoint, timeoutMs: number, signal?: AbortSignal | null): Promise<net.Socket>;
31
+ /**
32
+ * Drop-in fetch replacement: routes through HTTP(S)_PROXY when one applies,
33
+ * otherwise delegates to global fetch (byte-identical behavior, including
34
+ * redirect following). Follows up to 5 redirects in proxied mode unless
35
+ * `redirect: 'manual'` is set. Only `string | URL` inputs are supported.
36
+ */
37
+ export declare function proxiedFetch(input: RequestInfo | URL, init?: ProxiedInit): Promise<Response>;
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Proxy support (1.3): `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` (either
3
+ * case) are honored for provider, web, and update fetches. No dependencies:
4
+ * plain-HTTP goes through the proxy as a forward absolute-URI request and
5
+ * HTTPS is tunneled with CONNECT + TLS, all on node:http/https/net/tls.
6
+ *
7
+ * Loopback destinations are never proxied. `NO_PROXY` supports exact hosts,
8
+ * leading-dot and bare suffixes, `*`, and IPv4 CIDR ranges. Only `http://`
9
+ * proxy URLs are supported (the standard CONNECT-proxy shape); anything
10
+ * else is treated as unconfigured.
11
+ */
12
+ import * as http from 'node:http';
13
+ import * as https from 'node:https';
14
+ import * as net from 'node:net';
15
+ import * as tls from 'node:tls';
16
+ const DEFAULT_TIMEOUT_MS = 30_000;
17
+ const DEFAULT_PROXY_PORT = 8080;
18
+ const MAX_REDIRECTS = 5;
19
+ function isLoopbackHost(host) {
20
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '');
21
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1')
22
+ return true;
23
+ if (h.startsWith('127.')) {
24
+ const parts = h.split('.');
25
+ return parts.length === 4 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255);
26
+ }
27
+ return false;
28
+ }
29
+ function ipv4ToInt(ip) {
30
+ const parts = ip.split('.');
31
+ if (parts.length !== 4 || !parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255))
32
+ return null;
33
+ return parts.reduce((acc, p) => acc * 256 + Number(p), 0) >>> 0;
34
+ }
35
+ function noProxyMatch(host, noProxy) {
36
+ const h = host.toLowerCase();
37
+ for (const rawEntry of noProxy.split(',')) {
38
+ const entry = rawEntry.trim().toLowerCase();
39
+ if (!entry)
40
+ continue;
41
+ if (entry === '*')
42
+ return true;
43
+ const name = entry.split(':')[0];
44
+ if (h === name || h.endsWith(`.${name.replace(/^\./, '')}`))
45
+ return true;
46
+ const slash = name.indexOf('/');
47
+ if (slash !== -1) {
48
+ const base = ipv4ToInt(name.slice(0, slash));
49
+ const bits = Number(name.slice(slash + 1));
50
+ const addr = ipv4ToInt(h);
51
+ if (base !== null && addr !== null && Number.isInteger(bits) && bits >= 0 && bits <= 32) {
52
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
53
+ if ((addr & mask) === (base & mask))
54
+ return true;
55
+ }
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+ /** Resolve the proxy for a URL, or null for a direct connection. */
61
+ export function proxyForUrl(rawUrl, env = process.env) {
62
+ let u;
63
+ try {
64
+ u = new URL(rawUrl);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
70
+ return null;
71
+ const host = u.hostname.toLowerCase();
72
+ if (isLoopbackHost(host))
73
+ return null;
74
+ const noProxy = env.NO_PROXY ?? env.no_proxy ?? '';
75
+ if (noProxy && noProxyMatch(host, noProxy))
76
+ return null;
77
+ const raw = u.protocol === 'https:'
78
+ ? (env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy)
79
+ : (env.HTTP_PROXY ?? env.http_proxy);
80
+ if (!raw)
81
+ return null;
82
+ let p;
83
+ try {
84
+ p = new URL(raw);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ if (p.protocol !== 'http:' || !p.hostname)
90
+ return null;
91
+ const port = p.port ? Number(p.port) : DEFAULT_PROXY_PORT;
92
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
93
+ return null;
94
+ const auth = p.username ? `${decodeURIComponent(p.username)}:${decodeURIComponent(p.password)}` : undefined;
95
+ return { host: p.hostname, port, ...(auth ? { auth } : {}) };
96
+ }
97
+ function proxyAuthHeader(proxy) {
98
+ return proxy.auth ? { 'Proxy-Authorization': `Basic ${Buffer.from(proxy.auth, 'utf-8').toString('base64')}` } : {};
99
+ }
100
+ function abortError(signal) {
101
+ const reason = signal?.reason;
102
+ if (reason instanceof Error)
103
+ return reason;
104
+ return new Error('aborted');
105
+ }
106
+ function headersToRecord(headers) {
107
+ const out = {};
108
+ if (!headers)
109
+ return out;
110
+ if (headers instanceof Headers) {
111
+ headers.forEach((v, k) => { out[k.toLowerCase()] = v; });
112
+ return out;
113
+ }
114
+ if (Array.isArray(headers)) {
115
+ for (const [k, v] of headers)
116
+ out[String(k).toLowerCase()] = String(v);
117
+ return out;
118
+ }
119
+ for (const [k, v] of Object.entries(headers))
120
+ out[k.toLowerCase()] = String(v);
121
+ return out;
122
+ }
123
+ /**
124
+ * Open a CONNECT tunnel to targetHost:targetPort through the proxy.
125
+ * Resolves with the tunnelled socket (paused); rejects on timeout, abort,
126
+ * proxy errors, or non-200 responses.
127
+ */
128
+ export function tunnelSocket(targetHost, targetPort, proxy, timeoutMs, signal) {
129
+ return new Promise((resolve, reject) => {
130
+ let done = false;
131
+ const fail = (err) => {
132
+ if (done)
133
+ return;
134
+ done = true;
135
+ clearTimeout(timer);
136
+ signal?.removeEventListener('abort', onAbort);
137
+ try {
138
+ sock.destroy();
139
+ }
140
+ catch { /* ignore */ }
141
+ reject(err);
142
+ };
143
+ const sock = net.connect(proxy.port, proxy.host);
144
+ const timer = setTimeout(() => fail(new Error(`proxy CONNECT to ${targetHost}:${targetPort} timed out after ${timeoutMs}ms`)), timeoutMs);
145
+ const onAbort = () => fail(abortError(signal));
146
+ if (signal?.aborted) {
147
+ clearTimeout(timer);
148
+ try {
149
+ sock.destroy();
150
+ }
151
+ catch { /* ignore */ }
152
+ reject(abortError(signal));
153
+ return;
154
+ }
155
+ signal?.addEventListener('abort', onAbort, { once: true });
156
+ let head = Buffer.alloc(0);
157
+ sock.on('connect', () => {
158
+ sock.write([`CONNECT ${targetHost}:${targetPort} HTTP/1.1`, `Host: ${targetHost}:${targetPort}`]
159
+ .concat(Object.entries(proxyAuthHeader(proxy)).map(([k, v]) => `${k}: ${v}`))
160
+ .concat(['', '']).join('\r\n'));
161
+ });
162
+ sock.on('data', (chunk) => {
163
+ head = Buffer.concat([head, chunk]);
164
+ const end = head.indexOf('\r\n\r\n');
165
+ if (end === -1) {
166
+ if (head.length > 8192)
167
+ fail(new Error('proxy CONNECT response too long'));
168
+ return;
169
+ }
170
+ const statusLine = head.subarray(0, end).toString('latin1').split('\r\n')[0] ?? '';
171
+ const statusMatch = /HTTP\/\d+(?:\.\d+)?\s+(\d+)/.exec(statusLine);
172
+ const code = statusMatch ? Number(statusMatch[1]) : NaN;
173
+ if (done)
174
+ return;
175
+ done = true;
176
+ clearTimeout(timer);
177
+ signal?.removeEventListener('abort', onAbort);
178
+ sock.removeAllListeners('data');
179
+ if (code !== 200) {
180
+ try {
181
+ sock.destroy();
182
+ }
183
+ catch { /* ignore */ }
184
+ reject(new Error(`proxy CONNECT rejected: ${statusLine.trim() || 'no status'}`));
185
+ return;
186
+ }
187
+ try {
188
+ sock.pause();
189
+ }
190
+ catch { /* ignore */ }
191
+ resolve(sock);
192
+ });
193
+ sock.on('error', (err) => fail(err instanceof Error ? err : new Error(String(err))));
194
+ sock.on('close', () => { if (!done)
195
+ fail(new Error('proxy connection closed')); });
196
+ });
197
+ }
198
+ function collectResponse(req, timeoutMs, signal) {
199
+ return new Promise((resolve, reject) => {
200
+ const timer = setTimeout(() => {
201
+ req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
202
+ }, timeoutMs);
203
+ const onAbort = () => {
204
+ clearTimeout(timer);
205
+ req.destroy(abortError(signal));
206
+ };
207
+ if (signal?.aborted) {
208
+ clearTimeout(timer);
209
+ reject(abortError(signal));
210
+ return;
211
+ }
212
+ signal?.addEventListener('abort', onAbort, { once: true });
213
+ req.on('response', (res) => {
214
+ const chunks = [];
215
+ res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
216
+ res.on('end', () => {
217
+ clearTimeout(timer);
218
+ signal?.removeEventListener('abort', onAbort);
219
+ const headers = new Headers();
220
+ const raw = res.headersDistinct ?? res.headers;
221
+ for (const [k, v] of Object.entries(raw)) {
222
+ if (v === undefined)
223
+ continue;
224
+ headers.append(k, Array.isArray(v) ? v.join(', ') : String(v));
225
+ }
226
+ resolve(new Response(Buffer.concat(chunks), { status: res.statusCode ?? 500, statusText: res.statusMessage ?? '', headers }));
227
+ });
228
+ res.on('error', (err) => {
229
+ clearTimeout(timer);
230
+ signal?.removeEventListener('abort', onAbort);
231
+ reject(err instanceof Error ? err : new Error(String(err)));
232
+ });
233
+ });
234
+ req.on('error', (err) => {
235
+ clearTimeout(timer);
236
+ signal?.removeEventListener('abort', onAbort);
237
+ reject(err instanceof Error ? err : new Error(String(err)));
238
+ });
239
+ });
240
+ }
241
+ function bodyToBuffer(body) {
242
+ if (body === undefined || body === null)
243
+ return undefined;
244
+ if (typeof body === 'string')
245
+ return Buffer.from(body, 'utf-8');
246
+ if (Buffer.isBuffer(body))
247
+ return body;
248
+ if (body instanceof Uint8Array)
249
+ return Buffer.from(body);
250
+ if (body instanceof ArrayBuffer)
251
+ return Buffer.from(body);
252
+ throw new TypeError('proxiedFetch: unsupported body type');
253
+ }
254
+ async function requestViaProxy(url, method, headers, body, proxy, timeoutMs, signal) {
255
+ if (signal?.aborted)
256
+ throw abortError(signal);
257
+ if (url.protocol === 'http:') {
258
+ const target = `${url.hostname}${url.port ? `:${url.port}` : ''}`;
259
+ const req = http.request({
260
+ host: proxy.host,
261
+ port: proxy.port,
262
+ method,
263
+ path: url.href, // forward-proxy form: absolute URI
264
+ headers: { host: target, ...proxyAuthHeader(proxy), ...headers },
265
+ signal: signal ?? undefined,
266
+ });
267
+ const pending = collectResponse(req, timeoutMs, signal);
268
+ req.end(body);
269
+ return pending;
270
+ }
271
+ // https: — CONNECT tunnel, then TLS, then ordinary HTTPS on the socket.
272
+ const port = url.port ? Number(url.port) : 443;
273
+ const sock = await tunnelSocket(url.hostname, port, proxy, timeoutMs, signal);
274
+ if (signal?.aborted) {
275
+ try {
276
+ sock.destroy();
277
+ }
278
+ catch { /* ignore */ }
279
+ throw abortError(signal);
280
+ }
281
+ const tlsSock = tls.connect({ socket: sock, servername: url.hostname });
282
+ await new Promise((resolve, reject) => {
283
+ const cleanup = () => {
284
+ tlsSock.off('secureConnect', onSecure);
285
+ tlsSock.off('error', onErr);
286
+ signal?.removeEventListener('abort', onAbort);
287
+ };
288
+ const onAbort = () => {
289
+ cleanup();
290
+ try {
291
+ tlsSock.destroy(abortError(signal));
292
+ }
293
+ catch { /* ignore */ }
294
+ reject(abortError(signal));
295
+ };
296
+ const onSecure = () => { cleanup(); resolve(); };
297
+ const onErr = (err) => { cleanup(); reject(err); };
298
+ if (signal?.aborted)
299
+ return onAbort();
300
+ signal?.addEventListener('abort', onAbort, { once: true });
301
+ tlsSock.once('secureConnect', onSecure);
302
+ tlsSock.once('error', onErr);
303
+ });
304
+ const req = https.request({
305
+ host: url.hostname,
306
+ port,
307
+ path: `${url.pathname}${url.search}`,
308
+ method,
309
+ headers: { host: url.host, ...headers },
310
+ createConnection: () => tlsSock,
311
+ signal: signal ?? undefined,
312
+ });
313
+ const pending = collectResponse(req, timeoutMs, signal);
314
+ req.end(body);
315
+ return pending;
316
+ }
317
+ /**
318
+ * Drop-in fetch replacement: routes through HTTP(S)_PROXY when one applies,
319
+ * otherwise delegates to global fetch (byte-identical behavior, including
320
+ * redirect following). Follows up to 5 redirects in proxied mode unless
321
+ * `redirect: 'manual'` is set. Only `string | URL` inputs are supported.
322
+ */
323
+ export async function proxiedFetch(input, init = {}) {
324
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, redirect, ...rest } = init;
325
+ if (typeof input !== 'string' && !(input instanceof URL)) {
326
+ throw new TypeError('proxiedFetch: only string | URL inputs are supported');
327
+ }
328
+ let url = new URL(String(input));
329
+ let method = (rest.method ?? 'GET').toUpperCase();
330
+ const headers = headersToRecord(rest.headers);
331
+ let body = bodyToBuffer(rest.body);
332
+ const signal = rest.signal ?? undefined;
333
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
334
+ const proxy = proxyForUrl(url.href);
335
+ let res;
336
+ if (!proxy) {
337
+ res = await globalThis.fetch(url.href, { ...rest, method, headers, body });
338
+ }
339
+ else {
340
+ res = await requestViaProxy(url, method, headers, body, proxy, timeoutMs, signal);
341
+ }
342
+ if (redirect === 'manual' || ![301, 302, 303, 307, 308].includes(res.status))
343
+ return res;
344
+ const loc = res.headers.get('location');
345
+ if (!loc)
346
+ return res;
347
+ try {
348
+ await res.arrayBuffer();
349
+ }
350
+ catch { /* ignore — drain best-effort */ }
351
+ url = new URL(loc, url.href);
352
+ if (res.status === 303 || ((res.status === 301 || res.status === 302) && method === 'POST')) {
353
+ method = 'GET';
354
+ body = undefined;
355
+ delete headers['content-length'];
356
+ delete headers['content-type'];
357
+ }
358
+ }
359
+ throw new Error('proxiedFetch: too many redirects');
360
+ }
@@ -1,4 +1,4 @@
1
1
  export declare const taskListTool: import("../types.js").Tool<{
2
2
  parentTaskId?: string | undefined;
3
- status?: "running" | "succeeded" | "failed" | "cancelled" | "timed_out" | "blocked" | "queued" | undefined;
3
+ status?: "succeeded" | "failed" | "cancelled" | "timed_out" | "blocked" | "running" | "queued" | undefined;
4
4
  }, import("../../agent/task-manager.js").TaskSummary[]>;
@@ -154,8 +154,8 @@ async function guardWrite(cwd, relPath, resolved) {
154
154
  // before the write and refuse if the target moved. Compares canonicalized
155
155
  // forms (not raw strings) to avoid false positives from
156
156
  // lexical-vs-canonical spellings (e.g. `sub/../a.txt`, short names).
157
- async function assertSameTarget(cwd, relPath, resolved) {
158
- const { resolved: reResolved } = await resolveAndFollowSymlinks(cwd, relPath);
157
+ async function assertSameTarget(cwd, relPath, resolved, extraRoots) {
158
+ const { resolved: reResolved } = await resolveAndFollowSymlinks(cwd, relPath, extraRoots);
159
159
  const canon = async (p) => {
160
160
  try {
161
161
  return await fs.realpath(p);
@@ -216,7 +216,7 @@ export const applyPatchTool = defineTool({
216
216
  if (ctx.repairGuard?.denyTestEdits && repairGuardHit(sec.path, sec.body.join('\n'))) {
217
217
  throw Object.assign(new Error('repair-guard: test edits denied (denyTestEdits)'), { code: 'POLICY_DENIED' });
218
218
  }
219
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, sec.path);
219
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, sec.path, ctx.agentAllowedPaths);
220
220
  if (await allowedHit(ctx.cwd, resolved, ctx.agentAllowedPaths)) {
221
221
  throw Object.assign(new Error(`agent path not allowed: ${resolved} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
222
222
  }
@@ -240,7 +240,7 @@ export const applyPatchTool = defineTool({
240
240
  .map((l) => l.slice(1))
241
241
  .join('\n');
242
242
  await guardWrite(ctx.cwd, sec.path, resolved);
243
- await assertSameTarget(ctx.cwd, sec.path, resolved);
243
+ await assertSameTarget(ctx.cwd, sec.path, resolved, ctx.agentAllowedPaths);
244
244
  await fs.mkdir(path.dirname(resolved), { recursive: true });
245
245
  // Symlink-swap guard: refuse if the final dest became a symlink
246
246
  // since the up-front resolve (lstat never follows the final link).
@@ -78,7 +78,7 @@ export const editFileTool = defineTool({
78
78
  const guard = checkRepairGuard(input.path, `${input.find}\n${input.replace}`, ctx.repairGuard?.denyTestEdits);
79
79
  if (guard)
80
80
  return guard;
81
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
81
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path, ctx.agentAllowedPaths);
82
82
  const allowed = await checkAllowedPaths(ctx.cwd, resolved, ctx.agentAllowedPaths);
83
83
  if (allowed)
84
84
  return allowed;
@@ -161,7 +161,7 @@ export const editFileTool = defineTool({
161
161
  // before the write and refuse if the target moved. Compares
162
162
  // canonicalized forms (not raw strings) to avoid false positives from
163
163
  // lexical-vs-canonical spellings (e.g. `sub/../a.txt`, short names).
164
- const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
164
+ const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path, ctx.agentAllowedPaths);
165
165
  const canon = async (p) => {
166
166
  try {
167
167
  return await fs.realpath(p);
@@ -279,7 +279,6 @@ function findClosestMatch(text, needle) {
279
279
  const lines = text.split('\n');
280
280
  let bestIdx = 0;
281
281
  let bestScore = -1;
282
- let bestSnippet = '';
283
282
  // Simple similarity: longest common substring ratio
284
283
  for (let i = 0; i < lines.length; i++) {
285
284
  const line = lines[i] ?? '';
@@ -296,7 +295,6 @@ function findClosestMatch(text, needle) {
296
295
  if (score > bestScore) {
297
296
  bestScore = score;
298
297
  bestIdx = i;
299
- bestSnippet = line;
300
298
  }
301
299
  }
302
300
  const start = Math.max(0, bestIdx - 2);
@@ -22,7 +22,7 @@ export const listDirTool = defineTool({
22
22
  inputSchema: InputSchema,
23
23
  execute: async (input, ctx) => {
24
24
  return safe(async () => {
25
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
25
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path, ctx.agentAllowedPaths);
26
26
  const stat = await fs.stat(resolved);
27
27
  if (!stat.isDirectory()) {
28
28
  return {
@@ -69,7 +69,7 @@ export const multiEditTool = defineTool({
69
69
  const guard = checkRepairGuard(input.path, combined, ctx.repairGuard?.denyTestEdits);
70
70
  if (guard)
71
71
  return guard;
72
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
72
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path, ctx.agentAllowedPaths);
73
73
  const allowed = await checkAllowedPaths(ctx.cwd, resolved, ctx.agentAllowedPaths);
74
74
  if (allowed)
75
75
  return allowed;
@@ -94,7 +94,7 @@ export const multiEditTool = defineTool({
94
94
  // before the write and refuse if the target moved. Compares
95
95
  // canonicalized forms (not raw strings) to avoid false positives from
96
96
  // lexical-vs-canonical spellings (e.g. `sub/../a.txt`, short names).
97
- const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
97
+ const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path, ctx.agentAllowedPaths);
98
98
  const canon = async (p) => {
99
99
  try {
100
100
  return await fs.realpath(p);
@@ -36,7 +36,7 @@ export const readFileTool = defineTool({
36
36
  renderResult: (output) => `${output.path} (${output.totalLines} lines, ${output.bytesRead} bytes${output.truncated ? ' truncated' : ''})`,
37
37
  execute: async (input, ctx) => {
38
38
  return safe(async () => {
39
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
39
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path, ctx.agentAllowedPaths);
40
40
  const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
41
41
  const stat = await fs.stat(resolved);
42
42
  if (!stat.isFile()) {
@@ -68,7 +68,7 @@ export const writeFileTool = defineTool({
68
68
  const guard = checkRepairGuard(input.path, input.content, ctx.repairGuard?.denyTestEdits);
69
69
  if (guard)
70
70
  return guard;
71
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
71
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path, ctx.agentAllowedPaths);
72
72
  const allowed = await checkAllowedPaths(ctx.cwd, resolved, ctx.agentAllowedPaths);
73
73
  if (allowed)
74
74
  return allowed;
@@ -96,7 +96,7 @@ export const writeFileTool = defineTool({
96
96
  // the re-resolution is canonical (parent now exists), so compare
97
97
  // canonicalized forms — not raw strings — to avoid false positives
98
98
  // (e.g. Windows 8.3 short-name expansion after mkdir).
99
- const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
99
+ const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path, ctx.agentAllowedPaths);
100
100
  const canon = async (p) => {
101
101
  try {
102
102
  return await fs.realpath(p);
@@ -0,0 +1,5 @@
1
+ export declare const gitBlameTool: import("../types.js").Tool<{
2
+ path: string;
3
+ }, {
4
+ readonly blame: string;
5
+ }>;
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../types.js';
3
+ import { safe } from '../normalize.js';
4
+ import { runGit } from './run-git.js';
5
+ const InputSchema = z.object({
6
+ path: z.string().min(1).describe('File path to blame'),
7
+ });
8
+ export const gitBlameTool = defineTool({
9
+ name: 'git_blame',
10
+ description: 'Show git blame for a file, line by line. Read-only.',
11
+ inputSchema: InputSchema,
12
+ permission: 'read',
13
+ isConcurrencySafe: true,
14
+ execute: async (input, ctx) => {
15
+ return safe(async () => {
16
+ const res = await runGit(['blame', '--', input.path], ctx.cwd);
17
+ if (res.code !== 0)
18
+ throw new Error(res.err || `git blame failed`);
19
+ return { blame: res.out };
20
+ });
21
+ },
22
+ });
@@ -2,31 +2,14 @@
2
2
  * git_diff — show unstaged (`git diff`) or staged (`git diff --cached`)
3
3
  * changes plus a compact per-file stat. Read-only.
4
4
  */
5
- import { spawn } from 'node:child_process';
6
5
  import { z } from 'zod';
7
6
  import { defineTool } from '../types.js';
8
7
  import { safe, TOOL_ERROR_CODES } from '../normalize.js';
8
+ import { runGit } from './run-git.js';
9
9
  const InputSchema = z.object({
10
10
  staged: z.boolean().optional().describe('Show staged changes instead of unstaged (default false)'),
11
11
  path: z.string().optional().describe('Limit to a specific path'),
12
12
  });
13
- function run(args, cwd, timeoutMs = 15_000) {
14
- return new Promise((resolve) => {
15
- const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
16
- const out = [];
17
- const err = [];
18
- const timer = setTimeout(() => { try {
19
- child.kill('SIGKILL');
20
- }
21
- catch { /* noop */ } }, timeoutMs);
22
- child.stdout.on('data', (b) => out.push(b));
23
- child.stderr.on('data', (b) => err.push(b));
24
- child.on('close', (code) => {
25
- clearTimeout(timer);
26
- resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
27
- });
28
- });
29
- }
30
13
  export const gitDiffTool = defineTool({
31
14
  name: 'git_diff',
32
15
  description: 'Show the unified diff of working-tree changes (unstaged by default) or staged changes. Read-only. Returns the diff text, a per-file stat, and the list of changed file paths.',
@@ -37,7 +20,7 @@ export const gitDiffTool = defineTool({
37
20
  const args = [...base, '--no-color'];
38
21
  if (input.path)
39
22
  args.push('--', input.path);
40
- const diff = await run(args, ctx.cwd);
23
+ const diff = await runGit(args, ctx.cwd);
41
24
  if (diff.code !== 0) {
42
25
  return {
43
26
  ok: false,
@@ -47,7 +30,7 @@ export const gitDiffTool = defineTool({
47
30
  const statArgs = [...base, '--stat', '--no-color'];
48
31
  if (input.path)
49
32
  statArgs.push('--', input.path);
50
- const stat = await run(statArgs, ctx.cwd);
33
+ const stat = await runGit(statArgs, ctx.cwd);
51
34
  // Parse changed file paths from `diff --git a/... b/...` lines.
52
35
  const patchedFiles = [];
53
36
  const newline = /\r?\n/;
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { defineTool } from '../types.js';
3
3
  import { safe } from '../normalize.js';
4
- import { spawn } from 'node:child_process';
4
+ import { runGit } from './run-git.js';
5
5
  const InputSchema = z.object({
6
6
  limit: z.number().int().min(1).max(100).optional().describe('Number of commits'),
7
7
  path: z.string().optional().describe('Filter by path'),
@@ -17,24 +17,10 @@ export const gitLogTool = defineTool({
17
17
  const args = ['log', '--oneline', `-${input.limit ?? 20}`];
18
18
  if (input.path)
19
19
  args.push('--', input.path);
20
- const out = await runGit(args, ctx.cwd);
21
- return { log: out };
20
+ const res = await runGit(args, ctx.cwd);
21
+ if (res.code !== 0)
22
+ throw new Error(res.err || `git log failed`);
23
+ return { log: res.out };
22
24
  });
23
25
  },
24
26
  });
25
- function runGit(args, cwd) {
26
- return new Promise((resolve, reject) => {
27
- const child = spawn('git', args, { cwd, shell: false, windowsHide: true });
28
- let out = '';
29
- let err = '';
30
- child.stdout.on('data', (b) => { out += b.toString(); });
31
- child.stderr.on('data', (b) => { err += b.toString(); });
32
- child.on('error', reject);
33
- child.on('close', (code) => {
34
- if (code === 0)
35
- resolve(out);
36
- else
37
- reject(new Error(err || `git ${args[0]} failed`));
38
- });
39
- });
40
- }
@@ -0,0 +1,5 @@
1
+ export declare const gitShowTool: import("../types.js").Tool<{
2
+ target: string;
3
+ }, {
4
+ readonly content: string;
5
+ }>;