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
|
@@ -11,6 +11,44 @@ import { z } from 'zod';
|
|
|
11
11
|
import { defineTool } from '../types.js';
|
|
12
12
|
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
13
13
|
import { safe } from '../normalize.js';
|
|
14
|
+
// Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
|
|
15
|
+
// __tests__ segments — but NOT latest.ts / attest.ts / contest-data.
|
|
16
|
+
const TEST_BASENAME_RE = /(^|[._-])(test|spec)\.[^.]+$|\.test\.[^.]+$|\.spec\.[^.]+$|__(tests|snapshots)__/i;
|
|
17
|
+
function checkRepairGuard(targetPath, _content, deny) {
|
|
18
|
+
if (!deny)
|
|
19
|
+
return null;
|
|
20
|
+
const base = targetPath.split(/[\\/]/).pop() ?? targetPath;
|
|
21
|
+
// Basename alone decides. File CONTENT is never consulted for non-test
|
|
22
|
+
// paths (the old assert-skip regex over-blocked ordinary sources
|
|
23
|
+
// containing e.g. `it(` or `test(`), and stays unconsulted for test-like
|
|
24
|
+
// paths too since the basename already denies.
|
|
25
|
+
if (!TEST_BASENAME_RE.test(base))
|
|
26
|
+
return null;
|
|
27
|
+
return { ok: false, error: { code: 'POLICY_DENIED', message: 'repair-guard: test edits denied (denyTestEdits)' } };
|
|
28
|
+
}
|
|
29
|
+
async function checkAllowedPaths(cwd, resolved, allowed) {
|
|
30
|
+
if (!allowed)
|
|
31
|
+
return null;
|
|
32
|
+
// Canonicalize both sides (realpath) so lexical-vs-canonical spellings
|
|
33
|
+
// of the same directory (e.g. Windows 8.3 short names) compare equal.
|
|
34
|
+
const canon = async (p) => {
|
|
35
|
+
try {
|
|
36
|
+
return await fs.realpath(p);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return path.resolve(p);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const canonTarget = await canon(resolved);
|
|
43
|
+
for (const base of allowed) {
|
|
44
|
+
const absBase = path.isAbsolute(base) ? path.resolve(base) : path.resolve(cwd, base);
|
|
45
|
+
const canonBase = await canon(absBase);
|
|
46
|
+
const rel = path.relative(canonBase, canonTarget);
|
|
47
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return { ok: false, error: { code: 'POLICY_DENIED', message: `agent path not allowed: ${resolved}` } };
|
|
51
|
+
}
|
|
14
52
|
const InputSchema = z.object({
|
|
15
53
|
path: z.string().min(1).describe('Path relative to cwd or absolute (must be inside cwd)'),
|
|
16
54
|
content: z.string().describe('File content. Use empty string to truncate.'),
|
|
@@ -26,7 +64,13 @@ export const writeFileTool = defineTool({
|
|
|
26
64
|
renderResult: (output) => `${output.path} written ${output.bytesWritten} bytes`,
|
|
27
65
|
execute: async (input, ctx) => {
|
|
28
66
|
return safe(async () => {
|
|
67
|
+
const guard = checkRepairGuard(input.path, input.content, ctx.repairGuard?.denyTestEdits);
|
|
68
|
+
if (guard)
|
|
69
|
+
return guard;
|
|
29
70
|
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
|
|
71
|
+
const allowed = await checkAllowedPaths(ctx.cwd, resolved, ctx.agentAllowedPaths);
|
|
72
|
+
if (allowed)
|
|
73
|
+
return allowed;
|
|
30
74
|
const parent = path.dirname(resolved);
|
|
31
75
|
await fs.mkdir(parent, { recursive: true });
|
|
32
76
|
// 3.2: if file exists and was not read this session, warn but allow with diff
|
|
@@ -44,6 +88,29 @@ export const writeFileTool = defineTool({
|
|
|
44
88
|
const suffix = crypto.randomBytes(8).toString('hex');
|
|
45
89
|
const tmp = path.join(parent, `.klyro-write-${process.pid}-${suffix}.tmp`);
|
|
46
90
|
const data = Buffer.from(input.content, 'utf-8');
|
|
91
|
+
// TOCTOU shrink (not elimination): re-resolve symlinks immediately
|
|
92
|
+
// before the write and refuse if the target moved. A racing swap
|
|
93
|
+
// between this check and rename can still occur.
|
|
94
|
+
// NOTE: the pre-check resolution may be lexical (parent missing) while
|
|
95
|
+
// the re-resolution is canonical (parent now exists), so compare
|
|
96
|
+
// canonicalized forms — not raw strings — to avoid false positives
|
|
97
|
+
// (e.g. Windows 8.3 short-name expansion after mkdir).
|
|
98
|
+
const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
|
|
99
|
+
const canon = async (p) => {
|
|
100
|
+
try {
|
|
101
|
+
return await fs.realpath(p);
|
|
102
|
+
}
|
|
103
|
+
catch { /* missing file → try parent */ }
|
|
104
|
+
try {
|
|
105
|
+
return path.join(await fs.realpath(path.dirname(p)), path.basename(p));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return p;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
if ((await canon(reResolved)) !== (await canon(resolved))) {
|
|
112
|
+
throw Object.assign(new Error(`Path target changed between check and write: ${input.path} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
|
|
113
|
+
}
|
|
47
114
|
// Truncate to 8k tokens ~32k chars for result
|
|
48
115
|
const fh = await fs.open(tmp, 'w');
|
|
49
116
|
try {
|
package/dist/tools/registry.js
CHANGED
|
@@ -28,6 +28,9 @@ import { memoryWriteTool } from './memory-write.js';
|
|
|
28
28
|
import { spawnAgentTool } from './agent/spawn-agent.js';
|
|
29
29
|
import { taskListTool } from './agent/task-list.js';
|
|
30
30
|
import { taskGetTool } from './agent/task-get.js';
|
|
31
|
+
import { taskWaitTool } from './agent/task-wait.js';
|
|
32
|
+
import { taskStopTool } from './agent/task-stop.js';
|
|
33
|
+
import { taskApplyTool } from './agent/task-apply.js';
|
|
31
34
|
import { zodToJsonSchema } from './schema.js';
|
|
32
35
|
export class ToolRegistry {
|
|
33
36
|
tools = new Map();
|
|
@@ -113,5 +116,8 @@ export const builtinRegistry = () => {
|
|
|
113
116
|
r.register(spawnAgentTool);
|
|
114
117
|
r.register(taskListTool);
|
|
115
118
|
r.register(taskGetTool);
|
|
119
|
+
r.register(taskWaitTool);
|
|
120
|
+
r.register(taskStopTool);
|
|
121
|
+
r.register(taskApplyTool);
|
|
116
122
|
return r;
|
|
117
123
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
|
|
3
3
|
*/
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
-
import { findBlockedReason } from './shell-exec.js';
|
|
5
|
+
import { findBlockedReason, filteredEnv } from './shell-exec.js';
|
|
6
6
|
const jobs = new Map();
|
|
7
7
|
let counter = 0;
|
|
8
8
|
const MAX_JOBS = 5;
|
|
@@ -32,7 +32,9 @@ export function startBackground(command, cwd) {
|
|
|
32
32
|
if (blocked)
|
|
33
33
|
throw new Error(`Command blocked: ${blocked}`);
|
|
34
34
|
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
35
|
-
|
|
35
|
+
// Filtered env, same call shape as shell_exec — background children must
|
|
36
|
+
// not inherit secrets from the parent process.
|
|
37
|
+
const proc = spawn(command, { cwd, shell: true, windowsHide: true, env: filteredEnv() });
|
|
36
38
|
const job = { id, command, cwd, proc, output: '', start: Date.now() };
|
|
37
39
|
jobs.set(id, job);
|
|
38
40
|
proc.stdout?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
@@ -56,7 +58,8 @@ export function startBackgroundArgv(argv, cwd) {
|
|
|
56
58
|
if (argv.length === 0)
|
|
57
59
|
throw new Error('startBackgroundArgv requires argv');
|
|
58
60
|
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
59
|
-
|
|
61
|
+
// Filtered env, same call shape as shell_exec (see startBackground).
|
|
62
|
+
const proc = spawn(argv[0], argv.slice(1), { cwd, shell: false, windowsHide: true, env: filteredEnv() });
|
|
60
63
|
const job = { id, command: argv.join(' '), cwd, proc, output: '', start: Date.now() };
|
|
61
64
|
jobs.set(id, job);
|
|
62
65
|
proc.stdout?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G1-1 — OS-level sandbox shim for shell/subprocess tools.
|
|
3
|
+
*
|
|
4
|
+
* Bounds even a *compromised* tool call by the kernel, not by policy. Path
|
|
5
|
+
* guards + the destructive-pattern denylist stop a cooperative model; this
|
|
6
|
+
* is the layer that stops an exploited one.
|
|
7
|
+
*
|
|
8
|
+
* Backend strategy (defense-in-depth, cheapest-first):
|
|
9
|
+
* - `bwrap` (bubblewrap) — rootless, cross-distro, the same primitive Claude
|
|
10
|
+
* Code's sandbox docs reference. Primary backend when present.
|
|
11
|
+
* - landlock — kernel LSM, no helper binary needed, but requires a native
|
|
12
|
+
* syscall helper Node can't invoke directly. Tracked for the next step.
|
|
13
|
+
*
|
|
14
|
+
* Detection is done once at startup and cached. When no backend is present we
|
|
15
|
+
* *degrade cleanly* to `undefined` — an empty sandbox command and a clearly
|
|
16
|
+
* reported status, never a pretend "sandboxed" that isn't.
|
|
17
|
+
*/
|
|
18
|
+
export type SandboxBackend = 'bwrap' | 'none';
|
|
19
|
+
export interface SandboxStatus {
|
|
20
|
+
backend: SandboxBackend;
|
|
21
|
+
/** True when a real kernel/namespace boundary will be applied. */
|
|
22
|
+
active: boolean;
|
|
23
|
+
/** Human-readable why — for /sandbox and the trust gate. */
|
|
24
|
+
reason: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One-time detection. Cached for the process lifetime so we don't fork a
|
|
28
|
+
* probe on every tool call. bwrap only — landlock is a follow-on.
|
|
29
|
+
*/
|
|
30
|
+
export declare function detectSandbox(): SandboxStatus;
|
|
31
|
+
/**
|
|
32
|
+
* Build a bwrap argv-wrapper for a command, bounding:
|
|
33
|
+
* - filesystem: read-only root, bind cwd read-write (only the permitted dir)
|
|
34
|
+
* - network: disabled (--unshare-net) for tool calls by default
|
|
35
|
+
* - process: new PID/user namespace (--unshare-pid --unshare-user)
|
|
36
|
+
* - devices/sockets: stripped of host access
|
|
37
|
+
*
|
|
38
|
+
* Returns `undefined` when no backend is active — the caller then runs the
|
|
39
|
+
* command normally. Sandbox is opt-out of the *command namespace*, never a
|
|
40
|
+
* silent no-op that claims to be sandboxed.
|
|
41
|
+
*/
|
|
42
|
+
export declare function sandboxCommand(cmd: string, args: string[], opts: {
|
|
43
|
+
cwd: string;
|
|
44
|
+
readWriteDirs?: string[];
|
|
45
|
+
allowNetwork?: boolean;
|
|
46
|
+
}): {
|
|
47
|
+
cmd: string;
|
|
48
|
+
args: string[];
|
|
49
|
+
} | undefined;
|
|
50
|
+
/** Reset detection (tests). */
|
|
51
|
+
export declare function resetSandbox(): void;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G1-1 — OS-level sandbox shim for shell/subprocess tools.
|
|
3
|
+
*
|
|
4
|
+
* Bounds even a *compromised* tool call by the kernel, not by policy. Path
|
|
5
|
+
* guards + the destructive-pattern denylist stop a cooperative model; this
|
|
6
|
+
* is the layer that stops an exploited one.
|
|
7
|
+
*
|
|
8
|
+
* Backend strategy (defense-in-depth, cheapest-first):
|
|
9
|
+
* - `bwrap` (bubblewrap) — rootless, cross-distro, the same primitive Claude
|
|
10
|
+
* Code's sandbox docs reference. Primary backend when present.
|
|
11
|
+
* - landlock — kernel LSM, no helper binary needed, but requires a native
|
|
12
|
+
* syscall helper Node can't invoke directly. Tracked for the next step.
|
|
13
|
+
*
|
|
14
|
+
* Detection is done once at startup and cached. When no backend is present we
|
|
15
|
+
* *degrade cleanly* to `undefined` — an empty sandbox command and a clearly
|
|
16
|
+
* reported status, never a pretend "sandboxed" that isn't.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
let cachedStatus = null;
|
|
22
|
+
/** Walk $PATH for an executable without shells. Returns null when absent. */
|
|
23
|
+
function findOnPath(bin) {
|
|
24
|
+
const paths = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
|
|
25
|
+
for (const dir of paths) {
|
|
26
|
+
const p = path.join(dir, bin);
|
|
27
|
+
try {
|
|
28
|
+
if (fs.statSync(p).isFile() && p.endsWith(bin))
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** True when `bwrap --version` runs and reports a sane bubblewrap build. */
|
|
38
|
+
function bwrapUsable(binPath) {
|
|
39
|
+
try {
|
|
40
|
+
const r = spawnSync(binPath, ['--version'], { encoding: 'utf-8', timeout: 2000, windowsHide: true });
|
|
41
|
+
return r.status === 0 && r.stdout.trim().length > 0;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* One-time detection. Cached for the process lifetime so we don't fork a
|
|
49
|
+
* probe on every tool call. bwrap only — landlock is a follow-on.
|
|
50
|
+
*/
|
|
51
|
+
export function detectSandbox() {
|
|
52
|
+
if (cachedStatus)
|
|
53
|
+
return cachedStatus;
|
|
54
|
+
// bubblewrap is POSIX-only; Windows guards are policy+path only (documented
|
|
55
|
+
// honestly — Windows receives a native sandbox in G1-3 via a microVM).
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
cachedStatus = {
|
|
58
|
+
backend: 'none',
|
|
59
|
+
active: false,
|
|
60
|
+
reason: 'native sandbox not yet available on win32 (G1-3 microVM planned); policy+path guards active',
|
|
61
|
+
};
|
|
62
|
+
return cachedStatus;
|
|
63
|
+
}
|
|
64
|
+
const bwrap = findOnPath('bwrap');
|
|
65
|
+
if (bwrap && bwrapUsable(bwrap)) {
|
|
66
|
+
cachedStatus = { backend: 'bwrap', active: true, reason: `bubblewrap at ${bwrap}` };
|
|
67
|
+
return cachedStatus;
|
|
68
|
+
}
|
|
69
|
+
cachedStatus = {
|
|
70
|
+
backend: 'none',
|
|
71
|
+
active: false,
|
|
72
|
+
reason: 'bwrap not found on $PATH — install bubblewrap (e.g. `apt install bubblewrap`) or run unsandboxed; policy+path guards remain active',
|
|
73
|
+
};
|
|
74
|
+
return cachedStatus;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build a bwrap argv-wrapper for a command, bounding:
|
|
78
|
+
* - filesystem: read-only root, bind cwd read-write (only the permitted dir)
|
|
79
|
+
* - network: disabled (--unshare-net) for tool calls by default
|
|
80
|
+
* - process: new PID/user namespace (--unshare-pid --unshare-user)
|
|
81
|
+
* - devices/sockets: stripped of host access
|
|
82
|
+
*
|
|
83
|
+
* Returns `undefined` when no backend is active — the caller then runs the
|
|
84
|
+
* command normally. Sandbox is opt-out of the *command namespace*, never a
|
|
85
|
+
* silent no-op that claims to be sandboxed.
|
|
86
|
+
*/
|
|
87
|
+
export function sandboxCommand(cmd, args, opts) {
|
|
88
|
+
const status = detectSandbox();
|
|
89
|
+
if (!status.active)
|
|
90
|
+
return undefined;
|
|
91
|
+
const roDirs = ['/usr', '/bin', '/etc', '/lib', '/lib64', '/opt'];
|
|
92
|
+
const bwrapArgs = [
|
|
93
|
+
'--unshare-all',
|
|
94
|
+
'--ro-bind',
|
|
95
|
+
'/usr',
|
|
96
|
+
'/usr',
|
|
97
|
+
'--ro-bind',
|
|
98
|
+
'/bin',
|
|
99
|
+
'/bin',
|
|
100
|
+
'--ro-bind',
|
|
101
|
+
'/etc',
|
|
102
|
+
'/etc',
|
|
103
|
+
'--ro-bind',
|
|
104
|
+
'/lib',
|
|
105
|
+
'/lib',
|
|
106
|
+
'--ro-bind',
|
|
107
|
+
'/lib64',
|
|
108
|
+
'/lib64',
|
|
109
|
+
'--ro-bind',
|
|
110
|
+
'/opt',
|
|
111
|
+
'/opt',
|
|
112
|
+
'--dev',
|
|
113
|
+
'/dev',
|
|
114
|
+
'--proc',
|
|
115
|
+
'/proc',
|
|
116
|
+
'--tmpfs',
|
|
117
|
+
'/tmp',
|
|
118
|
+
];
|
|
119
|
+
// Read-write: the permitted cwd (and any explicitly allowed dirs) only.
|
|
120
|
+
const rw = [opts.cwd, ...(opts.readWriteDirs ?? [])];
|
|
121
|
+
for (const d of roDirs)
|
|
122
|
+
void d; // (kept for parity with the read-only surface)
|
|
123
|
+
for (const d of rw) {
|
|
124
|
+
bwrapArgs.push('--bind', d, d);
|
|
125
|
+
}
|
|
126
|
+
// A clean, empty HOME so the child can't read the operator's dotfiles.
|
|
127
|
+
bwrapArgs.push('--tmpfs', '/root');
|
|
128
|
+
bwrapArgs.push('--clearenv');
|
|
129
|
+
bwrapArgs.push('--setenv', 'PATH', '/usr/bin:/bin:/usr/local/bin');
|
|
130
|
+
bwrapArgs.push('--setenv', 'HOME', '/root');
|
|
131
|
+
bwrapArgs.push('--chdir', opts.cwd);
|
|
132
|
+
if (!opts.allowNetwork) {
|
|
133
|
+
// Default: sandboxed tool calls are offline. Exfiltration via curl/wget is
|
|
134
|
+
// already denylisted at the policy layer; this is the belt-and-suspenders.
|
|
135
|
+
bwrapArgs.push('--unshare-net');
|
|
136
|
+
}
|
|
137
|
+
bwrapArgs.push('--', cmd, ...args);
|
|
138
|
+
return { cmd: 'bwrap', args: bwrapArgs };
|
|
139
|
+
}
|
|
140
|
+
/** Reset detection (tests). */
|
|
141
|
+
export function resetSandbox() {
|
|
142
|
+
cachedStatus = null;
|
|
143
|
+
}
|
|
@@ -17,6 +17,7 @@ declare const InputSchema: z.ZodObject<{
|
|
|
17
17
|
}, z.core.$strip>;
|
|
18
18
|
/** Reset the sticky cwd (e.g. after /cd changes the session directory). */
|
|
19
19
|
export declare function resetPersistentCwd(): void;
|
|
20
|
+
export declare function filteredEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
|
|
20
21
|
/**
|
|
21
22
|
* Shared dangerous-command check (also used by background shells).
|
|
22
23
|
* Returns the block reason, or null when the command is not hard-blocked.
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
import { spawn, spawnSync } 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'))
|
|
@@ -99,7 +101,30 @@ const DANGEROUS_PATTERNS = [
|
|
|
99
101
|
{ pattern: /;\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
100
102
|
{ pattern: /&&\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
101
103
|
{ pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
104
|
+
// Exfiltration denylist (mirrors policy engine shellDenyRule).
|
|
105
|
+
{ 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' },
|
|
106
|
+
{ pattern: /\bwget\b.*--post-(data|file)\b/i, reason: 'exfiltration: wget --post-data/--post-file denied' },
|
|
107
|
+
{ pattern: /\b(powershell|pwsh)\b.*-e(nc|ncodedcommand)\b/i, reason: 'exfiltration: powershell -enc denied' },
|
|
108
|
+
{ pattern: /\bInvoke-Expression\b/i, reason: 'exfiltration: Invoke-Expression denied' },
|
|
109
|
+
{ pattern: /\biex\s*\(/i, reason: 'exfiltration: iex( denied' },
|
|
110
|
+
{ pattern: /\bcertutil\b.*-urlcache\b/i, reason: 'exfiltration: certutil -urlcache denied' },
|
|
111
|
+
{ pattern: /\bbitsadmin\b.*\/transfer\b/i, reason: 'exfiltration: bitsadmin /transfer denied' },
|
|
112
|
+
// .env writes via tee / PowerShell (mirrors policy engine .env guard).
|
|
113
|
+
{ pattern: /\|\s*tee\b[^\n]*\.env/i, reason: 'write to .env via tee denied' },
|
|
114
|
+
{ pattern: /\b(Set-Content|Out-File)\b[^\n]*\.env/i, reason: 'write to .env via Set-Content/Out-File denied' },
|
|
115
|
+
// Upload-form exfiltration (mirrors policy engine shellDenyRule).
|
|
116
|
+
{ pattern: /\bcurl\b.*(?:\s-F\b|\s--form\b)/i, reason: 'exfiltration: curl -F/--form denied' },
|
|
117
|
+
{ pattern: /\bwget\b.*(?:\s--method=POST\b|\s--body-data\b)/i, reason: 'exfiltration: wget --method=POST/--body-data denied' },
|
|
118
|
+
{ pattern: /\bInvoke-RestMethod\b/i, reason: 'exfiltration: Invoke-RestMethod denied' },
|
|
119
|
+
{ pattern: /\bStart-BitsTransfer\b/i, reason: 'exfiltration: Start-BitsTransfer denied' },
|
|
102
120
|
];
|
|
121
|
+
/**
|
|
122
|
+
* Repair-guard mirror: heredoc/redirect writes to test paths, or test-path
|
|
123
|
+
* rewrites via sed/python/perl/ruby/node/git (e.g. `> x.test.ts`,
|
|
124
|
+
* `<<EOF > x.test.ts`, `sed -i`, `git apply`). Only enforced when
|
|
125
|
+
* `ctx.repairGuard?.denyTestEdits` is set.
|
|
126
|
+
*/
|
|
127
|
+
const REPAIR_GUARD_SHELL_RE = />+\s*['"]?[^'"\s]*test[^'"\s]*|\b(sed|python|perl|ruby|node|git)\b[^\n]*test[^'"\s]*/i;
|
|
103
128
|
/**
|
|
104
129
|
* Shared dangerous-command check (also used by background shells).
|
|
105
130
|
* Returns the block reason, or null when the command is not hard-blocked.
|
|
@@ -113,6 +138,15 @@ export function findBlockedReason(command) {
|
|
|
113
138
|
}
|
|
114
139
|
return null;
|
|
115
140
|
}
|
|
141
|
+
/** Resolve symlinks on both base and target, then enforce containment. */
|
|
142
|
+
async function realContainedCwd(ctxCwd, resolved) {
|
|
143
|
+
const realBase = await fsp.realpath(ctxCwd).catch(() => path.resolve(ctxCwd));
|
|
144
|
+
const realTarget = await fsp.realpath(resolved).catch(() => resolved);
|
|
145
|
+
const rel = path.relative(realBase, realTarget);
|
|
146
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
|
|
147
|
+
return realTarget;
|
|
148
|
+
throw Object.assign(new Error(`Path escapes cwd via symlink: ${resolved}`), { code: TOOL_ERROR_CODES.PATH_ESCAPE });
|
|
149
|
+
}
|
|
116
150
|
export const shellExecTool = defineTool({
|
|
117
151
|
name: 'shell_exec',
|
|
118
152
|
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 +157,10 @@ export const shellExecTool = defineTool({
|
|
|
123
157
|
renderResult: (output) => `exit ${output.exitCode} (${output.durationMs}ms${output.timedOut ? ' timedOut' : ''})`,
|
|
124
158
|
execute: async (input, ctx) => {
|
|
125
159
|
return safe(async () => {
|
|
160
|
+
// Repair-guard mirror (denyTestEdits): refuse shell writes to test paths.
|
|
161
|
+
if (ctx.repairGuard?.denyTestEdits && REPAIR_GUARD_SHELL_RE.test(input.command)) {
|
|
162
|
+
throw Object.assign(new Error('repair-guard: test edits via shell denied (denyTestEdits)'), { code: 'POLICY_DENIED' });
|
|
163
|
+
}
|
|
126
164
|
// Interactive detection
|
|
127
165
|
for (const pat of INTERACTIVE_PATTERNS) {
|
|
128
166
|
if (pat.test(input.command)) {
|
|
@@ -137,6 +175,9 @@ export const shellExecTool = defineTool({
|
|
|
137
175
|
let cwd = persistentCwd ?? ctx.cwd;
|
|
138
176
|
if (input.cwd) {
|
|
139
177
|
cwd = resolveWithinCwd(ctx.cwd, input.cwd).resolved;
|
|
178
|
+
// Resolve symlinks for the effective cwd before the containment
|
|
179
|
+
// check, so a symlinked subdir can't escape the workspace.
|
|
180
|
+
cwd = await realContainedCwd(ctx.cwd, cwd);
|
|
140
181
|
persistentCwd = cwd;
|
|
141
182
|
}
|
|
142
183
|
else if (input.command.trim().startsWith('cd ')) {
|
|
@@ -145,25 +186,56 @@ export const shellExecTool = defineTool({
|
|
|
145
186
|
try {
|
|
146
187
|
const target = m[1].replace(/^["']|["']$/g, '');
|
|
147
188
|
const resolved = resolveWithinCwd(cwd, target).resolved;
|
|
148
|
-
persistentCwd = resolved;
|
|
189
|
+
persistentCwd = await realContainedCwd(ctx.cwd, resolved);
|
|
149
190
|
}
|
|
150
191
|
catch { /* ignore */ }
|
|
151
192
|
}
|
|
152
193
|
}
|
|
194
|
+
else {
|
|
195
|
+
// Even without an explicit cwd, normalize symlinks on the
|
|
196
|
+
// effective cwd so containment holds when ctx.cwd is symlinked.
|
|
197
|
+
try {
|
|
198
|
+
cwd = await realContainedCwd(ctx.cwd, cwd);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
throw Object.assign(new Error(`Working directory escapes workspace (POLICY_DENIED)`), { code: TOOL_ERROR_CODES.PATH_ESCAPE });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
153
204
|
const timeoutMs = Math.min(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
154
205
|
const env = filteredEnv(input.env);
|
|
155
206
|
const start = Date.now();
|
|
156
207
|
// Detached on POSIX so timeout kills the whole process GROUP
|
|
157
208
|
// (child.kill alone orphans grandchildren holding the pipes).
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
209
|
+
// G1-2 — sandbox is ON BY DEFAULT whenever a backend is available
|
|
210
|
+
// (bwrap, Linux). Explicit opt-out with KLYRO_SANDBOX=0. Sandboxed calls
|
|
211
|
+
// are OFFLINE by default; KLYRO_SANDBOX_NET=1 re-enables the network.
|
|
212
|
+
const sandboxBackend = detectSandbox();
|
|
213
|
+
const useSandbox = sandboxBackend.active && process.env.KLYRO_SANDBOX !== '0';
|
|
214
|
+
const sandboxWrap = useSandbox
|
|
215
|
+
? sandboxCommand('/bin/sh', ['-c', input.command], {
|
|
216
|
+
cwd,
|
|
217
|
+
allowNetwork: process.env.KLYRO_SANDBOX_NET === '1',
|
|
218
|
+
})
|
|
219
|
+
: undefined;
|
|
220
|
+
const child = sandboxWrap
|
|
221
|
+
? spawn(sandboxWrap.cmd, sandboxWrap.args, {
|
|
222
|
+
cwd,
|
|
223
|
+
env: env,
|
|
224
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
225
|
+
windowsHide: true,
|
|
226
|
+
signal: ctx.signal,
|
|
227
|
+
detached: process.platform !== 'win32',
|
|
228
|
+
shell: false,
|
|
229
|
+
})
|
|
230
|
+
: spawn(input.command, {
|
|
231
|
+
cwd,
|
|
232
|
+
env: env,
|
|
233
|
+
shell: true,
|
|
234
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
235
|
+
windowsHide: true,
|
|
236
|
+
signal: ctx.signal,
|
|
237
|
+
detached: process.platform !== 'win32',
|
|
238
|
+
});
|
|
167
239
|
const stdoutChunks = [];
|
|
168
240
|
const stderrChunks = [];
|
|
169
241
|
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,25 @@
|
|
|
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 type { KlyroEvent } from '../events/catalog.js';
|
|
5
11
|
export declare class TraceWriter {
|
|
6
12
|
private filePath;
|
|
13
|
+
/** Serializes appends; concurrent write() calls stay line-valid. */
|
|
14
|
+
private queue;
|
|
15
|
+
private nextSeq;
|
|
16
|
+
private seqPrimed;
|
|
7
17
|
constructor(sessionId: string, dir?: string);
|
|
8
18
|
init(): Promise<void>;
|
|
19
|
+
/** Prime the seq counter from existing lines (so restarts don't reuse seqs). */
|
|
20
|
+
private primeSeq;
|
|
9
21
|
write(ev: KlyroEvent): Promise<void>;
|
|
22
|
+
private appendOne;
|
|
10
23
|
close(): Promise<void>;
|
|
11
24
|
get path(): string;
|
|
12
25
|
readAll(): Promise<KlyroEvent[]>;
|