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.
Files changed (110) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +49 -5
  2. package/dist/agent/anthropic-adapter.js +86 -17
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +53 -6
  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 +124 -6
  8. package/dist/agent/orchestrator.js +425 -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 +54 -12
  13. package/dist/agent/runtime.d.ts +84 -8
  14. package/dist/agent/runtime.js +352 -39
  15. package/dist/agent/stream-budget.d.ts +36 -0
  16. package/dist/agent/stream-budget.js +121 -0
  17. package/dist/agent/worktree-manager.d.ts +74 -0
  18. package/dist/agent/worktree-manager.js +189 -0
  19. package/dist/checkpoints/store.d.ts +9 -0
  20. package/dist/checkpoints/store.js +56 -5
  21. package/dist/cli/auth.js +16 -1
  22. package/dist/cli/commit.d.ts +31 -0
  23. package/dist/cli/commit.js +142 -0
  24. package/dist/cli/config.d.ts +54 -3
  25. package/dist/cli/config.js +146 -3
  26. package/dist/cli/doctor.d.ts +1 -0
  27. package/dist/cli/doctor.js +71 -6
  28. package/dist/cli/eval.d.ts +6 -1
  29. package/dist/cli/eval.js +9 -0
  30. package/dist/cli/hooks.d.ts +47 -0
  31. package/dist/cli/hooks.js +181 -0
  32. package/dist/cli/repl.js +196 -29
  33. package/dist/cli/run.d.ts +13 -11
  34. package/dist/cli/run.js +144 -20
  35. package/dist/cli/update.d.ts +5 -0
  36. package/dist/cli/update.js +62 -10
  37. package/dist/context/import-graph.d.ts +2 -0
  38. package/dist/context/import-graph.js +31 -3
  39. package/dist/context/klyro-md.js +4 -1
  40. package/dist/context/memory.d.ts +8 -0
  41. package/dist/context/memory.js +50 -2
  42. package/dist/context/project-map.d.ts +6 -0
  43. package/dist/context/project-map.js +50 -2
  44. package/dist/context/repo-map.d.ts +2 -0
  45. package/dist/context/repo-map.js +31 -1
  46. package/dist/events/catalog.d.ts +37 -0
  47. package/dist/events/catalog.js +9 -0
  48. package/dist/index.js +177 -8
  49. package/dist/mcp/client.d.ts +6 -4
  50. package/dist/mcp/client.js +83 -14
  51. package/dist/mcp/config.d.ts +10 -0
  52. package/dist/mcp/config.js +18 -1
  53. package/dist/mcp/registry.d.ts +23 -19
  54. package/dist/mcp/registry.js +127 -8
  55. package/dist/mcp/schema.d.ts +11 -4
  56. package/dist/mcp/schema.js +27 -16
  57. package/dist/mcp/trust.d.ts +20 -0
  58. package/dist/mcp/trust.js +74 -0
  59. package/dist/persistence/audit.d.ts +28 -0
  60. package/dist/persistence/audit.js +101 -1
  61. package/dist/persistence/store.d.ts +26 -2
  62. package/dist/persistence/store.js +140 -13
  63. package/dist/policy/approval.d.ts +14 -0
  64. package/dist/policy/approval.js +44 -2
  65. package/dist/policy/engine.d.ts +17 -0
  66. package/dist/policy/engine.js +162 -9
  67. package/dist/policy/path-guard.d.ts +24 -0
  68. package/dist/policy/path-guard.js +46 -0
  69. package/dist/policy/secret-redactor.js +4 -0
  70. package/dist/providers/model-info.d.ts +23 -0
  71. package/dist/providers/model-info.js +43 -2
  72. package/dist/repl.d.ts +6 -0
  73. package/dist/repl.js +12 -7
  74. package/dist/tools/agent/spawn-agent.js +5 -5
  75. package/dist/tools/agent/task-apply.d.ts +4 -0
  76. package/dist/tools/agent/task-apply.js +44 -0
  77. package/dist/tools/agent/task-stop.d.ts +6 -0
  78. package/dist/tools/agent/task-stop.js +39 -0
  79. package/dist/tools/agent/task-wait.d.ts +17 -0
  80. package/dist/tools/agent/task-wait.js +79 -0
  81. package/dist/tools/fs/apply-patch.js +77 -1
  82. package/dist/tools/fs/edit-file.js +69 -1
  83. package/dist/tools/fs/multi-edit.d.ts +4 -0
  84. package/dist/tools/fs/multi-edit.js +70 -1
  85. package/dist/tools/fs/write-file.js +83 -6
  86. package/dist/tools/plan/todo-write.js +1 -1
  87. package/dist/tools/registry.js +6 -0
  88. package/dist/tools/shell/background.js +6 -3
  89. package/dist/tools/shell/sandbox.d.ts +51 -0
  90. package/dist/tools/shell/sandbox.js +143 -0
  91. package/dist/tools/shell/shell-exec.d.ts +29 -0
  92. package/dist/tools/shell/shell-exec.js +170 -12
  93. package/dist/tools/shell/worker-entry.d.ts +12 -0
  94. package/dist/tools/shell/worker-entry.js +43 -0
  95. package/dist/tools/types.d.ts +6 -0
  96. package/dist/tools/verify/run-verify.js +3 -1
  97. package/dist/trace/writer.d.ts +20 -0
  98. package/dist/trace/writer.js +62 -4
  99. package/dist/tui/app.js +1 -1
  100. package/dist/tui/approval.js +20 -21
  101. package/dist/util.d.ts +1 -0
  102. package/dist/util.js +1 -0
  103. package/dist/verification/baseline.js +17 -3
  104. package/dist/verification/classify.js +27 -15
  105. package/dist/verification/engine.d.ts +8 -0
  106. package/dist/verification/engine.js +28 -1
  107. package/dist/verification/registry.d.ts +2 -0
  108. package/dist/verification/registry.js +44 -0
  109. package/dist/verification/scoped.js +64 -11
  110. package/package.json +1 -1
@@ -3,12 +3,51 @@
3
3
  * Strict: ambiguous matches fail unless replaceAll=true. Missing match fails.
4
4
  */
5
5
  import * as fs from 'node:fs/promises';
6
+ import * as path from 'node:path';
6
7
  import * as crypto from 'node:crypto';
7
8
  import { z } from 'zod';
8
9
  import { defineTool } from '../types.js';
9
- import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
10
+ import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
10
11
  import { safe } from '../normalize.js';
11
12
  import { wasRead } from './read-history.js';
13
+ // Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
14
+ // __tests__ segments — but NOT latest.ts / attest.ts / contest-data.
15
+ const TEST_BASENAME_RE = /(^|[._-])(test|spec)\.[^.]+$|\.test\.[^.]+$|\.spec\.[^.]+$|__(tests|snapshots)__/i;
16
+ function checkRepairGuard(targetPath, _content, deny) {
17
+ if (!deny)
18
+ return null;
19
+ const base = targetPath.split(/[\\/]/).pop() ?? targetPath;
20
+ // Basename alone decides. File CONTENT is never consulted for non-test
21
+ // paths (the old assert-skip regex over-blocked ordinary sources
22
+ // containing e.g. `it(` or `test(`), and stays unconsulted for test-like
23
+ // paths too since the basename already denies.
24
+ if (!TEST_BASENAME_RE.test(base))
25
+ return null;
26
+ return { ok: false, error: { code: 'POLICY_DENIED', message: 'repair-guard: test edits denied (denyTestEdits)' } };
27
+ }
28
+ async function checkAllowedPaths(cwd, resolved, allowed) {
29
+ if (!allowed)
30
+ return null;
31
+ // Canonicalize both sides (realpath) so lexical-vs-canonical spellings
32
+ // of the same directory (e.g. Windows 8.3 short names) compare equal.
33
+ const canon = async (p) => {
34
+ try {
35
+ return await fs.realpath(p);
36
+ }
37
+ catch {
38
+ return path.resolve(p);
39
+ }
40
+ };
41
+ const canonTarget = await canon(resolved);
42
+ for (const base of allowed) {
43
+ const absBase = path.isAbsolute(base) ? path.resolve(base) : path.resolve(cwd, base);
44
+ const canonBase = await canon(absBase);
45
+ const rel = path.relative(canonBase, canonTarget);
46
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
47
+ return null;
48
+ }
49
+ return { ok: false, error: { code: 'POLICY_DENIED', message: `agent path not allowed: ${resolved}` } };
50
+ }
12
51
  const InputSchema = z.object({
13
52
  path: z.string().min(1),
14
53
  find: z.string().min(1).describe('Exact substring to match. Not a regex.'),
@@ -36,7 +75,13 @@ export const editFileTool = defineTool({
36
75
  renderResult: (output) => `${output.path} ${output.replacements} replacement(s)`,
37
76
  execute: async (input, ctx) => {
38
77
  return safe(async () => {
78
+ const guard = checkRepairGuard(input.path, `${input.find}\n${input.replace}`, ctx.repairGuard?.denyTestEdits);
79
+ if (guard)
80
+ return guard;
39
81
  const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
82
+ const allowed = await checkAllowedPaths(ctx.cwd, resolved, ctx.agentAllowedPaths);
83
+ if (allowed)
84
+ return allowed;
40
85
  // 4.1: staleness check via mtime+hash
41
86
  const stat = await fs.stat(resolved);
42
87
  const raw = await fs.readFile(resolved, 'utf-8');
@@ -112,6 +157,26 @@ export const editFileTool = defineTool({
112
157
  // Preserve BOM
113
158
  if (hasBOM)
114
159
  next = '\uFEFF' + next;
160
+ // TOCTOU shrink (mirrors write_file): re-resolve symlinks immediately
161
+ // before the write and refuse if the target moved. Compares
162
+ // canonicalized forms (not raw strings) to avoid false positives from
163
+ // lexical-vs-canonical spellings (e.g. `sub/../a.txt`, short names).
164
+ const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
165
+ const canon = async (p) => {
166
+ try {
167
+ return await fs.realpath(p);
168
+ }
169
+ catch { /* missing file → try parent */ }
170
+ try {
171
+ return path.join(await fs.realpath(path.dirname(p)), path.basename(p));
172
+ }
173
+ catch {
174
+ return p;
175
+ }
176
+ };
177
+ if ((await canon(reResolved)) !== (await canon(resolved))) {
178
+ throw Object.assign(new Error(`Path target changed between check and write: ${input.path} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
179
+ }
115
180
  const tmp = `${resolved}.klyro-edit-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.tmp`;
116
181
  await fs.writeFile(tmp, next, 'utf-8');
117
182
  // Ensure fsync before rename (like write_file)
@@ -122,6 +187,9 @@ export const editFileTool = defineTool({
122
187
  finally {
123
188
  await fh.close().catch(() => undefined);
124
189
  }
190
+ // Symlink-swap guard: refuse if the final dest became a symlink
191
+ // since the up-front resolve (lstat never follows the final link).
192
+ await assertNotSymlink(resolved);
125
193
  try {
126
194
  await fs.rename(tmp, resolved);
127
195
  }
@@ -9,6 +9,10 @@ export declare const multiEditTool: import("../types.js").Tool<{
9
9
  replaceAll?: boolean | undefined;
10
10
  }[];
11
11
  }, {
12
+ path: string;
13
+ edits: number;
14
+ diff: string;
15
+ } | {
12
16
  readonly path: string;
13
17
  readonly edits: number;
14
18
  readonly diff: `multi_edit ${number} edits`;
@@ -5,10 +5,49 @@ import * as fs from 'node:fs/promises';
5
5
  import * as crypto from 'node:crypto';
6
6
  import { z } from 'zod';
7
7
  import { defineTool } from '../types.js';
8
- import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
8
+ import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
9
9
  import { safe } from '../normalize.js';
10
10
  import { wasRead } from './read-history.js';
11
11
  import { checkStaleness, recordEditStaleness } from './edit-file.js';
12
+ import * as path from 'node:path';
13
+ // Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
14
+ // __tests__ segments — but NOT latest.ts / attest.ts / contest-data.
15
+ const TEST_BASENAME_RE = /(^|[._-])(test|spec)\.[^.]+$|\.test\.[^.]+$|\.spec\.[^.]+$|__(tests|snapshots)__/i;
16
+ function checkRepairGuard(targetPath, _content, deny) {
17
+ if (!deny)
18
+ return null;
19
+ const base = targetPath.split(/[\\/]/).pop() ?? targetPath;
20
+ // Basename alone decides. File CONTENT is never consulted for non-test
21
+ // paths (the old assert-skip regex over-blocked ordinary sources
22
+ // containing e.g. `it(` or `test(`), and stays unconsulted for test-like
23
+ // paths too since the basename already denies.
24
+ if (!TEST_BASENAME_RE.test(base))
25
+ return null;
26
+ return { ok: false, error: { code: 'POLICY_DENIED', message: 'repair-guard: test edits denied (denyTestEdits)' } };
27
+ }
28
+ async function checkAllowedPaths(cwd, resolved, allowed) {
29
+ if (!allowed)
30
+ return null;
31
+ // Canonicalize both sides (realpath) so lexical-vs-canonical spellings
32
+ // of the same directory (e.g. Windows 8.3 short names) compare equal.
33
+ const canon = async (p) => {
34
+ try {
35
+ return await fs.realpath(p);
36
+ }
37
+ catch {
38
+ return path.resolve(p);
39
+ }
40
+ };
41
+ const canonTarget = await canon(resolved);
42
+ for (const base of allowed) {
43
+ const absBase = path.isAbsolute(base) ? path.resolve(base) : path.resolve(cwd, base);
44
+ const canonBase = await canon(absBase);
45
+ const rel = path.relative(canonBase, canonTarget);
46
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
47
+ return null;
48
+ }
49
+ return { ok: false, error: { code: 'POLICY_DENIED', message: `agent path not allowed: ${resolved}` } };
50
+ }
12
51
  const EditSchema = z.object({
13
52
  find: z.string().min(1),
14
53
  replace: z.string(),
@@ -26,7 +65,14 @@ export const multiEditTool = defineTool({
26
65
  isConcurrencySafe: false,
27
66
  execute: async (input, ctx) => {
28
67
  return safe(async () => {
68
+ const combined = input.edits.map((e) => `${e.find}\n${e.replace}`).join('\n');
69
+ const guard = checkRepairGuard(input.path, combined, ctx.repairGuard?.denyTestEdits);
70
+ if (guard)
71
+ return guard;
29
72
  const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
73
+ const allowed = await checkAllowedPaths(ctx.cwd, resolved, ctx.agentAllowedPaths);
74
+ if (allowed)
75
+ return allowed;
30
76
  const stat = await fs.stat(resolved);
31
77
  let content = await fs.readFile(resolved, 'utf-8');
32
78
  if (checkStaleness(resolved, stat.mtimeMs, crypto.createHash('sha256').update(content, 'utf-8').digest('hex'))) {
@@ -44,8 +90,31 @@ export const multiEditTool = defineTool({
44
90
  throw Object.assign(new Error(`edit ${i}: ambiguous (${count} matches)`), { code: 'MATCH_AMBIGUOUS' });
45
91
  content = e.replaceAll ? content.split(e.find).join(e.replace) : content.replace(e.find, e.replace);
46
92
  }
93
+ // TOCTOU shrink (mirrors write_file): re-resolve symlinks immediately
94
+ // before the write and refuse if the target moved. Compares
95
+ // canonicalized forms (not raw strings) to avoid false positives from
96
+ // lexical-vs-canonical spellings (e.g. `sub/../a.txt`, short names).
97
+ const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
98
+ const canon = async (p) => {
99
+ try {
100
+ return await fs.realpath(p);
101
+ }
102
+ catch { /* missing file → try parent */ }
103
+ try {
104
+ return path.join(await fs.realpath(path.dirname(p)), path.basename(p));
105
+ }
106
+ catch {
107
+ return p;
108
+ }
109
+ };
110
+ if ((await canon(reResolved)) !== (await canon(resolved))) {
111
+ throw Object.assign(new Error(`Path target changed between check and write: ${input.path} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
112
+ }
47
113
  const tmp = `${resolved}.klyro-multi-${Date.now()}.tmp`;
48
114
  await fs.writeFile(tmp, content, 'utf-8');
115
+ // Symlink-swap guard: refuse if the final dest became a symlink
116
+ // since the up-front resolve (lstat never follows the final link).
117
+ await assertNotSymlink(resolved);
49
118
  try {
50
119
  await fs.rename(tmp, resolved);
51
120
  }
@@ -5,12 +5,51 @@
5
5
  * directories if needed.
6
6
  */
7
7
  import * as fs from 'node:fs/promises';
8
+ import * as fsSyncNode from 'node:fs';
8
9
  import * as path from 'node:path';
9
10
  import * as crypto from 'node:crypto';
10
11
  import { z } from 'zod';
11
12
  import { defineTool } from '../types.js';
12
- import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
13
+ import { resolveAndFollowSymlinks, assertNotSymlink, openNoFollowForWrite } from '../../policy/path-guard.js';
13
14
  import { safe } from '../normalize.js';
15
+ // Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
16
+ // __tests__ segments — but NOT latest.ts / attest.ts / contest-data.
17
+ const TEST_BASENAME_RE = /(^|[._-])(test|spec)\.[^.]+$|\.test\.[^.]+$|\.spec\.[^.]+$|__(tests|snapshots)__/i;
18
+ function checkRepairGuard(targetPath, _content, deny) {
19
+ if (!deny)
20
+ return null;
21
+ const base = targetPath.split(/[\\/]/).pop() ?? targetPath;
22
+ // Basename alone decides. File CONTENT is never consulted for non-test
23
+ // paths (the old assert-skip regex over-blocked ordinary sources
24
+ // containing e.g. `it(` or `test(`), and stays unconsulted for test-like
25
+ // paths too since the basename already denies.
26
+ if (!TEST_BASENAME_RE.test(base))
27
+ return null;
28
+ return { ok: false, error: { code: 'POLICY_DENIED', message: 'repair-guard: test edits denied (denyTestEdits)' } };
29
+ }
30
+ async function checkAllowedPaths(cwd, resolved, allowed) {
31
+ if (!allowed)
32
+ return null;
33
+ // Canonicalize both sides (realpath) so lexical-vs-canonical spellings
34
+ // of the same directory (e.g. Windows 8.3 short names) compare equal.
35
+ const canon = async (p) => {
36
+ try {
37
+ return await fs.realpath(p);
38
+ }
39
+ catch {
40
+ return path.resolve(p);
41
+ }
42
+ };
43
+ const canonTarget = await canon(resolved);
44
+ for (const base of allowed) {
45
+ const absBase = path.isAbsolute(base) ? path.resolve(base) : path.resolve(cwd, base);
46
+ const canonBase = await canon(absBase);
47
+ const rel = path.relative(canonBase, canonTarget);
48
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
49
+ return null;
50
+ }
51
+ return { ok: false, error: { code: 'POLICY_DENIED', message: `agent path not allowed: ${resolved}` } };
52
+ }
14
53
  const InputSchema = z.object({
15
54
  path: z.string().min(1).describe('Path relative to cwd or absolute (must be inside cwd)'),
16
55
  content: z.string().describe('File content. Use empty string to truncate.'),
@@ -26,7 +65,13 @@ export const writeFileTool = defineTool({
26
65
  renderResult: (output) => `${output.path} written ${output.bytesWritten} bytes`,
27
66
  execute: async (input, ctx) => {
28
67
  return safe(async () => {
68
+ const guard = checkRepairGuard(input.path, input.content, ctx.repairGuard?.denyTestEdits);
69
+ if (guard)
70
+ return guard;
29
71
  const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
72
+ const allowed = await checkAllowedPaths(ctx.cwd, resolved, ctx.agentAllowedPaths);
73
+ if (allowed)
74
+ return allowed;
30
75
  const parent = path.dirname(resolved);
31
76
  await fs.mkdir(parent, { recursive: true });
32
77
  // 3.2: if file exists and was not read this session, warn but allow with diff
@@ -44,15 +89,47 @@ export const writeFileTool = defineTool({
44
89
  const suffix = crypto.randomBytes(8).toString('hex');
45
90
  const tmp = path.join(parent, `.klyro-write-${process.pid}-${suffix}.tmp`);
46
91
  const data = Buffer.from(input.content, 'utf-8');
47
- // Truncate to 8k tokens ~32k chars for result
48
- const fh = await fs.open(tmp, 'w');
92
+ // TOCTOU shrink (not elimination): re-resolve symlinks immediately
93
+ // before the write and refuse if the target moved. A racing swap
94
+ // between this check and rename can still occur.
95
+ // NOTE: the pre-check resolution may be lexical (parent missing) while
96
+ // the re-resolution is canonical (parent now exists), so compare
97
+ // canonicalized forms — not raw strings — to avoid false positives
98
+ // (e.g. Windows 8.3 short-name expansion after mkdir).
99
+ const { resolved: reResolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
100
+ const canon = async (p) => {
101
+ try {
102
+ return await fs.realpath(p);
103
+ }
104
+ catch { /* missing file → try parent */ }
105
+ try {
106
+ return path.join(await fs.realpath(path.dirname(p)), path.basename(p));
107
+ }
108
+ catch {
109
+ return p;
110
+ }
111
+ };
112
+ if ((await canon(reResolved)) !== (await canon(resolved))) {
113
+ throw Object.assign(new Error(`Path target changed between check and write: ${input.path} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
114
+ }
115
+ // Truncate to 8k tokens ~32k chars for result.
116
+ // The tmp file is opened O_NOFOLLOW (POSIX; plain 'w' on Windows —
117
+ // see openNoFollowForWrite) so a planted link at the fresh tmp name
118
+ // can't redirect the content write; atomic tmp+rename semantics stay.
119
+ const fd = openNoFollowForWrite(parent, path.basename(tmp));
49
120
  try {
50
- await fh.writeFile(data);
51
- await fh.sync();
121
+ fsSyncNode.writeFileSync(fd, data);
122
+ fsSyncNode.fsyncSync(fd);
52
123
  }
53
124
  finally {
54
- await fh.close().catch(() => undefined);
125
+ try {
126
+ fsSyncNode.closeSync(fd);
127
+ }
128
+ catch { /* already closed */ }
55
129
  }
130
+ // Symlink-swap guard: lstat the final dest immediately before rename
131
+ // and refuse if it became a symlink since the up-front resolve.
132
+ await assertNotSymlink(resolved);
56
133
  try {
57
134
  await fs.rename(tmp, resolved);
58
135
  }
@@ -14,7 +14,7 @@ export const todoWriteTool = defineTool({
14
14
  name: 'todo_write',
15
15
  description: 'Update the live plan checklist. Persisted and re-injected when stale.',
16
16
  inputSchema: InputSchema,
17
- permission: 'read',
17
+ permission: 'edit',
18
18
  isConcurrencySafe: true,
19
19
  execute: async (input, ctx) => {
20
20
  return safe(async () => {
@@ -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,35 @@ 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;
21
+ /**
22
+ * Protected-branch push helpers (mirror policy engine shellDenyRule).
23
+ * `git push` targeting main/master/production is denied outright; a bare
24
+ * `git push` (no ref args) is denied when the checkout sits on a protected
25
+ * branch. Escape hatch: KLYRO_ALLOW_MAIN_PUSH=1.
26
+ */
27
+ export declare const PROTECTED_BRANCHES: string[];
28
+ export declare const PROTECTED_PUSH_RE: RegExp;
29
+ /**
30
+ * True when KLYRO_ALLOW_MAIN_PUSH=1 opts out of the protected-push deny. */
31
+ export declare function isMainPushEscape(): boolean;
32
+ /**
33
+ * True when the command is a `git push` with NO ref/positional args
34
+ * (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
35
+ * out. `git push origin feature` is explicit, not bare.
36
+ */
37
+ export declare function isBareGitPush(cmd: string): boolean;
38
+ /**
39
+ * Resolve the currently checked-out branch via `git branch --show-current`.
40
+ * Returns null on any failure (not a repo, git missing) — callers treat
41
+ * null as "unknown" and allow the normal flow to continue.
42
+ */
43
+ export declare function currentGitBranch(cwd: string): string | null;
44
+ /**
45
+ * Bare-push check with cwd: deny `git push` with no ref args when the
46
+ * checkout is on a protected branch. Returns the block reason or null.
47
+ */
48
+ export declare function findBareProtectedPushReason(command: string, cwd: string): string | null;
20
49
  /**
21
50
  * Shared dangerous-command check (also used by background shells).
22
51
  * Returns the block reason, or null when the command is not hard-blocked.