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.
- package/dist/agent/anthropic-adapter.d.ts +36 -0
- package/dist/agent/anthropic-adapter.js +68 -16
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +46 -5
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +106 -5
- package/dist/agent/orchestrator.js +378 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +2 -2
- package/dist/agent/runtime.d.ts +50 -8
- package/dist/agent/runtime.js +196 -33
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.js +30 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/config.d.ts +9 -3
- package/dist/cli/config.js +64 -3
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/repl.js +155 -28
- package/dist/cli/run.d.ts +7 -11
- package/dist/cli/run.js +68 -17
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +28 -0
- package/dist/index.js +89 -4
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -1
- package/dist/mcp/registry.js +78 -6
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +1 -0
- package/dist/policy/engine.js +88 -8
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +17 -0
- package/dist/providers/model-info.js +35 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +71 -0
- package/dist/tools/fs/edit-file.js +65 -0
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +66 -0
- package/dist/tools/fs/write-file.js +67 -0
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +1 -0
- package/dist/tools/shell/shell-exec.js +83 -11
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +55 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +23 -12
- package/dist/verification/engine.js +3 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +33 -0
- package/dist/verification/scoped.js +28 -6
- package/package.json +1 -1
package/dist/trace/writer.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
package/dist/tui/approval.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 (
|
|
92
|
+
if (action === 'explain') {
|
|
101
93
|
setExplain((v) => !v);
|
|
102
94
|
return;
|
|
103
95
|
}
|
|
104
|
-
if (
|
|
105
|
-
bridge.resolve(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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} -- ${
|
|
76
|
+
return `${baseCommand} -- ${safe.map((f) => `"${f}"`).join(' ')}`;
|
|
59
77
|
}
|
|
60
78
|
if (baseCommand.includes('pytest')) {
|
|
61
|
-
return `pytest ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|