klyro 1.0.0 → 1.0.2

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 (110) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +49 -5
  2. package/dist/agent/anthropic-adapter.js +86 -17
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +53 -6
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +124 -6
  8. package/dist/agent/orchestrator.js +425 -58
  9. package/dist/agent/provider-adapter.d.ts +8 -0
  10. package/dist/agent/provider-adapter.js +12 -3
  11. package/dist/agent/retry.d.ts +1 -1
  12. package/dist/agent/retry.js +54 -12
  13. package/dist/agent/runtime.d.ts +84 -8
  14. package/dist/agent/runtime.js +352 -39
  15. package/dist/agent/stream-budget.d.ts +36 -0
  16. package/dist/agent/stream-budget.js +121 -0
  17. package/dist/agent/worktree-manager.d.ts +74 -0
  18. package/dist/agent/worktree-manager.js +189 -0
  19. package/dist/checkpoints/store.d.ts +9 -0
  20. package/dist/checkpoints/store.js +56 -5
  21. package/dist/cli/auth.js +16 -1
  22. package/dist/cli/commit.d.ts +31 -0
  23. package/dist/cli/commit.js +142 -0
  24. package/dist/cli/config.d.ts +54 -3
  25. package/dist/cli/config.js +146 -3
  26. package/dist/cli/doctor.d.ts +1 -0
  27. package/dist/cli/doctor.js +71 -6
  28. package/dist/cli/eval.d.ts +6 -1
  29. package/dist/cli/eval.js +9 -0
  30. package/dist/cli/hooks.d.ts +47 -0
  31. package/dist/cli/hooks.js +181 -0
  32. package/dist/cli/repl.js +196 -29
  33. package/dist/cli/run.d.ts +13 -11
  34. package/dist/cli/run.js +144 -20
  35. package/dist/cli/update.d.ts +5 -0
  36. package/dist/cli/update.js +62 -10
  37. package/dist/context/import-graph.d.ts +2 -0
  38. package/dist/context/import-graph.js +31 -3
  39. package/dist/context/klyro-md.js +4 -1
  40. package/dist/context/memory.d.ts +8 -0
  41. package/dist/context/memory.js +50 -2
  42. package/dist/context/project-map.d.ts +6 -0
  43. package/dist/context/project-map.js +50 -2
  44. package/dist/context/repo-map.d.ts +2 -0
  45. package/dist/context/repo-map.js +31 -1
  46. package/dist/events/catalog.d.ts +37 -0
  47. package/dist/events/catalog.js +9 -0
  48. package/dist/index.js +177 -8
  49. package/dist/mcp/client.d.ts +6 -4
  50. package/dist/mcp/client.js +83 -14
  51. package/dist/mcp/config.d.ts +10 -0
  52. package/dist/mcp/config.js +18 -1
  53. package/dist/mcp/registry.d.ts +23 -19
  54. package/dist/mcp/registry.js +127 -8
  55. package/dist/mcp/schema.d.ts +11 -4
  56. package/dist/mcp/schema.js +27 -16
  57. package/dist/mcp/trust.d.ts +20 -0
  58. package/dist/mcp/trust.js +74 -0
  59. package/dist/persistence/audit.d.ts +28 -0
  60. package/dist/persistence/audit.js +101 -1
  61. package/dist/persistence/store.d.ts +26 -2
  62. package/dist/persistence/store.js +140 -13
  63. package/dist/policy/approval.d.ts +14 -0
  64. package/dist/policy/approval.js +44 -2
  65. package/dist/policy/engine.d.ts +17 -0
  66. package/dist/policy/engine.js +162 -9
  67. package/dist/policy/path-guard.d.ts +24 -0
  68. package/dist/policy/path-guard.js +46 -0
  69. package/dist/policy/secret-redactor.js +4 -0
  70. package/dist/providers/model-info.d.ts +23 -0
  71. package/dist/providers/model-info.js +43 -2
  72. package/dist/repl.d.ts +6 -0
  73. package/dist/repl.js +12 -7
  74. package/dist/tools/agent/spawn-agent.js +5 -5
  75. package/dist/tools/agent/task-apply.d.ts +4 -0
  76. package/dist/tools/agent/task-apply.js +44 -0
  77. package/dist/tools/agent/task-stop.d.ts +6 -0
  78. package/dist/tools/agent/task-stop.js +39 -0
  79. package/dist/tools/agent/task-wait.d.ts +17 -0
  80. package/dist/tools/agent/task-wait.js +79 -0
  81. package/dist/tools/fs/apply-patch.js +77 -1
  82. package/dist/tools/fs/edit-file.js +69 -1
  83. package/dist/tools/fs/multi-edit.d.ts +4 -0
  84. package/dist/tools/fs/multi-edit.js +70 -1
  85. package/dist/tools/fs/write-file.js +83 -6
  86. package/dist/tools/plan/todo-write.js +1 -1
  87. package/dist/tools/registry.js +6 -0
  88. package/dist/tools/shell/background.js +6 -3
  89. package/dist/tools/shell/sandbox.d.ts +51 -0
  90. package/dist/tools/shell/sandbox.js +143 -0
  91. package/dist/tools/shell/shell-exec.d.ts +29 -0
  92. package/dist/tools/shell/shell-exec.js +170 -12
  93. package/dist/tools/shell/worker-entry.d.ts +12 -0
  94. package/dist/tools/shell/worker-entry.js +43 -0
  95. package/dist/tools/types.d.ts +6 -0
  96. package/dist/tools/verify/run-verify.js +3 -1
  97. package/dist/trace/writer.d.ts +20 -0
  98. package/dist/trace/writer.js +62 -4
  99. package/dist/tui/app.js +1 -1
  100. package/dist/tui/approval.js +20 -21
  101. package/dist/util.d.ts +1 -0
  102. package/dist/util.js +1 -0
  103. package/dist/verification/baseline.js +17 -3
  104. package/dist/verification/classify.js +27 -15
  105. package/dist/verification/engine.d.ts +8 -0
  106. package/dist/verification/engine.js +28 -1
  107. package/dist/verification/registry.d.ts +2 -0
  108. package/dist/verification/registry.js +44 -0
  109. package/dist/verification/scoped.js +64 -11
  110. package/package.json +1 -1
@@ -8,12 +8,14 @@
8
8
  * - Non-zero exit is returned as a successful tool call (with exitCode in
9
9
  * the output). The agent reads exitCode to decide what to do next.
10
10
  */
11
- import { spawn, spawnSync } from 'node:child_process';
11
+ import { spawn, spawnSync, execFileSync } from 'node:child_process';
12
12
  import * as os from 'node:os';
13
13
  import * as path from 'node:path';
14
+ import * as fsp from 'node:fs/promises';
14
15
  import { z } from 'zod';
15
16
  import { defineTool } from '../types.js';
16
17
  import { resolveWithinCwd } from '../../policy/path-guard.js';
18
+ import { sandboxCommand, detectSandbox } from './sandbox.js';
17
19
  import { safe, TOOL_ERROR_CODES } from '../normalize.js';
18
20
  const InputSchema = z.object({
19
21
  command: z.string().min(1).describe('Shell command to execute (interpreted by /bin/sh -c on Unix, cmd.exe /c on Windows)'),
@@ -41,7 +43,7 @@ export function resetPersistentCwd() {
41
43
  }
42
44
  // Filtered env — only safe vars, secrets stripped
43
45
  const ALLOWED_ENV_PREFIXES = ['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'NODE_', 'NPM_', 'PNPM_', 'YARN_'];
44
- function filteredEnv(extra) {
46
+ export function filteredEnv(extra) {
45
47
  const out = {};
46
48
  for (const [k, v] of Object.entries(process.env)) {
47
49
  if (k.startsWith('KLYRO_') && k.includes('API_KEY'))
@@ -71,6 +73,15 @@ function filteredEnv(extra) {
71
73
  }
72
74
  // Interactive command detection (3.3)
73
75
  const INTERACTIVE_PATTERNS = [/^\s*vim\b/, /^\s*nano\b/, /^\s*htop\b/, /^\s*less\b/, /^\s*more\b/, /^\s*ssh\b/, /^\s*tmux\b/];
76
+ /**
77
+ * Protected-branch push helpers (mirror policy engine shellDenyRule).
78
+ * `git push` targeting main/master/production is denied outright; a bare
79
+ * `git push` (no ref args) is denied when the checkout sits on a protected
80
+ * branch. Escape hatch: KLYRO_ALLOW_MAIN_PUSH=1.
81
+ */
82
+ export const PROTECTED_BRANCHES = ['main', 'master', 'production'];
83
+ export const PROTECTED_PUSH_RE = /\bpush\b[^\n]*\b(main|master|production)\b/;
84
+ const GIT_PUSH_RE = /\bgit\b[^\n]*\bpush\b/;
74
85
  // Hard-coded dangerous patterns. These are non-overridable, configurable via --yolo only.
75
86
  const DANGEROUS_PATTERNS = [
76
87
  { pattern: /rm\s+-rf?\s+\//, reason: 'recursive delete at filesystem root' },
@@ -99,7 +110,97 @@ const DANGEROUS_PATTERNS = [
99
110
  { pattern: /;\s*rm\s+-rf/, reason: 'chained rm -rf' },
100
111
  { pattern: /&&\s*rm\s+-rf/, reason: 'chained rm -rf' },
101
112
  { pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
113
+ // Exfiltration denylist (mirrors policy engine shellDenyRule).
114
+ { pattern: /\bcurl\b.*(?:\s-d\b|\s--data(?:-binary|-urlencode)?\b|\s--upload-file\b)/i, reason: 'exfiltration: curl with --data/-d/--upload-file denied' },
115
+ { pattern: /\bwget\b.*--post-(data|file)\b/i, reason: 'exfiltration: wget --post-data/--post-file denied' },
116
+ { pattern: /\b(powershell|pwsh)\b.*-e(nc|ncodedcommand)\b/i, reason: 'exfiltration: powershell -enc denied' },
117
+ { pattern: /\bInvoke-Expression\b/i, reason: 'exfiltration: Invoke-Expression denied' },
118
+ { pattern: /\biex\s*\(/i, reason: 'exfiltration: iex( denied' },
119
+ { pattern: /\bcertutil\b.*-urlcache\b/i, reason: 'exfiltration: certutil -urlcache denied' },
120
+ { pattern: /\bbitsadmin\b.*\/transfer\b/i, reason: 'exfiltration: bitsadmin /transfer denied' },
121
+ // .env writes via tee / PowerShell (mirrors policy engine .env guard).
122
+ { pattern: /\|\s*tee\b[^\n]*\.env/i, reason: 'write to .env via tee denied' },
123
+ { pattern: /\b(Set-Content|Out-File)\b[^\n]*\.env/i, reason: 'write to .env via Set-Content/Out-File denied' },
124
+ // Shell-redirection containment (mirrors policy engine shellDenyRule):
125
+ // deny `>` / `>>` into dotfiles under home/abs paths or bare project
126
+ // dotfiles. `(?<![0-9])` excludes the `2>` stderr-redirect prefix.
127
+ { pattern: /(?<![0-9])>+\s*["']?(~|\/)[^"'\s]*\/\.[^"'\s]+/, reason: 'redirect into dotfile under home/abs path denied' },
128
+ { pattern: /(?<![0-9])>+\s*["']?\.[^"'\s\/][^"'\s]*/, reason: 'redirect into project dotfile denied' },
129
+ // Upload-form exfiltration (mirrors policy engine shellDenyRule).
130
+ { pattern: /\bcurl\b.*(?:\s-F\b|\s--form\b)/i, reason: 'exfiltration: curl -F/--form denied' },
131
+ { pattern: /\bwget\b.*(?:\s--method=POST\b|\s--body-data\b)/i, reason: 'exfiltration: wget --method=POST/--body-data denied' },
132
+ { pattern: /\bInvoke-RestMethod\b/i, reason: 'exfiltration: Invoke-RestMethod denied' },
133
+ { pattern: /\bStart-BitsTransfer\b/i, reason: 'exfiltration: Start-BitsTransfer denied' },
134
+ // Protected-branch push deny (mirrors policy engine shellDenyRule).
135
+ // Explicit `git push ... main|master|production`. Honored only when
136
+ // KLYRO_ALLOW_MAIN_PUSH=1 is NOT set (see findBlockedReason). Bare
137
+ // `git push` on a protected checkout is handled in execute() with cwd.
138
+ { pattern: PROTECTED_PUSH_RE, reason: 'protected-branch push denied (main/master/production) — set KLYRO_ALLOW_MAIN_PUSH=1 to override' },
102
139
  ];
140
+ /**
141
+ * True when KLYRO_ALLOW_MAIN_PUSH=1 opts out of the protected-push deny. */
142
+ export function isMainPushEscape() {
143
+ return process.env.KLYRO_ALLOW_MAIN_PUSH === '1';
144
+ }
145
+ /**
146
+ * True when the command is a `git push` with NO ref/positional args
147
+ * (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
148
+ * out. `git push origin feature` is explicit, not bare.
149
+ */
150
+ export function isBareGitPush(cmd) {
151
+ if (!GIT_PUSH_RE.test(cmd))
152
+ return false;
153
+ const idx = cmd.search(/\bpush\b/);
154
+ const rest = idx >= 0 ? cmd.slice(idx + 4) : '';
155
+ const segment = (rest.split(/[;&|]/)[0] ?? '').split(/\n/)[0] ?? '';
156
+ const tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
157
+ const positionals = tokens.filter((t) => {
158
+ const unquoted = t.replace(/^["']|["']$/g, '');
159
+ return unquoted.length > 0 && !unquoted.startsWith('-');
160
+ });
161
+ return positionals.length === 0;
162
+ }
163
+ /**
164
+ * Resolve the currently checked-out branch via `git branch --show-current`.
165
+ * Returns null on any failure (not a repo, git missing) — callers treat
166
+ * null as "unknown" and allow the normal flow to continue.
167
+ */
168
+ export function currentGitBranch(cwd) {
169
+ try {
170
+ const out = execFileSync('git', ['-C', cwd, 'branch', '--show-current'], {
171
+ timeout: 3000,
172
+ stdio: ['ignore', 'pipe', 'ignore'],
173
+ encoding: 'utf-8',
174
+ });
175
+ const branch = String(out).trim();
176
+ return branch.length > 0 ? branch : null;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ /**
183
+ * Bare-push check with cwd: deny `git push` with no ref args when the
184
+ * checkout is on a protected branch. Returns the block reason or null.
185
+ */
186
+ export function findBareProtectedPushReason(command, cwd) {
187
+ if (isMainPushEscape())
188
+ return null;
189
+ if (!isBareGitPush(command))
190
+ return null;
191
+ const branch = currentGitBranch(cwd);
192
+ if (branch !== null && PROTECTED_BRANCHES.includes(branch)) {
193
+ return `bare git push on protected branch '${branch}' — set KLYRO_ALLOW_MAIN_PUSH=1 to override`;
194
+ }
195
+ return null;
196
+ }
197
+ /**
198
+ * Repair-guard mirror: heredoc/redirect writes to test paths, or test-path
199
+ * rewrites via sed/python/perl/ruby/node/git (e.g. `> x.test.ts`,
200
+ * `<<EOF > x.test.ts`, `sed -i`, `git apply`). Only enforced when
201
+ * `ctx.repairGuard?.denyTestEdits` is set.
202
+ */
203
+ const REPAIR_GUARD_SHELL_RE = />+\s*['"]?[^'"\s]*test[^'"\s]*|\b(sed|python|perl|ruby|node|git)\b[^\n]*test[^'"\s]*/i;
103
204
  /**
104
205
  * Shared dangerous-command check (also used by background shells).
105
206
  * Returns the block reason, or null when the command is not hard-blocked.
@@ -108,11 +209,23 @@ const DANGEROUS_PATTERNS = [
108
209
  */
109
210
  export function findBlockedReason(command) {
110
211
  for (const { pattern, reason } of DANGEROUS_PATTERNS) {
212
+ // Protected-push entry honors the KLYRO_ALLOW_MAIN_PUSH=1 escape hatch.
213
+ if (pattern === PROTECTED_PUSH_RE && isMainPushEscape())
214
+ continue;
111
215
  if (pattern.test(command))
112
216
  return reason;
113
217
  }
114
218
  return null;
115
219
  }
220
+ /** Resolve symlinks on both base and target, then enforce containment. */
221
+ async function realContainedCwd(ctxCwd, resolved) {
222
+ const realBase = await fsp.realpath(ctxCwd).catch(() => path.resolve(ctxCwd));
223
+ const realTarget = await fsp.realpath(resolved).catch(() => resolved);
224
+ const rel = path.relative(realBase, realTarget);
225
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
226
+ return realTarget;
227
+ throw Object.assign(new Error(`Path escapes cwd via symlink: ${resolved}`), { code: TOOL_ERROR_CODES.PATH_ESCAPE });
228
+ }
116
229
  export const shellExecTool = defineTool({
117
230
  name: 'shell_exec',
118
231
  description: 'Execute a shell command. Default timeout 120s (max 600s). Output head+tail 30k chars, full to ~/.klyro/tool-output/<id>.txt. Persistent cwd, filtered env, tree-kill on timeout.',
@@ -123,6 +236,10 @@ export const shellExecTool = defineTool({
123
236
  renderResult: (output) => `exit ${output.exitCode} (${output.durationMs}ms${output.timedOut ? ' timedOut' : ''})`,
124
237
  execute: async (input, ctx) => {
125
238
  return safe(async () => {
239
+ // Repair-guard mirror (denyTestEdits): refuse shell writes to test paths.
240
+ if (ctx.repairGuard?.denyTestEdits && REPAIR_GUARD_SHELL_RE.test(input.command)) {
241
+ throw Object.assign(new Error('repair-guard: test edits via shell denied (denyTestEdits)'), { code: 'POLICY_DENIED' });
242
+ }
126
243
  // Interactive detection
127
244
  for (const pat of INTERACTIVE_PATTERNS) {
128
245
  if (pat.test(input.command)) {
@@ -137,6 +254,9 @@ export const shellExecTool = defineTool({
137
254
  let cwd = persistentCwd ?? ctx.cwd;
138
255
  if (input.cwd) {
139
256
  cwd = resolveWithinCwd(ctx.cwd, input.cwd).resolved;
257
+ // Resolve symlinks for the effective cwd before the containment
258
+ // check, so a symlinked subdir can't escape the workspace.
259
+ cwd = await realContainedCwd(ctx.cwd, cwd);
140
260
  persistentCwd = cwd;
141
261
  }
142
262
  else if (input.command.trim().startsWith('cd ')) {
@@ -145,25 +265,63 @@ export const shellExecTool = defineTool({
145
265
  try {
146
266
  const target = m[1].replace(/^["']|["']$/g, '');
147
267
  const resolved = resolveWithinCwd(cwd, target).resolved;
148
- persistentCwd = resolved;
268
+ persistentCwd = await realContainedCwd(ctx.cwd, resolved);
149
269
  }
150
270
  catch { /* ignore */ }
151
271
  }
152
272
  }
273
+ else {
274
+ // Even without an explicit cwd, normalize symlinks on the
275
+ // effective cwd so containment holds when ctx.cwd is symlinked.
276
+ try {
277
+ cwd = await realContainedCwd(ctx.cwd, cwd);
278
+ }
279
+ catch {
280
+ throw Object.assign(new Error(`Working directory escapes workspace (POLICY_DENIED)`), { code: TOOL_ERROR_CODES.PATH_ESCAPE });
281
+ }
282
+ }
153
283
  const timeoutMs = Math.min(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
154
284
  const env = filteredEnv(input.env);
155
285
  const start = Date.now();
286
+ // Protected-branch bare push: `git push` with no ref args while the
287
+ // effective checkout sits on main/master/production. Explicit
288
+ // `push ... main` was already denied by findBlockedReason above.
289
+ const barePushReason = findBareProtectedPushReason(input.command, cwd);
290
+ if (barePushReason) {
291
+ throw Object.assign(new Error(`Command blocked: ${barePushReason}`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
292
+ }
156
293
  // Detached on POSIX so timeout kills the whole process GROUP
157
294
  // (child.kill alone orphans grandchildren holding the pipes).
158
- const child = spawn(input.command, {
159
- cwd,
160
- env: env,
161
- shell: true,
162
- stdio: ['ignore', 'pipe', 'pipe'],
163
- windowsHide: true,
164
- signal: ctx.signal,
165
- detached: process.platform !== 'win32',
166
- });
295
+ // G1-2 — sandbox is ON BY DEFAULT whenever a backend is available
296
+ // (bwrap, Linux). Explicit opt-out with KLYRO_SANDBOX=0. Sandboxed calls
297
+ // are OFFLINE by default; KLYRO_SANDBOX_NET=1 re-enables the network.
298
+ const sandboxBackend = detectSandbox();
299
+ const useSandbox = sandboxBackend.active && process.env.KLYRO_SANDBOX !== '0';
300
+ const sandboxWrap = useSandbox
301
+ ? sandboxCommand('/bin/sh', ['-c', input.command], {
302
+ cwd,
303
+ allowNetwork: process.env.KLYRO_SANDBOX_NET === '1',
304
+ })
305
+ : undefined;
306
+ const child = sandboxWrap
307
+ ? spawn(sandboxWrap.cmd, sandboxWrap.args, {
308
+ cwd,
309
+ env: env,
310
+ stdio: ['ignore', 'pipe', 'pipe'],
311
+ windowsHide: true,
312
+ signal: ctx.signal,
313
+ detached: process.platform !== 'win32',
314
+ shell: false,
315
+ })
316
+ : spawn(input.command, {
317
+ cwd,
318
+ env: env,
319
+ shell: true,
320
+ stdio: ['ignore', 'pipe', 'pipe'],
321
+ windowsHide: true,
322
+ signal: ctx.signal,
323
+ detached: process.platform !== 'win32',
324
+ });
167
325
  const stdoutChunks = [];
168
326
  const stderrChunks = [];
169
327
  const fullStdout = [];
@@ -0,0 +1,12 @@
1
+ /**
2
+ * G2 — Worker entry point for subprocess isolation.
3
+ *
4
+ * When the OS sandbox (bwrap) is unavailable (Windows, no-bwrap Linux),
5
+ * shell_exec routes its command through this worker so a crash/OOM in the
6
+ * child cannot take down the parent harness.
7
+ *
8
+ * Protocol: single JSON arg on argv[2] -> single JSON line on stdout.
9
+ * Input: { cmd: string; args: string[]; cwd: string; env?: Record<string,string> }
10
+ * Output: { status: number|null; signal: string|null; stdout: string; stderr: string }
11
+ */
12
+ export {};
@@ -0,0 +1,43 @@
1
+ /**
2
+ * G2 — Worker entry point for subprocess isolation.
3
+ *
4
+ * When the OS sandbox (bwrap) is unavailable (Windows, no-bwrap Linux),
5
+ * shell_exec routes its command through this worker so a crash/OOM in the
6
+ * child cannot take down the parent harness.
7
+ *
8
+ * Protocol: single JSON arg on argv[2] -> single JSON line on stdout.
9
+ * Input: { cmd: string; args: string[]; cwd: string; env?: Record<string,string> }
10
+ * Output: { status: number|null; signal: string|null; stdout: string; stderr: string }
11
+ */
12
+ import { spawnSync } from 'node:child_process';
13
+ import { filteredEnv } from './shell-exec.js';
14
+ function main() {
15
+ const raw = process.argv[2];
16
+ if (!raw) {
17
+ process.stderr.write('worker-entry: missing payload\n');
18
+ process.exit(2);
19
+ }
20
+ let payload;
21
+ try {
22
+ payload = JSON.parse(raw);
23
+ }
24
+ catch (e) {
25
+ process.stderr.write(`worker-entry: bad JSON: ${e}\n`);
26
+ process.exit(2);
27
+ }
28
+ const { cmd, args, cwd, env } = payload;
29
+ const child = spawnSync(cmd, args, {
30
+ cwd,
31
+ env: filteredEnv(env),
32
+ shell: false,
33
+ windowsHide: true,
34
+ });
35
+ const out = {
36
+ status: child.status,
37
+ signal: child.signal,
38
+ stdout: child.stdout ? Buffer.from(child.stdout).toString('base64') : '',
39
+ stderr: child.stderr ? Buffer.from(child.stderr).toString('base64') : '',
40
+ };
41
+ process.stdout.write(JSON.stringify(out));
42
+ }
43
+ main();
@@ -60,6 +60,12 @@ export interface ToolContext {
60
60
  agentAllowedTools?: ReadonlySet<string> | null;
61
61
  /** Model override active for the current agent. */
62
62
  agentModel?: string;
63
+ /** Repair-guard: when denyTestEdits is true, tools must refuse test-file edits. */
64
+ repairGuard?: {
65
+ denyTestEdits: boolean;
66
+ };
67
+ /** Allowed target paths for this agent. When present, file tools deny targets outside it. */
68
+ agentAllowedPaths?: readonly string[];
63
69
  }
64
70
  /**
65
71
  * A typed tool definition. TInput is the validated input shape; TOutput is
@@ -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 { filteredVerifyEnv } from '../../verification/registry.js';
13
14
  const InputSchema = z.object({
14
15
  command: z.string().min(1).describe('Verification command, e.g. "npm test" or "tsc --noEmit"'),
15
16
  cwd: z.string().optional(),
@@ -25,7 +26,8 @@ export const runVerifyTool = defineTool({
25
26
  return safe(async () => {
26
27
  const cwd = input.cwd ?? ctx.cwd;
27
28
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28
- const env = process.env;
29
+ // S2: verify commands run with filtered env; servers needing keys must use explicit config.
30
+ const env = filteredVerifyEnv();
29
31
  const start = Date.now();
30
32
  const child = spawn(input.command, {
31
33
  cwd,
@@ -1,12 +1,32 @@
1
1
  /**
2
2
  * 3.1 — TraceWriter (JSONL, fsync on tool results)
3
+ *
4
+ * Durability: appends serialize through a promise-chain queue (concurrent
5
+ * writes stay line-valid), each line carries a per-file `seq` number, and
6
+ * the serialized line is passed through redact() before it hits disk so
7
+ * secrets never land in the trace. readAll skips corrupt lines instead of
8
+ * discarding the whole trace.
9
+ *
10
+ * Trace-dir rationale (PRD divergence): the PRD places traces at
11
+ * `~/.klyro/projects/<hash>/traces/<session>.jsonl` (global store), but this
12
+ * writer defaults to `<cwd>/.klyro/traces/<session>.jsonl` (project-local)
13
+ * so traces travel with the working tree — inspectable offline, backed up
14
+ * with the repo, and resolvable without a global-project-hash lookup. The
15
+ * `dir` ctor param preserves the PRD path for callers that want it.
3
16
  */
4
17
  import type { KlyroEvent } from '../events/catalog.js';
5
18
  export declare class TraceWriter {
6
19
  private filePath;
20
+ /** Serializes appends; concurrent write() calls stay line-valid. */
21
+ private queue;
22
+ private nextSeq;
23
+ private seqPrimed;
7
24
  constructor(sessionId: string, dir?: string);
8
25
  init(): Promise<void>;
26
+ /** Prime the seq counter from existing lines (so restarts don't reuse seqs). */
27
+ private primeSeq;
9
28
  write(ev: KlyroEvent): Promise<void>;
29
+ private appendOne;
10
30
  close(): Promise<void>;
11
31
  get path(): string;
12
32
  readAll(): Promise<KlyroEvent[]>;
@@ -1,10 +1,28 @@
1
1
  /**
2
2
  * 3.1 — TraceWriter (JSONL, fsync on tool results)
3
+ *
4
+ * Durability: appends serialize through a promise-chain queue (concurrent
5
+ * writes stay line-valid), each line carries a per-file `seq` number, and
6
+ * the serialized line is passed through redact() before it hits disk so
7
+ * secrets never land in the trace. readAll skips corrupt lines instead of
8
+ * discarding the whole trace.
9
+ *
10
+ * Trace-dir rationale (PRD divergence): the PRD places traces at
11
+ * `~/.klyro/projects/<hash>/traces/<session>.jsonl` (global store), but this
12
+ * writer defaults to `<cwd>/.klyro/traces/<session>.jsonl` (project-local)
13
+ * so traces travel with the working tree — inspectable offline, backed up
14
+ * with the repo, and resolvable without a global-project-hash lookup. The
15
+ * `dir` ctor param preserves the PRD path for callers that want it.
3
16
  */
4
17
  import * as fs from 'node:fs/promises';
5
18
  import * as path from 'node:path';
19
+ import { redact } from '../policy/secret-redactor.js';
6
20
  export class TraceWriter {
7
21
  filePath;
22
+ /** Serializes appends; concurrent write() calls stay line-valid. */
23
+ queue = Promise.resolve();
24
+ nextSeq = 0;
25
+ seqPrimed = false;
8
26
  constructor(sessionId, dir) {
9
27
  const base = dir ?? path.join(process.cwd(), '.klyro', 'traces');
10
28
  this.filePath = path.join(base, `${sessionId}.jsonl`);
@@ -17,8 +35,36 @@ export class TraceWriter {
17
35
  }
18
36
  catch { /* ignore */ }
19
37
  }
38
+ /** Prime the seq counter from existing lines (so restarts don't reuse seqs). */
39
+ async primeSeq() {
40
+ if (this.seqPrimed)
41
+ return;
42
+ this.seqPrimed = true;
43
+ try {
44
+ const raw = await fs.readFile(this.filePath, 'utf-8');
45
+ for (const line of raw.split('\n')) {
46
+ if (!line.trim())
47
+ continue;
48
+ try {
49
+ const parsed = JSON.parse(line);
50
+ if (typeof parsed.seq === 'number' && parsed.seq >= this.nextSeq) {
51
+ this.nextSeq = parsed.seq + 1;
52
+ }
53
+ }
54
+ catch { /* corrupt line — ignored, readAll skips it too */ }
55
+ }
56
+ }
57
+ catch { /* missing file — seq starts at 0 */ }
58
+ }
20
59
  async write(ev) {
21
- const line = JSON.stringify(ev) + '\n';
60
+ const task = this.queue.then(() => this.appendOne(ev));
61
+ // Keep the queue alive across failures; the caller still sees its error.
62
+ this.queue = task.catch(() => undefined);
63
+ return task;
64
+ }
65
+ async appendOne(ev) {
66
+ await this.primeSeq();
67
+ const line = redact(JSON.stringify({ ...ev, seq: this.nextSeq++ })) + '\n';
22
68
  await fs.appendFile(this.filePath, line, 'utf-8');
23
69
  // fsync on tool results for crash safety
24
70
  if (ev.type === 'tool.result') {
@@ -35,16 +81,28 @@ export class TraceWriter {
35
81
  }
36
82
  }
37
83
  async close() {
38
- // No persistent handle to close
84
+ // Drain the queue — no persistent handle to close.
85
+ await this.queue;
39
86
  }
40
87
  get path() {
41
88
  return this.filePath;
42
89
  }
43
- // For tests: read back
90
+ // For tests: read back (skips unparseable lines, never discards the trace).
44
91
  async readAll() {
45
92
  try {
46
93
  const raw = await fs.readFile(this.filePath, 'utf-8');
47
- return raw.split('\n').filter(Boolean).map((l) => JSON.parse(l));
94
+ const out = [];
95
+ for (const line of raw.split('\n')) {
96
+ if (!line.trim())
97
+ continue;
98
+ try {
99
+ out.push(JSON.parse(line));
100
+ }
101
+ catch {
102
+ continue; // corrupt line skipped
103
+ }
104
+ }
105
+ return out;
48
106
  }
49
107
  catch {
50
108
  return [];
package/dist/tui/app.js CHANGED
@@ -158,7 +158,7 @@ function useChatScroll(opts) {
158
158
  }), [dispatch]);
159
159
  const resolved = resolveTopRow(state, ctx);
160
160
  const maxTop = maxTopFor(ctx);
161
- const pinned = state.anchor.mode === 'pinned' && !resolved.atBottom;
161
+ const pinned = state.anchor.mode === 'pinned' && (!resolved.atBottom || maxTop === 0);
162
162
  return {
163
163
  topRow: resolved.topRow,
164
164
  atBottom: resolved.atBottom,
@@ -21,6 +21,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
21
21
  */
22
22
  import { useEffect, useState } from 'react';
23
23
  import { Box, Text, useInput } from 'ink';
24
+ import { approvalChoiceForKey, sanitizeForPrompt } from '../policy/approval.js';
24
25
  class TuiApprovalBridge {
25
26
  pending = null;
26
27
  // Fan-out set: BOTH the App (awaitingApproval gate) and the ApprovalModal
@@ -72,41 +73,39 @@ export function ApprovalModal({ bridge }) {
72
73
  return bridge.subscribe((p) => setPending(p?.req ?? null));
73
74
  }, [bridge]);
74
75
  const [explain, setExplain] = useState(false);
76
+ const [full, setFull] = useState(false);
75
77
  useInput((inputStr, key) => {
76
78
  if (!pending)
77
79
  return;
78
- const c = inputStr.toLowerCase();
79
- if (c === 'y') {
80
- bridge.resolve('allow');
81
- return;
82
- }
83
- if (c === 'a') {
84
- bridge.resolve('always');
85
- return;
86
- }
87
- if (c === 'A') {
88
- bridge.resolve('always-persist');
89
- return;
90
- }
91
- if (c === 'd' || c === 'n') {
80
+ if (key.return) {
92
81
  bridge.resolve('deny');
93
82
  return;
94
83
  }
95
- if (c === 'e') {
96
- // Edit: for MVP, treat as deny with edit hint — model will be told to re-read and retry
97
- bridge.resolve('deny');
84
+ // Case-sensitive mapping lives in approvalChoiceForKey: 'A' persists,
85
+ // 'a' is session-only (the old inline toLowerCase-first code collapsed
86
+ // them, silently downgrading persistence).
87
+ const action = approvalChoiceForKey(inputStr);
88
+ if (action === 'expand') {
89
+ setFull((v) => !v);
98
90
  return;
99
91
  }
100
- if (c === '?') {
92
+ if (action === 'explain') {
101
93
  setExplain((v) => !v);
102
94
  return;
103
95
  }
104
- if (key.return) {
105
- bridge.resolve('deny');
96
+ if (action === 'allow' || action === 'always' || action === 'always-persist' || action === 'deny') {
97
+ bridge.resolve(action);
106
98
  return;
107
99
  }
108
100
  });
109
101
  if (!pending)
110
102
  return null;
111
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: ", pending.reason] }), pending.summary ? _jsxs(Text, { children: [" ", pending.summary] }) : null, explain ? _jsxs(Text, { color: "cyan", children: [" Explain: This tool will ", pending.toolName, " with the shown args. [y] once, [a] session, [A] always\u2192settings, [n] deny, [e] edit, [?] toggle help."] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y] once" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] session" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[A] always" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[n] deny" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "yellow", children: "[e] edit" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[?] explain" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
103
+ // Sanitized display (ANSI/control-strip + 500-char cap inside
104
+ // sanitizeForPrompt), then the 300-char TUI truncation on top. [f] toggles
105
+ // the full sanitized text.
106
+ const trunc = (s, n = 300) => {
107
+ const clean = sanitizeForPrompt(s);
108
+ return !full && clean.length > n ? clean.slice(0, n) + '…' : clean;
109
+ };
110
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: \"", trunc(pending.reason), "\""] }), pending.summary ? _jsxs(Text, { children: [" \"", trunc(pending.summary), "\""] }) : null, explain ? _jsxs(Text, { color: "cyan", children: [" Explain: This tool will ", pending.toolName, " with the shown args. [y] once, [a] session, [A] always\u2192settings, [n] deny, [e] edit, [f] full text, [?] toggle help."] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y] once" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] session" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[A] always" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[n] deny" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "yellow", children: "[e] edit" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[f] full" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[?] explain" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
112
111
  }
package/dist/util.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare const x = 1;
package/dist/util.js ADDED
@@ -0,0 +1 @@
1
+ export const x = 1;
@@ -7,10 +7,11 @@ import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
8
  import * as crypto from 'node:crypto';
9
9
  import { spawn } from 'node:child_process';
10
- import { primaryVerifyCommand } from './registry.js';
10
+ import { primaryVerifyCommand, filteredVerifyEnv } from './registry.js';
11
11
  async function gitHead(cwd) {
12
12
  return new Promise((resolve) => {
13
- const child = spawn('git', ['rev-parse', 'HEAD'], { cwd, shell: false });
13
+ // S2: verify commands run with filtered env; servers needing keys must use explicit config.
14
+ const child = spawn('git', ['rev-parse', 'HEAD'], { cwd, shell: false, env: filteredVerifyEnv() });
14
15
  let out = '';
15
16
  child.stdout.on('data', (b) => { out += b.toString(); });
16
17
  child.on('close', () => resolve(out.trim() || 'no-head'));
@@ -60,6 +61,18 @@ export async function runBaseline(cwd, command, timeoutMs = 90_000) {
60
61
  fs.mkdirSync(path.dirname(p), { recursive: true });
61
62
  const tmp = `${p}.tmp-${crypto.randomBytes(4).toString('hex')}`;
62
63
  fs.writeFileSync(tmp, JSON.stringify(baseline, null, 2));
64
+ // N5: fsync before rename — mirrors the persistence/store.ts writeIndex
65
+ // idiom (write → fsync → rename) so a crash can't lose the baseline.
66
+ try {
67
+ const fh = fs.openSync(tmp, 'r');
68
+ try {
69
+ fs.fsyncSync(fh);
70
+ }
71
+ finally {
72
+ fs.closeSync(fh);
73
+ }
74
+ }
75
+ catch { /* ignore on Windows */ }
63
76
  fs.renameSync(tmp, p);
64
77
  }
65
78
  catch { /* ignore */ }
@@ -68,7 +81,8 @@ export async function runBaseline(cwd, command, timeoutMs = 90_000) {
68
81
  const MAX_BASELINE_BYTES = 256 * 1024;
69
82
  function runCmd(cwd, command, timeoutMs) {
70
83
  return new Promise((resolve) => {
71
- const child = spawn(command, { cwd, shell: true, env: process.env });
84
+ // S2: verify commands run with filtered env; servers needing keys must use explicit config.
85
+ const child = spawn(command, { cwd, shell: true, env: filteredVerifyEnv() });
72
86
  const outChunks = [];
73
87
  const errChunks = [];
74
88
  let done = false;