klyro 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +68 -16
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +46 -5
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +106 -5
  8. package/dist/agent/orchestrator.js +378 -58
  9. package/dist/agent/provider-adapter.d.ts +8 -0
  10. package/dist/agent/provider-adapter.js +12 -3
  11. package/dist/agent/retry.d.ts +1 -1
  12. package/dist/agent/retry.js +2 -2
  13. package/dist/agent/runtime.d.ts +50 -8
  14. package/dist/agent/runtime.js +196 -33
  15. package/dist/agent/worktree-manager.d.ts +74 -0
  16. package/dist/agent/worktree-manager.js +189 -0
  17. package/dist/checkpoints/store.js +30 -5
  18. package/dist/cli/auth.js +16 -1
  19. package/dist/cli/config.d.ts +9 -3
  20. package/dist/cli/config.js +64 -3
  21. package/dist/cli/eval.d.ts +6 -1
  22. package/dist/cli/eval.js +9 -0
  23. package/dist/cli/repl.js +155 -28
  24. package/dist/cli/run.d.ts +7 -11
  25. package/dist/cli/run.js +68 -17
  26. package/dist/cli/update.d.ts +5 -0
  27. package/dist/cli/update.js +62 -10
  28. package/dist/context/import-graph.d.ts +2 -0
  29. package/dist/context/import-graph.js +31 -3
  30. package/dist/context/klyro-md.js +4 -1
  31. package/dist/context/memory.d.ts +8 -0
  32. package/dist/context/memory.js +50 -2
  33. package/dist/context/project-map.d.ts +6 -0
  34. package/dist/context/project-map.js +50 -2
  35. package/dist/context/repo-map.d.ts +2 -0
  36. package/dist/context/repo-map.js +31 -1
  37. package/dist/events/catalog.d.ts +28 -0
  38. package/dist/index.js +89 -4
  39. package/dist/mcp/client.d.ts +6 -4
  40. package/dist/mcp/client.js +83 -14
  41. package/dist/mcp/config.d.ts +10 -0
  42. package/dist/mcp/config.js +18 -1
  43. package/dist/mcp/registry.d.ts +23 -1
  44. package/dist/mcp/registry.js +78 -6
  45. package/dist/mcp/schema.d.ts +11 -4
  46. package/dist/mcp/schema.js +27 -16
  47. package/dist/mcp/trust.d.ts +20 -0
  48. package/dist/mcp/trust.js +74 -0
  49. package/dist/persistence/audit.d.ts +28 -0
  50. package/dist/persistence/audit.js +101 -1
  51. package/dist/persistence/store.d.ts +26 -2
  52. package/dist/persistence/store.js +140 -13
  53. package/dist/policy/approval.d.ts +14 -0
  54. package/dist/policy/approval.js +44 -2
  55. package/dist/policy/engine.d.ts +1 -0
  56. package/dist/policy/engine.js +88 -8
  57. package/dist/policy/secret-redactor.js +4 -0
  58. package/dist/providers/model-info.d.ts +17 -0
  59. package/dist/providers/model-info.js +35 -2
  60. package/dist/repl.d.ts +6 -0
  61. package/dist/repl.js +12 -7
  62. package/dist/tools/agent/spawn-agent.js +5 -5
  63. package/dist/tools/agent/task-apply.d.ts +4 -0
  64. package/dist/tools/agent/task-apply.js +44 -0
  65. package/dist/tools/agent/task-stop.d.ts +6 -0
  66. package/dist/tools/agent/task-stop.js +39 -0
  67. package/dist/tools/agent/task-wait.d.ts +17 -0
  68. package/dist/tools/agent/task-wait.js +79 -0
  69. package/dist/tools/fs/apply-patch.js +71 -0
  70. package/dist/tools/fs/edit-file.js +65 -0
  71. package/dist/tools/fs/multi-edit.d.ts +4 -0
  72. package/dist/tools/fs/multi-edit.js +66 -0
  73. package/dist/tools/fs/write-file.js +67 -0
  74. package/dist/tools/registry.js +6 -0
  75. package/dist/tools/shell/background.js +6 -3
  76. package/dist/tools/shell/sandbox.d.ts +51 -0
  77. package/dist/tools/shell/sandbox.js +143 -0
  78. package/dist/tools/shell/shell-exec.d.ts +1 -0
  79. package/dist/tools/shell/shell-exec.js +83 -11
  80. package/dist/tools/shell/worker-entry.d.ts +12 -0
  81. package/dist/tools/shell/worker-entry.js +43 -0
  82. package/dist/tools/types.d.ts +6 -0
  83. package/dist/tools/verify/run-verify.js +3 -1
  84. package/dist/trace/writer.d.ts +13 -0
  85. package/dist/trace/writer.js +55 -4
  86. package/dist/tui/app.js +1 -1
  87. package/dist/tui/approval.js +20 -21
  88. package/dist/util.d.ts +1 -0
  89. package/dist/util.js +1 -0
  90. package/dist/verification/baseline.js +17 -3
  91. package/dist/verification/classify.js +23 -12
  92. package/dist/verification/engine.js +3 -1
  93. package/dist/verification/registry.d.ts +2 -0
  94. package/dist/verification/registry.js +33 -0
  95. package/dist/verification/scoped.js +28 -6
  96. package/package.json +1 -1
@@ -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 {
@@ -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
- const proc = spawn(command, { cwd, shell: true, windowsHide: true });
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
- const proc = spawn(argv[0], argv.slice(1), { cwd, shell: false, windowsHide: true });
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
- const child = spawn(input.command, {
159
- cwd,
160
- env: env,
161
- shell: true,
162
- stdio: ['ignore', 'pipe', 'pipe'],
163
- windowsHide: true,
164
- signal: ctx.signal,
165
- detached: process.platform !== 'win32',
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();
@@ -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
- const env = process.env;
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,
@@ -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[]>;