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.
- package/dist/agent/anthropic-adapter.d.ts +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -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 +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- 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 +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- 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 -19
- package/dist/mcp/registry.js +127 -8
- 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 +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -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 +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- 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 +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- 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 +20 -0
- package/dist/trace/writer.js +62 -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 +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- 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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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();
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/trace/writer.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/trace/writer.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
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;
|