klyro 1.0.18 → 1.0.21

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 (40) hide show
  1. package/dist/agent/anthropic-adapter.js +9 -1
  2. package/dist/agent/child-worker.js +21 -7
  3. package/dist/agent/orchestrator.js +27 -4
  4. package/dist/agent/provider-adapter.js +5 -0
  5. package/dist/agent/retry.js +6 -3
  6. package/dist/agent/runtime.js +15 -6
  7. package/dist/agent/worktree-manager.d.ts +2 -0
  8. package/dist/agent/worktree-manager.js +9 -2
  9. package/dist/checkpoints/store.d.ts +13 -0
  10. package/dist/checkpoints/store.js +65 -2
  11. package/dist/cli/dotenv.js +1 -1
  12. package/dist/cli/repl.js +22 -5
  13. package/dist/eval/harness.js +8 -3
  14. package/dist/mcp/remote.js +31 -7
  15. package/dist/mcp/sse.js +0 -1
  16. package/dist/persistence/audit.d.ts +0 -6
  17. package/dist/persistence/audit.js +11 -18
  18. package/dist/persistence/store.js +19 -13
  19. package/dist/policy/engine.js +43 -7
  20. package/dist/providers/model-info.d.ts +4 -1
  21. package/dist/providers/model-info.js +10 -2
  22. package/dist/repl.js +4 -2
  23. package/dist/tools/git/run-git.js +23 -4
  24. package/dist/tools/lsp/diagnostics.d.ts +50 -0
  25. package/dist/tools/lsp/diagnostics.js +109 -3
  26. package/dist/tools/registry.js +3 -1
  27. package/dist/tools/shell/background.js +52 -23
  28. package/dist/tools/verify/run-verify.js +11 -1
  29. package/dist/tui/app.d.ts +5 -0
  30. package/dist/tui/app.js +156 -51
  31. package/dist/tui/app.test.js +21 -0
  32. package/dist/tui/diff-parser.js +28 -5
  33. package/dist/tui/markdown.js +1 -1
  34. package/dist/tui/measure.js +4 -1
  35. package/dist/tui/scroll-model.d.ts +2 -0
  36. package/dist/tui/scroll-model.js +5 -0
  37. package/dist/tui/status.d.ts +3 -0
  38. package/dist/verification/engine.js +35 -2
  39. package/dist/verification/scoped.js +20 -5
  40. package/package.json +1 -1
@@ -76,6 +76,18 @@ export class PolicyEngine {
76
76
  call.name === 'memory_write') {
77
77
  return { action: 'deny', reason: 'plan mode: writes blocked — use /permissions to allow or switch mode' };
78
78
  }
79
+ // plan mode is read-only investigation: shells run only allowlisted
80
+ // read-only commands, and nothing may escape to subagents or MCP
81
+ // servers (both can write through their own tool surfaces).
82
+ if (call.name === 'shell_exec' || call.name === 'run_verify') {
83
+ const cmd = asString(call.input.command);
84
+ if (!cmd || !startsWithAny(cmd, ctx.config.shellAllow)) {
85
+ return { action: 'deny', reason: 'plan mode: only allowlisted read-only commands — use /permissions to allow or switch mode' };
86
+ }
87
+ }
88
+ if (call.name === 'spawn_agent' || call.name.startsWith('mcp__')) {
89
+ return { action: 'deny', reason: 'plan mode: subagents and MCP tools blocked — use /permissions to allow or switch mode' };
90
+ }
79
91
  }
80
92
  else if (ctx.config.mode === 'accept-edits') {
81
93
  // accept-edits: auto-allow edit tools
@@ -196,7 +208,8 @@ function isEnvPath(p) {
196
208
  // not secrets — reads AND writes are allowed.
197
209
  if (/[.](example|sample|template|dist)$/.test(lower))
198
210
  return false;
199
- return /(^|\/)\.env(\.|$)/.test(lower) || lower.endsWith('.env');
211
+ // Separator class covers POSIX and Windows paths (C:\proj\.env.local).
212
+ return /(^|[/\\])\.env(\.|$)/.test(lower) || lower.endsWith('.env');
200
213
  }
201
214
  function extractPatchPaths(patch) {
202
215
  const out = [];
@@ -238,9 +251,25 @@ export function matchesGlobRule(call, rule) {
238
251
  }
239
252
  function startsWithAny(haystack, needles) {
240
253
  const h = haystack.trim().toLowerCase();
254
+ // Quote-stripped variant: `rm -rf "/"` is `rm -rf /` with camouflage.
255
+ // Stripping quotes can only add deny matches at position 0, never remove.
256
+ const variants = [h, h.replace(/['"]/g, '')];
241
257
  return needles.some((n) => {
242
258
  const nl = n.toLowerCase().replace(/\s+$/, ''); // strip trailing ws from needle
243
- return h === nl || h.startsWith(nl + ' ') || h.startsWith(nl + '\t');
259
+ return variants.some((hv) => {
260
+ if (hv === nl)
261
+ return true;
262
+ if (!hv.startsWith(nl))
263
+ return false;
264
+ // Boundary after the needle: end, whitespace, quote, or a path
265
+ // separator/glob/chain char. Without the separator class, `rm -rf /*`,
266
+ // `rm -rf ~/*`, `rm -rf ./*` and `rm -rf ./` all slip past the deny
267
+ // list. Note this intentionally also matches scoped forms like
268
+ // `rm -rf ./build` — destructive-prefix commands stay denied and the
269
+ // caller allowlists (or uses a bare relative path).
270
+ const c = hv[nl.length];
271
+ return c === undefined || /[\s/'"`*?\[\\;&|]/.test(c);
272
+ });
244
273
  });
245
274
  }
246
275
  /** Branches that `git push` must never target without an explicit opt-out. */
@@ -407,13 +436,20 @@ export const writeFileCwdRule = {
407
436
  if (!p)
408
437
  return null;
409
438
  if (p.includes('..') || path.isAbsolute(p)) {
410
- // Check if absolute is inside additionalDirs
439
+ // Absolute paths are fine inside the workspace roots (cwd or
440
+ // additionalDirs) — only true escapes are denied.
411
441
  if (path.isAbsolute(p)) {
412
- const allowed = (ctx.config.additionalDirs ?? []).some((dir) => {
413
- const rel = path.relative(path.resolve(dir), path.resolve(p));
414
- return !rel.startsWith('..') && !path.isAbsolute(rel);
442
+ const roots = [ctx.cwd, ...(ctx.config.additionalDirs ?? [])];
443
+ const inside = roots.some((dir) => {
444
+ try {
445
+ const rel = path.relative(path.resolve(dir), path.resolve(p));
446
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
447
+ }
448
+ catch {
449
+ return false; // different drive on Windows: not inside
450
+ }
415
451
  });
416
- if (allowed)
452
+ if (inside)
417
453
  return null;
418
454
  }
419
455
  return { action: 'deny', reason: 'path traversal or absolute path not allowed' };
@@ -39,4 +39,7 @@ export declare function ratesFor(modelId: string): {
39
39
  * other families keep ignoring those counters.
40
40
  */
41
41
  export declare function isAnthropicModel(modelId: string): boolean;
42
- export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number): number;
42
+ export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number, cache?: {
43
+ read?: number;
44
+ write?: number;
45
+ }): number;
@@ -70,9 +70,17 @@ export function ratesFor(modelId) {
70
70
  export function isAnthropicModel(modelId) {
71
71
  return /anthropic|claude/i.test(modelId);
72
72
  }
73
- export function estimateCost(modelId, inputTokens, outputTokens) {
73
+ export function estimateCost(modelId, inputTokens, outputTokens, cache) {
74
74
  // Single source: delegate to ratesFor so registry, family fallbacks, and
75
75
  // local-$0 rules live in exactly one place (no split-brain with getModelInfo).
76
76
  const { input, output } = ratesFor(modelId);
77
- return (inputTokens / 1000) * input + (outputTokens / 1000) * output;
77
+ let cost = (inputTokens / 1000) * input + (outputTokens / 1000) * output;
78
+ // Cache-aware (Anthropic billing): cacheRead at 0.1x input, cacheWrite at
79
+ // 1.25x input. Other families ignore cache counters. Mirrors the
80
+ // runtime's estimateCost — keep the two in sync.
81
+ if (isAnthropicModel(modelId)) {
82
+ cost += ((cache?.read ?? 0) / 1000) * input * 0.1;
83
+ cost += ((cache?.write ?? 0) / 1000) * input * 1.25;
84
+ }
85
+ return cost;
78
86
  }
package/dist/repl.js CHANGED
@@ -94,8 +94,10 @@ export async function repl(system) {
94
94
  // EOF on stdin (Ctrl-D) or close — exit cleanly.
95
95
  break;
96
96
  }
97
- if (!line)
98
- break; // empty line on EOF
97
+ // EOF on stdin (Ctrl-D) rejects the question and exits via the
98
+ // catch above. An empty line is just a stray Enter — keep going.
99
+ if (line === '')
100
+ continue;
99
101
  const step = splitContinuedLine(continued, line);
100
102
  continued = step.pending;
101
103
  if (step.complete === undefined)
@@ -4,13 +4,32 @@ export function runGit(args, cwd, timeoutMs = 15_000) {
4
4
  const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
5
5
  const out = [];
6
6
  const err = [];
7
- const timer = setTimeout(() => { try {
8
- child.kill('SIGKILL');
9
- }
10
- catch { /* noop */ } }, timeoutMs);
7
+ let done = false;
8
+ const timer = setTimeout(() => {
9
+ if (done)
10
+ return;
11
+ done = true;
12
+ try {
13
+ child.kill('SIGKILL');
14
+ }
15
+ catch { /* noop */ }
16
+ resolve({ code: 1, out: Buffer.concat(out).toString('utf-8'), err: 'git timed out' });
17
+ }, timeoutMs);
11
18
  child.stdout.on('data', (b) => out.push(b));
12
19
  child.stderr.on('data', (b) => err.push(b));
20
+ // Missing git binary / bad cwd emits 'error' with no 'close' — without
21
+ // this every caller hangs to its own timeout instead of failing fast.
22
+ child.on('error', (e) => {
23
+ if (done)
24
+ return;
25
+ done = true;
26
+ clearTimeout(timer);
27
+ resolve({ code: 1, out: Buffer.concat(out).toString('utf-8'), err: String(e?.message ?? e) });
28
+ });
13
29
  child.on('close', (code) => {
30
+ if (done)
31
+ return;
32
+ done = true;
14
33
  clearTimeout(timer);
15
34
  resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
16
35
  });
@@ -49,3 +49,53 @@ export declare const lspGotoDefinitionTool: import("../types.js").Tool<{
49
49
  readonly candidates: number;
50
50
  readonly note?: undefined;
51
51
  }>;
52
+ export declare const lspReferencesTool: import("../types.js").Tool<{
53
+ path: string;
54
+ line: number;
55
+ character?: number | undefined;
56
+ }, {
57
+ readonly enabled: false;
58
+ readonly references: readonly [];
59
+ readonly note: "LSP off via KLYRO_LSP=0";
60
+ } | {
61
+ readonly enabled: true;
62
+ readonly references: readonly [];
63
+ readonly note: `unreadable file: ${string}`;
64
+ } | {
65
+ readonly enabled: true;
66
+ readonly references: readonly [];
67
+ readonly note: "no identifier under cursor";
68
+ } | {
69
+ readonly enabled: true;
70
+ readonly references: {
71
+ file: string;
72
+ line: number;
73
+ kind: string;
74
+ name: string;
75
+ }[];
76
+ readonly note?: undefined;
77
+ }>;
78
+ export declare const lspOutlineTool: import("../types.js").Tool<{
79
+ path: string;
80
+ }, {
81
+ readonly enabled: false;
82
+ readonly outline: readonly [];
83
+ readonly note: "LSP off via KLYRO_LSP=0";
84
+ } | {
85
+ readonly enabled: true;
86
+ readonly outline: readonly [];
87
+ readonly note: `unreadable file: ${string}`;
88
+ } | {
89
+ readonly enabled: true;
90
+ readonly outline: {
91
+ name: string;
92
+ kind: string;
93
+ line: number;
94
+ children: Array<{
95
+ name: string;
96
+ kind: string;
97
+ line: number;
98
+ }>;
99
+ }[];
100
+ readonly note?: undefined;
101
+ }>;
@@ -5,8 +5,10 @@
5
5
  * project has one and filters the output to the requested path (or returns
6
6
  * a capped full-project list). Other languages report "unsupported".
7
7
  * `lsp_goto_definition` resolves the identifier under (line, character) to
8
- * its definition via the regex repo-map symbol index. Set KLYRO_LSP=0 to
9
- * force both tools off.
8
+ * its definition via the regex repo-map symbol index.
9
+ * `lsp_references` finds all references to the identifier under (line, character).
10
+ * `lsp_outline` returns a hierarchical symbol outline for a file.
11
+ * Set KLYRO_LSP=0 to force all tools off.
10
12
  */
11
13
  import { spawnSync } from 'node:child_process';
12
14
  import * as fs from 'node:fs/promises';
@@ -15,7 +17,7 @@ import { z } from 'zod';
15
17
  import { defineTool } from '../types.js';
16
18
  import { safe } from '../normalize.js';
17
19
  import { resolveWithinCwd } from '../../policy/path-guard.js';
18
- import { buildRepoMap } from '../../context/repo-map.js';
20
+ import { buildRepoMap, extractSymbols } from '../../context/repo-map.js';
19
21
  function isOff() {
20
22
  return process.env.KLYRO_LSP === '0';
21
23
  }
@@ -104,3 +106,107 @@ export const lspGotoDefinitionTool = defineTool({
104
106
  return { enabled: true, location: best, candidates: hits.length };
105
107
  }),
106
108
  });
109
+ export const lspReferencesTool = defineTool({
110
+ name: 'lsp_references',
111
+ description: 'Find all references to the identifier under (line, character) via the repo symbol index',
112
+ inputSchema: z.object({ path: z.string().min(1), line: z.number().int().min(1), character: z.number().int().min(0).optional() }),
113
+ permission: 'read',
114
+ isConcurrencySafe: true,
115
+ execute: async (input, ctx) => safe(async () => {
116
+ if (isOff())
117
+ return { enabled: false, references: [], note: 'LSP off via KLYRO_LSP=0' };
118
+ const { resolved } = resolveWithinCwd(ctx.cwd, input.path, ctx.agentAllowedPaths);
119
+ let content;
120
+ try {
121
+ content = await fs.readFile(resolved, 'utf-8');
122
+ }
123
+ catch {
124
+ return { enabled: true, references: [], note: `unreadable file: ${input.path}` };
125
+ }
126
+ const lines = content.split('\n');
127
+ const lineText = lines[input.line - 1] ?? '';
128
+ const col = Math.min(input.character ?? 0, lineText.length);
129
+ const wordAt = (() => {
130
+ const isWord = (c) => /[A-Za-z0-9_$]/.test(c);
131
+ let s = col;
132
+ let e = col;
133
+ while (s > 0 && isWord(lineText[s - 1]))
134
+ s--;
135
+ while (e < lineText.length && isWord(lineText[e]))
136
+ e++;
137
+ const w = lineText.slice(s, e);
138
+ return w || null;
139
+ })();
140
+ if (!wordAt)
141
+ return { enabled: true, references: [], note: 'no identifier under cursor' };
142
+ const files = await buildRepoMap({ cwd: ctx.cwd, maxFiles: 300 });
143
+ const refs = [];
144
+ const seen = new Set();
145
+ for (const f of files) {
146
+ for (const s of f.symbols) {
147
+ if (s.name === wordAt) {
148
+ const key = `${f.path}:${s.line}:${s.name}`;
149
+ if (!seen.has(key)) {
150
+ seen.add(key);
151
+ refs.push({ file: f.path, line: s.line, kind: s.kind, name: s.name });
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return { enabled: true, references: refs.slice(0, 100) };
157
+ }),
158
+ });
159
+ export const lspOutlineTool = defineTool({
160
+ name: 'lsp_outline',
161
+ description: 'Hierarchical symbol outline for a file via the repo symbol index',
162
+ inputSchema: z.object({ path: z.string().min(1) }),
163
+ permission: 'read',
164
+ isConcurrencySafe: true,
165
+ execute: async (input, ctx) => safe(async () => {
166
+ if (isOff())
167
+ return { enabled: false, outline: [], note: 'LSP off via KLYRO_LSP=0' };
168
+ const { resolved } = resolveWithinCwd(ctx.cwd, input.path, ctx.agentAllowedPaths);
169
+ // Symbol-extract this file directly rather than via buildRepoMap: the repo
170
+ // map is a whole-tree walk capped by maxFiles, so a single-file query can
171
+ // never be served from it (it would return whichever file the walk reached
172
+ // first, and the caller's path would usually be absent).
173
+ let content;
174
+ try {
175
+ content = await fs.readFile(resolved, 'utf-8');
176
+ }
177
+ catch {
178
+ return { enabled: true, outline: [], note: `unreadable file: ${input.path}` };
179
+ }
180
+ const symbols = extractSymbols(content, path.extname(resolved).toLowerCase());
181
+ // Build simple hierarchy: classes/interfaces as parents, methods/fields as children
182
+ const outline = [];
183
+ for (const sym of symbols) {
184
+ if (['class', 'interface', 'type'].includes(sym.kind)) {
185
+ outline.push({ name: sym.name, kind: sym.kind, line: sym.line, children: [] });
186
+ }
187
+ }
188
+ for (const sym of symbols) {
189
+ if (['method', 'function', 'property', 'field', 'const'].includes(sym.kind)) {
190
+ // Assign to the nearest preceding class/interface
191
+ let parentIdx = -1;
192
+ for (let i = outline.length - 1; i >= 0; i--) {
193
+ const parent = outline[i];
194
+ if (parent && parent.line < sym.line) {
195
+ parentIdx = i;
196
+ break;
197
+ }
198
+ }
199
+ if (parentIdx >= 0) {
200
+ const parentEntry = outline[parentIdx];
201
+ if (parentEntry) {
202
+ parentEntry.children.push({ name: sym.name, kind: sym.kind, line: sym.line });
203
+ }
204
+ }
205
+ else {
206
+ outline.push({ name: sym.name, kind: sym.kind, line: sym.line, children: [] });
207
+ }
208
+ }
209
+ }
210
+ return { enabled: true, outline };
211
+ }),
212
+ });
@@ -24,7 +24,7 @@ import { askUserTool } from './plan/ask-user.js';
24
24
  import { repoMapTool } from './repo-map.js';
25
25
  import { importsOfTool, importersOfTool } from './search/imports.js';
26
26
  import { findSymbolTool } from './symbols/find-symbol.js';
27
- import { lspDiagnosticsTool, lspGotoDefinitionTool } from './lsp/diagnostics.js';
27
+ import { lspDiagnosticsTool, lspGotoDefinitionTool, lspReferencesTool, lspOutlineTool } from './lsp/diagnostics.js';
28
28
  import { expandResultTool } from './expand-result.js';
29
29
  import { memoryWriteTool } from './memory-write.js';
30
30
  import { webFetchTool } from './web/web-fetch.js';
@@ -117,6 +117,8 @@ export const builtinRegistry = () => {
117
117
  r.register(findSymbolTool);
118
118
  r.register(lspDiagnosticsTool);
119
119
  r.register(lspGotoDefinitionTool);
120
+ r.register(lspReferencesTool);
121
+ r.register(lspOutlineTool);
120
122
  r.register(expandResultTool);
121
123
  r.register(memoryWriteTool);
122
124
  r.register(webFetchTool);
@@ -1,21 +1,58 @@
1
1
  /**
2
2
  * 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
3
3
  */
4
- import { spawn } from 'node:child_process';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
5
  import { findBlockedReason, filteredEnv } from './shell-exec.js';
6
6
  const jobs = new Map();
7
7
  let counter = 0;
8
8
  const MAX_JOBS = 5;
9
9
  const JOB_TTL_MS = 10 * 60 * 1000;
10
+ /** Tree-kill: proc.kill() alone leaves grandchildren behind (shell:true). */
11
+ function killTree(proc) {
12
+ try {
13
+ if (process.platform === 'win32') {
14
+ if (proc.pid !== undefined) {
15
+ spawnSync('taskkill', ['/F', '/T', '/PID', String(proc.pid)], { windowsHide: true });
16
+ }
17
+ }
18
+ else if (proc.pid !== undefined) {
19
+ // Detached group kill when we own the group, else single kill.
20
+ try {
21
+ process.kill(-proc.pid, 'SIGKILL');
22
+ }
23
+ catch {
24
+ try {
25
+ proc.kill('SIGKILL');
26
+ }
27
+ catch { /* ignore */ }
28
+ }
29
+ }
30
+ else {
31
+ try {
32
+ proc.kill('SIGKILL');
33
+ }
34
+ catch { /* ignore */ }
35
+ }
36
+ }
37
+ catch { /* ignore */ }
38
+ }
39
+ function watchJob(job) {
40
+ // A failed spawn (bad cwd, missing shell) emits 'error' with no 'close'
41
+ // guarantee — without a listener Node throws and takes down the process.
42
+ job.proc.on('error', (e) => {
43
+ job.output += `\n[spawn failed: ${e?.message ?? e}]`;
44
+ });
45
+ job.proc.on('close', () => {
46
+ setTimeout(() => { if (jobs.get(job.id)?.proc.exitCode !== null)
47
+ jobs.delete(job.id); }, JOB_TTL_MS);
48
+ });
49
+ }
10
50
  function pruneJobs() {
11
51
  if (jobs.size <= MAX_JOBS)
12
52
  return;
13
53
  const sorted = [...jobs.values()].sort((a, b) => a.start - b.start);
14
54
  for (const j of sorted.slice(0, jobs.size - MAX_JOBS)) {
15
- try {
16
- j.proc.kill('SIGKILL');
17
- }
18
- catch { /* ignore */ }
55
+ killTree(j.proc);
19
56
  jobs.delete(j.id);
20
57
  }
21
58
  for (const j of [...jobs.values()]) {
@@ -33,18 +70,19 @@ export function startBackground(command, cwd) {
33
70
  throw new Error(`Command blocked: ${blocked}`);
34
71
  const id = `job-${++counter}-${Date.now().toString(36)}`;
35
72
  // Filtered env, same call shape as shell_exec — background children must
36
- // not inherit secrets from the parent process.
37
- const proc = spawn(command, { cwd, shell: true, windowsHide: true, env: filteredEnv() });
73
+ // not inherit secrets from the parent process. Detached on POSIX so
74
+ // killTree can take the whole group (mirrors shell_exec).
75
+ const proc = spawn(command, {
76
+ cwd, shell: true, windowsHide: true, env: filteredEnv(),
77
+ ...(process.platform === 'win32' ? {} : { detached: true }),
78
+ });
38
79
  const job = { id, command, cwd, proc, output: '', start: Date.now() };
39
80
  jobs.set(id, job);
40
81
  proc.stdout?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
41
82
  job.output = job.output.slice(-1_000_000); });
42
83
  proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
43
84
  job.output = job.output.slice(-1_000_000); });
44
- proc.on('close', () => {
45
- setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
46
- jobs.delete(id); }, JOB_TTL_MS);
47
- });
85
+ watchJob(job);
48
86
  return id;
49
87
  }
50
88
  /**
@@ -66,10 +104,7 @@ export function startBackgroundArgv(argv, cwd) {
66
104
  job.output = job.output.slice(-1_000_000); });
67
105
  proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
68
106
  job.output = job.output.slice(-1_000_000); });
69
- proc.on('close', () => {
70
- setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
71
- jobs.delete(id); }, JOB_TTL_MS);
72
- });
107
+ watchJob(job);
73
108
  return id;
74
109
  }
75
110
  export function getOutput(id, filter) {
@@ -84,10 +119,7 @@ export function killJob(id) {
84
119
  const job = jobs.get(id);
85
120
  if (!job)
86
121
  throw new Error(`No job ${id}`);
87
- try {
88
- job.proc.kill('SIGKILL');
89
- }
90
- catch { /* ignore */ }
122
+ killTree(job.proc);
91
123
  jobs.delete(id);
92
124
  }
93
125
  /**
@@ -98,10 +130,7 @@ export function killJob(id) {
98
130
  export function killAllJobs() {
99
131
  const killed = [];
100
132
  for (const job of [...jobs.values()]) {
101
- try {
102
- job.proc.kill('SIGKILL');
103
- }
104
- catch { /* ignore */ }
133
+ killTree(job.proc);
105
134
  jobs.delete(job.id);
106
135
  killed.push(job.id);
107
136
  }
@@ -10,6 +10,7 @@ import { spawn, spawnSync } from 'node:child_process';
10
10
  import { z } from 'zod';
11
11
  import { defineTool } from '../types.js';
12
12
  import { safe, TOOL_ERROR_CODES } from '../normalize.js';
13
+ import { resolveWithinCwd } from '../../policy/path-guard.js';
13
14
  import { filteredVerifyEnv } from '../../verification/registry.js';
14
15
  const InputSchema = z.object({
15
16
  command: z.string().min(1).describe('Verification command, e.g. "npm test" or "tsc --noEmit"'),
@@ -24,7 +25,16 @@ export const runVerifyTool = defineTool({
24
25
  inputSchema: InputSchema,
25
26
  execute: async (input, ctx) => {
26
27
  return safe(async () => {
27
- const cwd = input.cwd ?? ctx.cwd;
28
+ // Contain the model-supplied cwd to the workspace (mirrors shell_exec):
29
+ // without this the verify command could run in /etc, ~/.ssh, or any
30
+ // other directory outside the sandbox roots.
31
+ let cwd;
32
+ try {
33
+ cwd = resolveWithinCwd(ctx.cwd, input.cwd ?? '.').resolved;
34
+ }
35
+ catch {
36
+ throw Object.assign(new Error('run_verify cwd escapes workspace (POLICY_DENIED)'), { code: TOOL_ERROR_CODES.PATH_ESCAPE });
37
+ }
28
38
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29
39
  // S2: verify commands run with filtered env; servers needing keys must use explicit config.
30
40
  const env = filteredVerifyEnv();
package/dist/tui/app.d.ts CHANGED
@@ -37,4 +37,9 @@ export interface AppProps {
37
37
  version?: string;
38
38
  isFullscreen?: boolean;
39
39
  }
40
+ declare const MAX_TRANSCRIPT_ITEMS = 500;
41
+ export { MAX_TRANSCRIPT_ITEMS as TRANSCRIPT_ITEM_CAP };
42
+ export declare function capTranscript<T extends {
43
+ kind: string;
44
+ }>(prev: T[]): T[];
40
45
  export declare function App(props: AppProps): React.JSX.Element;