klyro 1.0.1 → 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.
- package/dist/agent/anthropic-adapter.d.ts +13 -5
- package/dist/agent/anthropic-adapter.js +19 -2
- package/dist/agent/capabilities.js +7 -1
- package/dist/agent/orchestrator.d.ts +18 -1
- package/dist/agent/orchestrator.js +51 -4
- package/dist/agent/retry.js +52 -10
- package/dist/agent/runtime.d.ts +34 -0
- package/dist/agent/runtime.js +164 -14
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +26 -0
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +45 -0
- package/dist/cli/config.js +82 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +41 -1
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.js +76 -3
- package/dist/events/catalog.d.ts +9 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +89 -5
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/registry.d.ts +0 -18
- package/dist/mcp/registry.js +49 -2
- package/dist/policy/engine.d.ts +16 -0
- package/dist/policy/engine.js +74 -1
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/providers/model-info.d.ts +6 -0
- package/dist/providers/model-info.js +8 -0
- package/dist/tools/fs/apply-patch.js +6 -1
- package/dist/tools/fs/edit-file.js +4 -1
- package/dist/tools/fs/multi-edit.js +4 -1
- package/dist/tools/fs/write-file.js +16 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/shell/shell-exec.d.ts +28 -0
- package/dist/tools/shell/shell-exec.js +87 -1
- package/dist/trace/writer.d.ts +7 -0
- package/dist/trace/writer.js +7 -0
- package/dist/verification/classify.js +4 -3
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +25 -0
- package/dist/verification/registry.js +16 -5
- package/dist/verification/scoped.js +36 -5
- package/package.json +1 -1
package/dist/mcp/registry.js
CHANGED
|
@@ -15,7 +15,18 @@
|
|
|
15
15
|
* write-only metadata today), so we pick the most restrictive class,
|
|
16
16
|
* 'admin' — the same class as `spawn_agent`, since MCP tools execute
|
|
17
17
|
* arbitrary external side effects (read/write/network) outside our control.
|
|
18
|
+
*
|
|
19
|
+
* Debug capture: when `KLYRO_MCP_DEBUG=1` is set, every MCP tool success
|
|
20
|
+
* AND error ALSO writes the UNREDACTED raw JSON payload (pre-redaction,
|
|
21
|
+
* may contain secrets — handle accordingly) to
|
|
22
|
+
* `<configDir>/tool-output/mcp-<server>-<ts>.json` (mode 0600) and notes
|
|
23
|
+
* the path on stderr. `<configDir>` is `$KLYRO_CONFIG_DIR` when set,
|
|
24
|
+
* otherwise `~/.klyro`. Default off: with the flag unset (or any value
|
|
25
|
+
* other than `1`) no file is written and behaviour is unchanged.
|
|
18
26
|
*/
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as os from 'node:os';
|
|
29
|
+
import * as path from 'node:path';
|
|
19
30
|
import { McpClient, McpError } from './client.js';
|
|
20
31
|
import { loadMcpServers } from './config.js';
|
|
21
32
|
import { evaluateMcpPolicy } from './policy.js';
|
|
@@ -48,7 +59,39 @@ export function sanitizeMcpName(server, tool) {
|
|
|
48
59
|
function errMessage(err) {
|
|
49
60
|
return err instanceof Error ? err.message : String(err);
|
|
50
61
|
}
|
|
51
|
-
|
|
62
|
+
/** Debug-capture filename disambiguator when Date.now() collides. */
|
|
63
|
+
let mcpDebugCounter = 0;
|
|
64
|
+
/**
|
|
65
|
+
* `KLYRO_MCP_DEBUG=1` capture: write the UNREDACTED raw payload to
|
|
66
|
+
* `<configDir>/tool-output/mcp-<server>-<ts>.json` (mode 0600) + a stderr
|
|
67
|
+
* note. Best-effort and synchronous — never throws into the tool path.
|
|
68
|
+
*/
|
|
69
|
+
function captureMcpDebug(server, tool, payload) {
|
|
70
|
+
if (process.env.KLYRO_MCP_DEBUG !== '1')
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
const base = process.env.KLYRO_CONFIG_DIR ?? path.join(os.homedir() || process.cwd(), '.klyro');
|
|
74
|
+
const dir = path.join(base, 'tool-output');
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
const safe = server.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 32) || 'server';
|
|
77
|
+
let file = path.join(dir, `mcp-${safe}-${Date.now()}.json`);
|
|
78
|
+
if (fs.existsSync(file)) {
|
|
79
|
+
mcpDebugCounter += 1;
|
|
80
|
+
file = path.join(dir, `mcp-${safe}-${Date.now()}-${mcpDebugCounter}.json`);
|
|
81
|
+
}
|
|
82
|
+
fs.writeFileSync(file, JSON.stringify({ server, tool, payload }, null, 2), { mode: 0o600 });
|
|
83
|
+
try {
|
|
84
|
+
process.stderr.write(`klyro: mcp debug captured ${server}/${tool} -> ${file}\n`);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* best-effort only */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function executeMcpTool(server, spec, toolDef, client, input, ctx) {
|
|
52
95
|
try {
|
|
53
96
|
// (1) Deny-by-default — runs BEFORE any client I/O.
|
|
54
97
|
const decision = evaluateMcpPolicy(spec.policy, toolDef.name);
|
|
@@ -67,16 +110,20 @@ async function executeMcpTool(spec, toolDef, client, input, ctx) {
|
|
|
67
110
|
// (3) Typed server failures keep their code; everything else is TOOL_ERROR.
|
|
68
111
|
// Redact BEFORE the message can reach the model/trace.
|
|
69
112
|
if (err instanceof McpError) {
|
|
113
|
+
captureMcpDebug(server, toolDef.name, { code: err.code, message: err.message, details: err.details });
|
|
70
114
|
return { ok: false, error: { code: err.code, message: redact(err.message) } };
|
|
71
115
|
}
|
|
116
|
+
captureMcpDebug(server, toolDef.name, { message: errMessage(err) });
|
|
72
117
|
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
|
|
73
118
|
}
|
|
74
119
|
// (4) Server-reported error → TOOL_ERROR, redacted, bounded.
|
|
75
120
|
if (res.isError) {
|
|
121
|
+
captureMcpDebug(server, toolDef.name, res.raw);
|
|
76
122
|
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(res.text).slice(0, 2000) } };
|
|
77
123
|
}
|
|
78
124
|
// (5) Success — redact BEFORE the value reaches the model/trace, then
|
|
79
125
|
// truncate to a bounded size with a marker.
|
|
126
|
+
captureMcpDebug(server, toolDef.name, res.raw);
|
|
80
127
|
const redacted = redact(res.text);
|
|
81
128
|
if (redacted.length > MCP_SUCCESS_MAX_CHARS) {
|
|
82
129
|
return {
|
|
@@ -177,7 +224,7 @@ export async function registerMcpServers(cfg, opts) {
|
|
|
177
224
|
// 'admin': most restrictive class — MCP tools run arbitrary
|
|
178
225
|
// external side effects; nothing in src reads this field yet.
|
|
179
226
|
permission: 'admin',
|
|
180
|
-
execute: (input, ctx) => executeMcpTool(boundSpec, boundDef, boundClient, input, ctx),
|
|
227
|
+
execute: (input, ctx) => executeMcpTool(server, boundSpec, boundDef, boundClient, input, ctx),
|
|
181
228
|
});
|
|
182
229
|
registry.register(tool);
|
|
183
230
|
registered.push(name);
|
package/dist/policy/engine.d.ts
CHANGED
|
@@ -84,6 +84,22 @@ export declare class PolicyEngine {
|
|
|
84
84
|
/** Builtin set of rules. Order matters: first match wins. */
|
|
85
85
|
export declare function builtinRules(): PolicyRule[];
|
|
86
86
|
export declare function matchesGlobRule(call: ToolCallLike, rule: string): boolean;
|
|
87
|
+
/** Branches that `git push` must never target without an explicit opt-out. */
|
|
88
|
+
export declare const PROTECTED_BRANCHES: string[];
|
|
89
|
+
/** Matches `push` with a protected branch name later on the same line. */
|
|
90
|
+
export declare const PROTECTED_PUSH_RE: RegExp;
|
|
91
|
+
/**
|
|
92
|
+
* True when the command is a `git push` with NO ref/positional args
|
|
93
|
+
* (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
|
|
94
|
+
* out. `git push origin feature` is explicit, not bare.
|
|
95
|
+
*/
|
|
96
|
+
export declare function isBareGitPush(cmd: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the currently checked-out branch via `git branch --show-current`.
|
|
99
|
+
* Returns null on any failure (not a repo, git missing) — callers treat
|
|
100
|
+
* null as "unknown" and allow the normal policy flow to continue.
|
|
101
|
+
*/
|
|
102
|
+
export declare function currentGitBranch(cwd: string): string | null;
|
|
87
103
|
/** Hard-deny for obviously destructive shell patterns. */
|
|
88
104
|
export declare const shellDenyRule: PolicyRule;
|
|
89
105
|
/** Allowlist for shell — exact or prefix. Anything not in the list asks. */
|
package/dist/policy/engine.js
CHANGED
|
@@ -66,7 +66,11 @@ export class PolicyEngine {
|
|
|
66
66
|
}
|
|
67
67
|
else if (ctx.config.mode === 'plan') {
|
|
68
68
|
// plan mode: block all writes (edit)
|
|
69
|
-
if (call.name === 'write_file' ||
|
|
69
|
+
if (call.name === 'write_file' ||
|
|
70
|
+
call.name === 'edit_file' ||
|
|
71
|
+
call.name === 'multi_edit' ||
|
|
72
|
+
call.name === 'apply_patch' ||
|
|
73
|
+
call.name === 'memory_write') {
|
|
70
74
|
return { action: 'deny', reason: 'plan mode: writes blocked — use /permissions to allow or switch mode' };
|
|
71
75
|
}
|
|
72
76
|
}
|
|
@@ -226,6 +230,48 @@ function startsWithAny(haystack, needles) {
|
|
|
226
230
|
return h === nl || h.startsWith(nl + ' ') || h.startsWith(nl + '\t');
|
|
227
231
|
});
|
|
228
232
|
}
|
|
233
|
+
/** Branches that `git push` must never target without an explicit opt-out. */
|
|
234
|
+
export const PROTECTED_BRANCHES = ['main', 'master', 'production'];
|
|
235
|
+
/** Matches `push` with a protected branch name later on the same line. */
|
|
236
|
+
export const PROTECTED_PUSH_RE = /\bpush\b[^\n]*\b(main|master|production)\b/;
|
|
237
|
+
const GIT_PUSH_RE = /\bgit\b[^\n]*\bpush\b/;
|
|
238
|
+
/**
|
|
239
|
+
* True when the command is a `git push` with NO ref/positional args
|
|
240
|
+
* (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
|
|
241
|
+
* out. `git push origin feature` is explicit, not bare.
|
|
242
|
+
*/
|
|
243
|
+
export function isBareGitPush(cmd) {
|
|
244
|
+
if (!GIT_PUSH_RE.test(cmd))
|
|
245
|
+
return false;
|
|
246
|
+
const idx = cmd.search(/\bpush\b/);
|
|
247
|
+
const rest = idx >= 0 ? cmd.slice(idx + 4) : '';
|
|
248
|
+
const segment = (rest.split(/[;&|]/)[0] ?? '').split(/\n/)[0] ?? '';
|
|
249
|
+
const tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
250
|
+
const positionals = tokens.filter((t) => {
|
|
251
|
+
const unquoted = t.replace(/^["']|["']$/g, '');
|
|
252
|
+
return unquoted.length > 0 && !unquoted.startsWith('-');
|
|
253
|
+
});
|
|
254
|
+
return positionals.length === 0;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Resolve the currently checked-out branch via `git branch --show-current`.
|
|
258
|
+
* Returns null on any failure (not a repo, git missing) — callers treat
|
|
259
|
+
* null as "unknown" and allow the normal policy flow to continue.
|
|
260
|
+
*/
|
|
261
|
+
export function currentGitBranch(cwd) {
|
|
262
|
+
try {
|
|
263
|
+
const out = execFileSync('git', ['-C', cwd, 'branch', '--show-current'], {
|
|
264
|
+
timeout: 3000,
|
|
265
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
266
|
+
encoding: 'utf-8',
|
|
267
|
+
});
|
|
268
|
+
const branch = String(out).trim();
|
|
269
|
+
return branch.length > 0 ? branch : null;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
229
275
|
/** Hard-deny for obviously destructive shell patterns. */
|
|
230
276
|
export const shellDenyRule = {
|
|
231
277
|
name: 'shell-deny',
|
|
@@ -271,6 +317,16 @@ export const shellDenyRule = {
|
|
|
271
317
|
if (/\b(Set-Content|Out-File)\b[^\n]*\.env/i.test(cmd)) {
|
|
272
318
|
return { action: 'deny', reason: 'write to .env via Set-Content/Out-File denied' };
|
|
273
319
|
}
|
|
320
|
+
// Shell-redirection containment: deny `>` / `>>` into dotfiles.
|
|
321
|
+
// Home/abs dotfile target (e.g. `> ~/.klyro/mcp.json`, `> /home/u/.config/x`).
|
|
322
|
+
// The `(?<![0-9])` guard excludes the `2>` stderr-redirect prefix.
|
|
323
|
+
if (/(?<![0-9])>+\s*["']?(~|\/)[^"'\s]*\/\.[^"'\s]+/.test(cmd)) {
|
|
324
|
+
return { action: 'deny', reason: 'redirect into dotfile under home/abs path denied' };
|
|
325
|
+
}
|
|
326
|
+
// Bare project dotfile target (e.g. `> .mcp.json`, `>> .env.local`).
|
|
327
|
+
if (/(?<![0-9])>+\s*["']?\.[^"'\s\/][^"'\s]*/.test(cmd)) {
|
|
328
|
+
return { action: 'deny', reason: 'redirect into project dotfile denied' };
|
|
329
|
+
}
|
|
274
330
|
// Upload-form exfiltration (mirrors shell_exec DANGEROUS_PATTERNS).
|
|
275
331
|
if (/\bcurl\b.*(?:\s-F\b|\s--form\b)/i.test(cmd)) {
|
|
276
332
|
return { action: 'deny', reason: 'exfiltration: curl -F/--form denied' };
|
|
@@ -284,6 +340,22 @@ export const shellDenyRule = {
|
|
|
284
340
|
if (/\bStart-BitsTransfer\b/i.test(cmd)) {
|
|
285
341
|
return { action: 'deny', reason: 'exfiltration: Start-BitsTransfer denied' };
|
|
286
342
|
}
|
|
343
|
+
// Protected-branch push deny (mirrors shell_exec DANGEROUS_PATTERNS).
|
|
344
|
+
// Explicit `git push ... main|master|production` is denied outright;
|
|
345
|
+
// a bare `git push` (no ref args) is denied when the checkout is on a
|
|
346
|
+
// protected branch. Escape hatch: KLYRO_ALLOW_MAIN_PUSH=1.
|
|
347
|
+
if (process.env.KLYRO_ALLOW_MAIN_PUSH !== '1') {
|
|
348
|
+
if (PROTECTED_PUSH_RE.test(cmd)) {
|
|
349
|
+
return { action: 'deny', reason: 'protected-branch push denied (main/master/production) — set KLYRO_ALLOW_MAIN_PUSH=1 to override' };
|
|
350
|
+
}
|
|
351
|
+
if (isBareGitPush(cmd)) {
|
|
352
|
+
const branch = currentGitBranch(ctx.cwd);
|
|
353
|
+
if (branch !== null && PROTECTED_BRANCHES.includes(branch)) {
|
|
354
|
+
return { action: 'deny', reason: `bare git push on protected branch '${branch}' denied — set KLYRO_ALLOW_MAIN_PUSH=1 to override` };
|
|
355
|
+
}
|
|
356
|
+
// currentGitBranch null (not a repo / git missing) → allow normal flow.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
287
359
|
return null;
|
|
288
360
|
},
|
|
289
361
|
};
|
|
@@ -383,4 +455,5 @@ export async function evaluatePolicy(engine, call, ctx) {
|
|
|
383
455
|
// policy/ depending only on tools/normalize, no other tool code.)
|
|
384
456
|
import * as path from 'node:path';
|
|
385
457
|
import * as fsSync from 'node:fs';
|
|
458
|
+
import { execFileSync } from 'node:child_process';
|
|
386
459
|
import { resolveWithinCwd } from './path-guard.js';
|
|
@@ -28,3 +28,27 @@ export declare function resolveWithinCwd(cwd: string, requested: string): PathGu
|
|
|
28
28
|
* Use this for read_file / write_file to defeat symlink-based escapes.
|
|
29
29
|
*/
|
|
30
30
|
export declare function resolveAndFollowSymlinks(cwd: string, requested: string): Promise<PathGuardResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Symlink-swap guard: refuse when the FINAL path itself is a symlink.
|
|
33
|
+
*
|
|
34
|
+
* File tools resolve symlinks up front, but a racing swap between that
|
|
35
|
+
* check and the final write/rename could redirect an open-with-O_TRUNC /
|
|
36
|
+
* O_CREAT (or rename) through a freshly-planted symlink. Calling this
|
|
37
|
+
* immediately before the final write/rename shrinks that window to ~0:
|
|
38
|
+
* `lstat` never follows the final component, so a swapped-in link is
|
|
39
|
+
* caught. (POSIX `rename(2)` onto a symlink replaces the LINK itself, so
|
|
40
|
+
* the vulnerable op is opening/truncating through the link — which this
|
|
41
|
+
* denies first.) Supported on POSIX and Windows (both implement lstat).
|
|
42
|
+
* Missing paths (ENOENT) are fine — a nonexistent path is not a symlink.
|
|
43
|
+
*/
|
|
44
|
+
export declare function assertNotSymlink(p: string): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Open a file for writing without following a trailing symlink (POSIX).
|
|
47
|
+
*
|
|
48
|
+
* Uses O_WRONLY | O_CREAT | O_NOFOLLOW so the open itself fails (ELOOP)
|
|
49
|
+
* when the final component is a symlink, instead of truncating through
|
|
50
|
+
* it. On Windows there is no O_NOFOLLOW, so this falls back to a plain
|
|
51
|
+
* `'w'` open — symlink-swap protection there comes from
|
|
52
|
+
* `assertNotSymlink` (lstat) immediately before the write/rename instead.
|
|
53
|
+
*/
|
|
54
|
+
export declare function openNoFollowForWrite(dir: string, file: string): number;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
import * as fs from 'node:fs/promises';
|
|
14
|
+
import * as fsSync from 'node:fs';
|
|
14
15
|
import { TOOL_ERROR_CODES } from '../tools/normalize.js';
|
|
15
16
|
export class PathGuardError extends Error {
|
|
16
17
|
code;
|
|
@@ -96,3 +97,48 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
|
|
|
96
97
|
}
|
|
97
98
|
return { resolved: real };
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Symlink-swap guard: refuse when the FINAL path itself is a symlink.
|
|
102
|
+
*
|
|
103
|
+
* File tools resolve symlinks up front, but a racing swap between that
|
|
104
|
+
* check and the final write/rename could redirect an open-with-O_TRUNC /
|
|
105
|
+
* O_CREAT (or rename) through a freshly-planted symlink. Calling this
|
|
106
|
+
* immediately before the final write/rename shrinks that window to ~0:
|
|
107
|
+
* `lstat` never follows the final component, so a swapped-in link is
|
|
108
|
+
* caught. (POSIX `rename(2)` onto a symlink replaces the LINK itself, so
|
|
109
|
+
* the vulnerable op is opening/truncating through the link — which this
|
|
110
|
+
* denies first.) Supported on POSIX and Windows (both implement lstat).
|
|
111
|
+
* Missing paths (ENOENT) are fine — a nonexistent path is not a symlink.
|
|
112
|
+
*/
|
|
113
|
+
export async function assertNotSymlink(p) {
|
|
114
|
+
let st;
|
|
115
|
+
try {
|
|
116
|
+
st = await fs.lstat(p);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
if (e?.code === 'ENOENT')
|
|
120
|
+
return;
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
if (st.isSymbolicLink()) {
|
|
124
|
+
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Refusing to write through symlink: ${p}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Open a file for writing without following a trailing symlink (POSIX).
|
|
129
|
+
*
|
|
130
|
+
* Uses O_WRONLY | O_CREAT | O_NOFOLLOW so the open itself fails (ELOOP)
|
|
131
|
+
* when the final component is a symlink, instead of truncating through
|
|
132
|
+
* it. On Windows there is no O_NOFOLLOW, so this falls back to a plain
|
|
133
|
+
* `'w'` open — symlink-swap protection there comes from
|
|
134
|
+
* `assertNotSymlink` (lstat) immediately before the write/rename instead.
|
|
135
|
+
*/
|
|
136
|
+
export function openNoFollowForWrite(dir, file) {
|
|
137
|
+
const target = path.resolve(dir, file);
|
|
138
|
+
if (process.platform === 'win32') {
|
|
139
|
+
// Windows fallback: no O_NOFOLLOW — plain open; see comment above.
|
|
140
|
+
return fsSync.openSync(target, 'w');
|
|
141
|
+
}
|
|
142
|
+
const flags = fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_NOFOLLOW;
|
|
143
|
+
return fsSync.openSync(target, flags, 0o666);
|
|
144
|
+
}
|
|
@@ -32,4 +32,10 @@ export declare function ratesFor(modelId: string): {
|
|
|
32
32
|
input: number;
|
|
33
33
|
output: number;
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* True for Anthropic-family model ids (Claude). Used by the runtime's
|
|
37
|
+
* cache-aware cost math: only Anthropic bills cacheRead/cacheWrite, so
|
|
38
|
+
* other families keep ignoring those counters.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isAnthropicModel(modelId: string): boolean;
|
|
35
41
|
export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number): number;
|
|
@@ -43,6 +43,14 @@ export function ratesFor(modelId) {
|
|
|
43
43
|
return { input: fam.input, output: fam.output };
|
|
44
44
|
return { input: 0, output: 0 };
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* True for Anthropic-family model ids (Claude). Used by the runtime's
|
|
48
|
+
* cache-aware cost math: only Anthropic bills cacheRead/cacheWrite, so
|
|
49
|
+
* other families keep ignoring those counters.
|
|
50
|
+
*/
|
|
51
|
+
export function isAnthropicModel(modelId) {
|
|
52
|
+
return /anthropic|claude/i.test(modelId);
|
|
53
|
+
}
|
|
46
54
|
export function estimateCost(modelId, inputTokens, outputTokens) {
|
|
47
55
|
// Single source: delegate to ratesFor so registry, family fallbacks, and
|
|
48
56
|
// local-$0 rules live in exactly one place (no split-brain with getModelInfo).
|
|
@@ -20,7 +20,7 @@ import * as fs from 'node:fs/promises';
|
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { z } from 'zod';
|
|
22
22
|
import { defineTool } from '../types.js';
|
|
23
|
-
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
23
|
+
import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
|
|
24
24
|
import { safe } from '../normalize.js';
|
|
25
25
|
import { wasRead } from './read-history.js';
|
|
26
26
|
// Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
|
|
@@ -242,6 +242,9 @@ export const applyPatchTool = defineTool({
|
|
|
242
242
|
await guardWrite(ctx.cwd, sec.path, resolved);
|
|
243
243
|
await assertSameTarget(ctx.cwd, sec.path, resolved);
|
|
244
244
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
245
|
+
// Symlink-swap guard: refuse if the final dest became a symlink
|
|
246
|
+
// since the up-front resolve (lstat never follows the final link).
|
|
247
|
+
await assertNotSymlink(resolved);
|
|
245
248
|
await fs.writeFile(resolved, content ? content + '\n' : '', 'utf-8');
|
|
246
249
|
patchedFiles.push(sec.path);
|
|
247
250
|
continue;
|
|
@@ -264,6 +267,8 @@ export const applyPatchTool = defineTool({
|
|
|
264
267
|
const next = applyHunks(sec.path, fileLines, hunks);
|
|
265
268
|
await assertSameTarget(ctx.cwd, sec.path, resolved);
|
|
266
269
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
270
|
+
// Symlink-swap guard (mirrors the creation path above).
|
|
271
|
+
await assertNotSymlink(resolved);
|
|
267
272
|
await fs.writeFile(resolved, next.join('\n') + (hasTrailingNewline ? '\n' : ''), 'utf-8');
|
|
268
273
|
patchedFiles.push(sec.path);
|
|
269
274
|
}
|
|
@@ -7,7 +7,7 @@ import * as path from 'node:path';
|
|
|
7
7
|
import * as crypto from 'node:crypto';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { defineTool } from '../types.js';
|
|
10
|
-
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
10
|
+
import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
|
|
11
11
|
import { safe } from '../normalize.js';
|
|
12
12
|
import { wasRead } from './read-history.js';
|
|
13
13
|
// Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
|
|
@@ -187,6 +187,9 @@ export const editFileTool = defineTool({
|
|
|
187
187
|
finally {
|
|
188
188
|
await fh.close().catch(() => undefined);
|
|
189
189
|
}
|
|
190
|
+
// Symlink-swap guard: refuse if the final dest became a symlink
|
|
191
|
+
// since the up-front resolve (lstat never follows the final link).
|
|
192
|
+
await assertNotSymlink(resolved);
|
|
190
193
|
try {
|
|
191
194
|
await fs.rename(tmp, resolved);
|
|
192
195
|
}
|
|
@@ -5,7 +5,7 @@ import * as fs from 'node:fs/promises';
|
|
|
5
5
|
import * as crypto from 'node:crypto';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { defineTool } from '../types.js';
|
|
8
|
-
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
8
|
+
import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
|
|
9
9
|
import { safe } from '../normalize.js';
|
|
10
10
|
import { wasRead } from './read-history.js';
|
|
11
11
|
import { checkStaleness, recordEditStaleness } from './edit-file.js';
|
|
@@ -112,6 +112,9 @@ export const multiEditTool = defineTool({
|
|
|
112
112
|
}
|
|
113
113
|
const tmp = `${resolved}.klyro-multi-${Date.now()}.tmp`;
|
|
114
114
|
await fs.writeFile(tmp, content, 'utf-8');
|
|
115
|
+
// Symlink-swap guard: refuse if the final dest became a symlink
|
|
116
|
+
// since the up-front resolve (lstat never follows the final link).
|
|
117
|
+
await assertNotSymlink(resolved);
|
|
115
118
|
try {
|
|
116
119
|
await fs.rename(tmp, resolved);
|
|
117
120
|
}
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* directories if needed.
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'node:fs/promises';
|
|
8
|
+
import * as fsSyncNode from 'node:fs';
|
|
8
9
|
import * as path from 'node:path';
|
|
9
10
|
import * as crypto from 'node:crypto';
|
|
10
11
|
import { z } from 'zod';
|
|
11
12
|
import { defineTool } from '../types.js';
|
|
12
|
-
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
13
|
+
import { resolveAndFollowSymlinks, assertNotSymlink, openNoFollowForWrite } from '../../policy/path-guard.js';
|
|
13
14
|
import { safe } from '../normalize.js';
|
|
14
15
|
// Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
|
|
15
16
|
// __tests__ segments — but NOT latest.ts / attest.ts / contest-data.
|
|
@@ -111,15 +112,24 @@ export const writeFileTool = defineTool({
|
|
|
111
112
|
if ((await canon(reResolved)) !== (await canon(resolved))) {
|
|
112
113
|
throw Object.assign(new Error(`Path target changed between check and write: ${input.path} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
|
|
113
114
|
}
|
|
114
|
-
// Truncate to 8k tokens ~32k chars for result
|
|
115
|
-
|
|
115
|
+
// Truncate to 8k tokens ~32k chars for result.
|
|
116
|
+
// The tmp file is opened O_NOFOLLOW (POSIX; plain 'w' on Windows —
|
|
117
|
+
// see openNoFollowForWrite) so a planted link at the fresh tmp name
|
|
118
|
+
// can't redirect the content write; atomic tmp+rename semantics stay.
|
|
119
|
+
const fd = openNoFollowForWrite(parent, path.basename(tmp));
|
|
116
120
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
fsSyncNode.writeFileSync(fd, data);
|
|
122
|
+
fsSyncNode.fsyncSync(fd);
|
|
119
123
|
}
|
|
120
124
|
finally {
|
|
121
|
-
|
|
125
|
+
try {
|
|
126
|
+
fsSyncNode.closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
catch { /* already closed */ }
|
|
122
129
|
}
|
|
130
|
+
// Symlink-swap guard: lstat the final dest immediately before rename
|
|
131
|
+
// and refuse if it became a symlink since the up-front resolve.
|
|
132
|
+
await assertNotSymlink(resolved);
|
|
123
133
|
try {
|
|
124
134
|
await fs.rename(tmp, resolved);
|
|
125
135
|
}
|
|
@@ -14,7 +14,7 @@ export const todoWriteTool = defineTool({
|
|
|
14
14
|
name: 'todo_write',
|
|
15
15
|
description: 'Update the live plan checklist. Persisted and re-injected when stale.',
|
|
16
16
|
inputSchema: InputSchema,
|
|
17
|
-
permission: '
|
|
17
|
+
permission: 'edit',
|
|
18
18
|
isConcurrencySafe: true,
|
|
19
19
|
execute: async (input, ctx) => {
|
|
20
20
|
return safe(async () => {
|
|
@@ -18,6 +18,34 @@ declare const InputSchema: z.ZodObject<{
|
|
|
18
18
|
/** Reset the sticky cwd (e.g. after /cd changes the session directory). */
|
|
19
19
|
export declare function resetPersistentCwd(): void;
|
|
20
20
|
export declare function filteredEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
|
|
21
|
+
/**
|
|
22
|
+
* Protected-branch push helpers (mirror policy engine shellDenyRule).
|
|
23
|
+
* `git push` targeting main/master/production is denied outright; a bare
|
|
24
|
+
* `git push` (no ref args) is denied when the checkout sits on a protected
|
|
25
|
+
* branch. Escape hatch: KLYRO_ALLOW_MAIN_PUSH=1.
|
|
26
|
+
*/
|
|
27
|
+
export declare const PROTECTED_BRANCHES: string[];
|
|
28
|
+
export declare const PROTECTED_PUSH_RE: RegExp;
|
|
29
|
+
/**
|
|
30
|
+
* True when KLYRO_ALLOW_MAIN_PUSH=1 opts out of the protected-push deny. */
|
|
31
|
+
export declare function isMainPushEscape(): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* True when the command is a `git push` with NO ref/positional args
|
|
34
|
+
* (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
|
|
35
|
+
* out. `git push origin feature` is explicit, not bare.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isBareGitPush(cmd: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the currently checked-out branch via `git branch --show-current`.
|
|
40
|
+
* Returns null on any failure (not a repo, git missing) — callers treat
|
|
41
|
+
* null as "unknown" and allow the normal flow to continue.
|
|
42
|
+
*/
|
|
43
|
+
export declare function currentGitBranch(cwd: string): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Bare-push check with cwd: deny `git push` with no ref args when the
|
|
46
|
+
* checkout is on a protected branch. Returns the block reason or null.
|
|
47
|
+
*/
|
|
48
|
+
export declare function findBareProtectedPushReason(command: string, cwd: string): string | null;
|
|
21
49
|
/**
|
|
22
50
|
* Shared dangerous-command check (also used by background shells).
|
|
23
51
|
* Returns the block reason, or null when the command is not hard-blocked.
|
|
@@ -8,7 +8,7 @@
|
|
|
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
14
|
import * as fsp from 'node:fs/promises';
|
|
@@ -73,6 +73,15 @@ export function filteredEnv(extra) {
|
|
|
73
73
|
}
|
|
74
74
|
// Interactive command detection (3.3)
|
|
75
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/;
|
|
76
85
|
// Hard-coded dangerous patterns. These are non-overridable, configurable via --yolo only.
|
|
77
86
|
const DANGEROUS_PATTERNS = [
|
|
78
87
|
{ pattern: /rm\s+-rf?\s+\//, reason: 'recursive delete at filesystem root' },
|
|
@@ -112,12 +121,79 @@ const DANGEROUS_PATTERNS = [
|
|
|
112
121
|
// .env writes via tee / PowerShell (mirrors policy engine .env guard).
|
|
113
122
|
{ pattern: /\|\s*tee\b[^\n]*\.env/i, reason: 'write to .env via tee denied' },
|
|
114
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' },
|
|
115
129
|
// Upload-form exfiltration (mirrors policy engine shellDenyRule).
|
|
116
130
|
{ pattern: /\bcurl\b.*(?:\s-F\b|\s--form\b)/i, reason: 'exfiltration: curl -F/--form denied' },
|
|
117
131
|
{ pattern: /\bwget\b.*(?:\s--method=POST\b|\s--body-data\b)/i, reason: 'exfiltration: wget --method=POST/--body-data denied' },
|
|
118
132
|
{ pattern: /\bInvoke-RestMethod\b/i, reason: 'exfiltration: Invoke-RestMethod denied' },
|
|
119
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' },
|
|
120
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
|
+
}
|
|
121
197
|
/**
|
|
122
198
|
* Repair-guard mirror: heredoc/redirect writes to test paths, or test-path
|
|
123
199
|
* rewrites via sed/python/perl/ruby/node/git (e.g. `> x.test.ts`,
|
|
@@ -133,6 +209,9 @@ const REPAIR_GUARD_SHELL_RE = />+\s*['"]?[^'"\s]*test[^'"\s]*|\b(sed|python|perl
|
|
|
133
209
|
*/
|
|
134
210
|
export function findBlockedReason(command) {
|
|
135
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;
|
|
136
215
|
if (pattern.test(command))
|
|
137
216
|
return reason;
|
|
138
217
|
}
|
|
@@ -204,6 +283,13 @@ export const shellExecTool = defineTool({
|
|
|
204
283
|
const timeoutMs = Math.min(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
205
284
|
const env = filteredEnv(input.env);
|
|
206
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
|
+
}
|
|
207
293
|
// Detached on POSIX so timeout kills the whole process GROUP
|
|
208
294
|
// (child.kill alone orphans grandchildren holding the pipes).
|
|
209
295
|
// G1-2 — sandbox is ON BY DEFAULT whenever a backend is available
|
package/dist/trace/writer.d.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* the serialized line is passed through redact() before it hits disk so
|
|
7
7
|
* secrets never land in the trace. readAll skips corrupt lines instead of
|
|
8
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.
|
|
9
16
|
*/
|
|
10
17
|
import type { KlyroEvent } from '../events/catalog.js';
|
|
11
18
|
export declare class TraceWriter {
|
package/dist/trace/writer.js
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* the serialized line is passed through redact() before it hits disk so
|
|
7
7
|
* secrets never land in the trace. readAll skips corrupt lines instead of
|
|
8
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.
|
|
9
16
|
*/
|
|
10
17
|
import * as fs from 'node:fs/promises';
|
|
11
18
|
import * as path from 'node:path';
|