klyro 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.
Files changed (96) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +68 -16
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +46 -5
  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 +106 -5
  8. package/dist/agent/orchestrator.js +378 -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 +2 -2
  13. package/dist/agent/runtime.d.ts +50 -8
  14. package/dist/agent/runtime.js +196 -33
  15. package/dist/agent/worktree-manager.d.ts +74 -0
  16. package/dist/agent/worktree-manager.js +189 -0
  17. package/dist/checkpoints/store.js +30 -5
  18. package/dist/cli/auth.js +16 -1
  19. package/dist/cli/config.d.ts +9 -3
  20. package/dist/cli/config.js +64 -3
  21. package/dist/cli/eval.d.ts +6 -1
  22. package/dist/cli/eval.js +9 -0
  23. package/dist/cli/repl.js +155 -28
  24. package/dist/cli/run.d.ts +7 -11
  25. package/dist/cli/run.js +68 -17
  26. package/dist/cli/update.d.ts +5 -0
  27. package/dist/cli/update.js +62 -10
  28. package/dist/context/import-graph.d.ts +2 -0
  29. package/dist/context/import-graph.js +31 -3
  30. package/dist/context/klyro-md.js +4 -1
  31. package/dist/context/memory.d.ts +8 -0
  32. package/dist/context/memory.js +50 -2
  33. package/dist/context/project-map.d.ts +6 -0
  34. package/dist/context/project-map.js +50 -2
  35. package/dist/context/repo-map.d.ts +2 -0
  36. package/dist/context/repo-map.js +31 -1
  37. package/dist/events/catalog.d.ts +28 -0
  38. package/dist/index.js +89 -4
  39. package/dist/mcp/client.d.ts +6 -4
  40. package/dist/mcp/client.js +83 -14
  41. package/dist/mcp/config.d.ts +10 -0
  42. package/dist/mcp/config.js +18 -1
  43. package/dist/mcp/registry.d.ts +23 -1
  44. package/dist/mcp/registry.js +78 -6
  45. package/dist/mcp/schema.d.ts +11 -4
  46. package/dist/mcp/schema.js +27 -16
  47. package/dist/mcp/trust.d.ts +20 -0
  48. package/dist/mcp/trust.js +74 -0
  49. package/dist/persistence/audit.d.ts +28 -0
  50. package/dist/persistence/audit.js +101 -1
  51. package/dist/persistence/store.d.ts +26 -2
  52. package/dist/persistence/store.js +140 -13
  53. package/dist/policy/approval.d.ts +14 -0
  54. package/dist/policy/approval.js +44 -2
  55. package/dist/policy/engine.d.ts +1 -0
  56. package/dist/policy/engine.js +88 -8
  57. package/dist/policy/secret-redactor.js +4 -0
  58. package/dist/providers/model-info.d.ts +17 -0
  59. package/dist/providers/model-info.js +35 -2
  60. package/dist/repl.d.ts +6 -0
  61. package/dist/repl.js +12 -7
  62. package/dist/tools/agent/spawn-agent.js +5 -5
  63. package/dist/tools/agent/task-apply.d.ts +4 -0
  64. package/dist/tools/agent/task-apply.js +44 -0
  65. package/dist/tools/agent/task-stop.d.ts +6 -0
  66. package/dist/tools/agent/task-stop.js +39 -0
  67. package/dist/tools/agent/task-wait.d.ts +17 -0
  68. package/dist/tools/agent/task-wait.js +79 -0
  69. package/dist/tools/fs/apply-patch.js +71 -0
  70. package/dist/tools/fs/edit-file.js +65 -0
  71. package/dist/tools/fs/multi-edit.d.ts +4 -0
  72. package/dist/tools/fs/multi-edit.js +66 -0
  73. package/dist/tools/fs/write-file.js +67 -0
  74. package/dist/tools/registry.js +6 -0
  75. package/dist/tools/shell/background.js +6 -3
  76. package/dist/tools/shell/sandbox.d.ts +51 -0
  77. package/dist/tools/shell/sandbox.js +143 -0
  78. package/dist/tools/shell/shell-exec.d.ts +1 -0
  79. package/dist/tools/shell/shell-exec.js +83 -11
  80. package/dist/tools/shell/worker-entry.d.ts +12 -0
  81. package/dist/tools/shell/worker-entry.js +43 -0
  82. package/dist/tools/types.d.ts +6 -0
  83. package/dist/tools/verify/run-verify.js +3 -1
  84. package/dist/trace/writer.d.ts +13 -0
  85. package/dist/trace/writer.js +55 -4
  86. package/dist/tui/app.js +1 -1
  87. package/dist/tui/approval.js +20 -21
  88. package/dist/util.d.ts +1 -0
  89. package/dist/util.js +1 -0
  90. package/dist/verification/baseline.js +17 -3
  91. package/dist/verification/classify.js +23 -12
  92. package/dist/verification/engine.js +3 -1
  93. package/dist/verification/registry.d.ts +2 -0
  94. package/dist/verification/registry.js +33 -0
  95. package/dist/verification/scoped.js +28 -6
  96. package/package.json +1 -1
@@ -1,10 +1,21 @@
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.
3
9
  */
4
10
  import * as fs from 'node:fs/promises';
5
11
  import * as path from 'node:path';
12
+ import { redact } from '../policy/secret-redactor.js';
6
13
  export class TraceWriter {
7
14
  filePath;
15
+ /** Serializes appends; concurrent write() calls stay line-valid. */
16
+ queue = Promise.resolve();
17
+ nextSeq = 0;
18
+ seqPrimed = false;
8
19
  constructor(sessionId, dir) {
9
20
  const base = dir ?? path.join(process.cwd(), '.klyro', 'traces');
10
21
  this.filePath = path.join(base, `${sessionId}.jsonl`);
@@ -17,8 +28,36 @@ export class TraceWriter {
17
28
  }
18
29
  catch { /* ignore */ }
19
30
  }
31
+ /** Prime the seq counter from existing lines (so restarts don't reuse seqs). */
32
+ async primeSeq() {
33
+ if (this.seqPrimed)
34
+ return;
35
+ this.seqPrimed = true;
36
+ try {
37
+ const raw = await fs.readFile(this.filePath, 'utf-8');
38
+ for (const line of raw.split('\n')) {
39
+ if (!line.trim())
40
+ continue;
41
+ try {
42
+ const parsed = JSON.parse(line);
43
+ if (typeof parsed.seq === 'number' && parsed.seq >= this.nextSeq) {
44
+ this.nextSeq = parsed.seq + 1;
45
+ }
46
+ }
47
+ catch { /* corrupt line — ignored, readAll skips it too */ }
48
+ }
49
+ }
50
+ catch { /* missing file — seq starts at 0 */ }
51
+ }
20
52
  async write(ev) {
21
- const line = JSON.stringify(ev) + '\n';
53
+ const task = this.queue.then(() => this.appendOne(ev));
54
+ // Keep the queue alive across failures; the caller still sees its error.
55
+ this.queue = task.catch(() => undefined);
56
+ return task;
57
+ }
58
+ async appendOne(ev) {
59
+ await this.primeSeq();
60
+ const line = redact(JSON.stringify({ ...ev, seq: this.nextSeq++ })) + '\n';
22
61
  await fs.appendFile(this.filePath, line, 'utf-8');
23
62
  // fsync on tool results for crash safety
24
63
  if (ev.type === 'tool.result') {
@@ -35,16 +74,28 @@ export class TraceWriter {
35
74
  }
36
75
  }
37
76
  async close() {
38
- // No persistent handle to close
77
+ // Drain the queue — no persistent handle to close.
78
+ await this.queue;
39
79
  }
40
80
  get path() {
41
81
  return this.filePath;
42
82
  }
43
- // For tests: read back
83
+ // For tests: read back (skips unparseable lines, never discards the trace).
44
84
  async readAll() {
45
85
  try {
46
86
  const raw = await fs.readFile(this.filePath, 'utf-8');
47
- return raw.split('\n').filter(Boolean).map((l) => JSON.parse(l));
87
+ const out = [];
88
+ for (const line of raw.split('\n')) {
89
+ if (!line.trim())
90
+ continue;
91
+ try {
92
+ out.push(JSON.parse(line));
93
+ }
94
+ catch {
95
+ continue; // corrupt line skipped
96
+ }
97
+ }
98
+ return out;
48
99
  }
49
100
  catch {
50
101
  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;
@@ -4,6 +4,14 @@
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
+ import { detect } from './detect.js';
8
+ function signatureOf(failure, fallbackExit) {
9
+ return {
10
+ type: failure?.type ?? 'unknown',
11
+ files: new Set((failure?.files ?? []).map((f) => f.path).filter(Boolean)),
12
+ exit: failure?.exitCode ?? fallbackExit,
13
+ };
14
+ }
7
15
  export function classifyFailure(current, baseline, flakyRerunOk) {
8
16
  const combined = (current.stderr + '\n' + current.stdout).toLowerCase();
9
17
  // env: missing binary, network, permission, no such file, EACCES etc
@@ -18,20 +26,23 @@ export function classifyFailure(current, baseline, flakyRerunOk) {
18
26
  if (flakyRerunOk)
19
27
  return 'flaky';
20
28
  if (baseline && !baseline.ok) {
21
- const baseRaw = (baseline.stderr + '\n' + baseline.stdout);
22
- // if current failure files overlap baseline failure raw, treat as pre-existing
23
- if (current.failure && baseline.stdout + baseline.stderr) {
24
- const curPaths = new Set(current.failure.files.map((f) => f.path).filter(Boolean));
25
- const baseContains = [...curPaths].some((p) => baseRaw.includes(p));
26
- if (baseContains)
27
- return 'pre_existing';
28
- // also if same exit code and type, likely pre-existing
29
- if (baseRaw.includes(current.failure.files[0]?.message ?? '') && baseRaw.length > 0)
29
+ // Structured signature compare (no substring matching): pre-existing
30
+ // iff same failure type AND file-set overlap is non-empty (or same
31
+ // exit + same type when both sides are fileless).
32
+ const curSig = signatureOf(current.failure, -1);
33
+ const baseFailure = detect(baseline.stdout, baseline.stderr, baseline.exitCode);
34
+ const baseSig = signatureOf(baseFailure, baseline.exitCode);
35
+ if (curSig.type !== 'unknown' && curSig.type === baseSig.type) {
36
+ if (curSig.files.size > 0 && baseSig.files.size > 0) {
37
+ for (const f of curSig.files) {
38
+ if (baseSig.files.has(f))
39
+ return 'pre_existing';
40
+ }
41
+ }
42
+ else if (curSig.files.size === 0 && baseSig.files.size === 0 && curSig.exit === baseSig.exit) {
30
43
  return 'pre_existing';
44
+ }
31
45
  }
32
- // if baseline failed and current also fails with similar raw length, assume pre-existing
33
- if (current.failure && baseline.stderr.length > 0 && combined.includes(baseline.stderr.slice(0, 200).toLowerCase()))
34
- return 'pre_existing';
35
46
  }
36
47
  return 'introduced';
37
48
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { spawn } from 'node:child_process';
11
11
  import { detect, summarize } from './detect.js';
12
+ import { filteredVerifyEnv } from './registry.js';
12
13
  import { redact } from '../policy/secret-redactor.js';
13
14
  const MAX_VERIFY_BYTES = 256 * 1024;
14
15
  function appendCapped(current, chunk) {
@@ -61,7 +62,8 @@ export async function verify(opts) {
61
62
  }
62
63
  const timeout = opts.timeoutMs ?? 5 * 60 * 1000;
63
64
  return new Promise((resolve) => {
64
- const child = spawn(opts.command, { cwd: opts.cwd, shell: true, env: process.env });
65
+ // S2: verify commands run with filtered env; servers needing keys must use explicit config.
66
+ const child = spawn(opts.command, { cwd: opts.cwd, shell: true, env: filteredVerifyEnv() });
65
67
  const outChunks = [];
66
68
  const errChunks = [];
67
69
  let done = false;
@@ -12,6 +12,8 @@ export interface Verifier {
12
12
  }
13
13
  export declare function detectVerifiers(cwd: string): Verifier[];
14
14
  export declare function primaryVerifyCommand(cwd: string): string | null;
15
+ /** Filtered env for verify children: allowlist + secret strip + preload-vector delete. */
16
+ export declare function filteredVerifyEnv(): NodeJS.ProcessEnv;
15
17
  export interface VerifySettings {
16
18
  commands?: string[];
17
19
  onEdit?: boolean;
@@ -113,6 +113,39 @@ export function primaryVerifyCommand(cwd) {
113
113
  const v = detectVerifiers(cwd);
114
114
  return v[0]?.command ?? null;
115
115
  }
116
+ // S2 — filtered env for ALL spawned verify commands.
117
+ //
118
+ // Sibling `tools/shell/shell-exec.ts` owns an equivalent `filteredEnv` but
119
+ // does not export it, so this mirrors that filter (allowlisted prefixes +
120
+ // exact keys, secret-ish names stripped, preload vectors deleted) instead of
121
+ // importing sibling internals.
122
+ const VERIFY_ENV_PREFIXES = ['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'NODE_', 'NPM_', 'PNPM_', 'YARN_'];
123
+ // Exact keys: sibling's PWD/TMPDIR/TEMP plus the Windows OS vars a spawned
124
+ // shell needs to start (COMSPEC/SystemRoot/PATHEXT/...) — none carry secrets.
125
+ const VERIFY_ENV_EXACT = new Set([
126
+ 'PWD', 'TMPDIR', 'TEMP', 'TMP',
127
+ 'SystemRoot', 'windir', 'COMSPEC', 'PATHEXT', 'OS',
128
+ 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
129
+ ]);
130
+ const VERIFY_ENV_SECRET_RE = /SECRET|TOKEN|KEY/i;
131
+ /** Filtered env for verify children: allowlist + secret strip + preload-vector delete. */
132
+ export function filteredVerifyEnv() {
133
+ const out = {};
134
+ for (const [k, v] of Object.entries(process.env)) {
135
+ if (v === undefined)
136
+ continue;
137
+ if (VERIFY_ENV_SECRET_RE.test(k))
138
+ continue;
139
+ if (VERIFY_ENV_PREFIXES.some((p) => k.startsWith(p)) || VERIFY_ENV_EXACT.has(k)) {
140
+ out[k] = v;
141
+ }
142
+ }
143
+ delete out.NODE_OPTIONS;
144
+ delete out.LD_PRELOAD;
145
+ delete out.LD_LIBRARY_PATH;
146
+ out.PATH = process.env.PATH;
147
+ return out;
148
+ }
116
149
  export function resolveVerifySettings(raw) {
117
150
  if (!raw || typeof raw !== 'object')
118
151
  return {};
@@ -5,6 +5,7 @@
5
5
  import * as fs from 'node:fs';
6
6
  import * as path from 'node:path';
7
7
  import { spawn } from 'node:child_process';
8
+ import { filteredVerifyEnv } from './registry.js';
8
9
  export function findRelatedTests(cwd, editedFiles) {
9
10
  if (editedFiles.length === 0)
10
11
  return [];
@@ -48,17 +49,34 @@ export function findRelatedTests(cwd, editedFiles) {
48
49
  }
49
50
  return [...related];
50
51
  }
52
+ const UNSAFE_SCOPED_RE = /["$`;&|()]/;
53
+ function isSafeScopedPath(cwd, f) {
54
+ if (UNSAFE_SCOPED_RE.test(f))
55
+ return false;
56
+ if (f.includes('\n') || f.includes('\r') || f.includes('\0'))
57
+ return false;
58
+ const rel = path.relative(path.resolve(cwd), path.resolve(cwd, f));
59
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
60
+ return false;
61
+ return true;
62
+ }
51
63
  export function buildScopedCommand(cwd, baseCommand, relatedTests) {
52
64
  if (relatedTests.length === 0)
53
65
  return null;
54
66
  if (relatedTests.length > 10)
55
67
  return null; // too many → full suite
68
+ // Validate every test path: must stay within cwd and contain no shell
69
+ // metacharacters. Drop offenders; if none survive, return null (caller
70
+ // falls back to the full suite — never interpolate untrusted paths).
71
+ const safe = relatedTests.filter((f) => isSafeScopedPath(cwd, f));
72
+ if (safe.length === 0)
73
+ return null;
56
74
  // npm test heuristic: npm test -- <files> or vitest/jest file list
57
75
  if (baseCommand.includes('npm test') || baseCommand.includes('pnpm test') || baseCommand.includes('yarn test') || baseCommand.includes('bun test')) {
58
- return `${baseCommand} -- ${relatedTests.map((f) => `"${f}"`).join(' ')}`;
76
+ return `${baseCommand} -- ${safe.map((f) => `"${f}"`).join(' ')}`;
59
77
  }
60
78
  if (baseCommand.includes('pytest')) {
61
- return `pytest ${relatedTests.map((f) => `"${f}"`).join(' ')}`;
79
+ return `pytest ${safe.map((f) => `"${f}"`).join(' ')}`;
62
80
  }
63
81
  if (baseCommand.includes('go test')) {
64
82
  // go test ./... with file filter — fallback to full
@@ -73,7 +91,8 @@ export function buildScopedCommand(cwd, baseCommand, relatedTests) {
73
91
  const MAX_SCOPED_BYTES = 256 * 1024;
74
92
  export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
75
93
  return new Promise((resolve) => {
76
- const child = spawn(command, { cwd, shell: true, env: process.env });
94
+ // S2: verify commands run with filtered env; servers needing keys must use explicit config.
95
+ const child = spawn(command, { cwd, shell: true, env: filteredVerifyEnv() });
77
96
  const outChunks = [];
78
97
  const errChunks = [];
79
98
  let done = false;
@@ -125,7 +144,8 @@ export async function syntaxCheck(cwd, file) {
125
144
  // For now, use tsc --noEmit --skipLibCheck on single file quickly
126
145
  if (ext === '.ts') {
127
146
  const ok = await new Promise((resolve) => {
128
- const child = spawn('npx', ['tsc', '--noEmit', '--skipLibCheck', full], { cwd, shell: false, env: process.env });
147
+ // S2: verify commands run with filtered env; servers needing keys must use explicit config.
148
+ const child = spawn('npx', ['tsc', '--noEmit', '--skipLibCheck', full], { cwd, shell: false, env: filteredVerifyEnv() });
129
149
  let done = false;
130
150
  const t = setTimeout(() => { if (!done) {
131
151
  done = true;
@@ -145,7 +165,8 @@ export async function syntaxCheck(cwd, file) {
145
165
  return { ok: true };
146
166
  }
147
167
  const ok2 = await new Promise((resolve) => {
148
- const child = spawn(process.execPath, ['--check', full], { cwd, shell: false, env: process.env });
168
+ // S2: verify commands run with filtered env; servers needing keys must use explicit config.
169
+ const child = spawn(process.execPath, ['--check', full], { cwd, shell: false, env: filteredVerifyEnv() });
149
170
  let done = false;
150
171
  const t = setTimeout(() => { if (!done) {
151
172
  done = true;
@@ -170,7 +191,8 @@ export async function syntaxCheck(cwd, file) {
170
191
  }
171
192
  if (ext === '.py') {
172
193
  const ok = await new Promise((resolve) => {
173
- const child = spawn('python', ['-m', 'py_compile', full], { cwd, shell: false, env: process.env });
194
+ // S2: verify commands run with filtered env; servers needing keys must use explicit config.
195
+ const child = spawn('python', ['-m', 'py_compile', full], { cwd, shell: false, env: filteredVerifyEnv() });
174
196
  let done = false;
175
197
  const t = setTimeout(() => { if (!done) {
176
198
  done = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",