klyro 0.1.63 → 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 (117) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +73 -16
  3. package/dist/agent/capabilities.d.ts +145 -0
  4. package/dist/agent/capabilities.js +191 -0
  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 +232 -0
  8. package/dist/agent/orchestrator.js +589 -0
  9. package/dist/agent/provider-adapter.d.ts +17 -0
  10. package/dist/agent/provider-adapter.js +35 -3
  11. package/dist/agent/registry.d.ts +1 -0
  12. package/dist/agent/registry.js +1 -0
  13. package/dist/agent/retry.d.ts +13 -2
  14. package/dist/agent/retry.js +21 -3
  15. package/dist/agent/runtime.d.ts +70 -8
  16. package/dist/agent/runtime.js +205 -34
  17. package/dist/agent/scoped-registry.d.ts +22 -0
  18. package/dist/agent/scoped-registry.js +42 -0
  19. package/dist/agent/task-manager.d.ts +115 -0
  20. package/dist/agent/task-manager.js +250 -0
  21. package/dist/agent/worker-spawner.d.ts +17 -12
  22. package/dist/agent/worker-spawner.js +26 -20
  23. package/dist/agent/worktree-manager.d.ts +74 -0
  24. package/dist/agent/worktree-manager.js +189 -0
  25. package/dist/checkpoints/store.js +30 -5
  26. package/dist/cli/auth.js +16 -1
  27. package/dist/cli/config.d.ts +9 -3
  28. package/dist/cli/config.js +64 -3
  29. package/dist/cli/dotenv.d.ts +3 -0
  30. package/dist/cli/dotenv.js +57 -0
  31. package/dist/cli/eval.d.ts +6 -1
  32. package/dist/cli/eval.js +9 -0
  33. package/dist/cli/repl.js +183 -17
  34. package/dist/cli/run.d.ts +10 -11
  35. package/dist/cli/run.js +176 -14
  36. package/dist/cli/update.d.ts +5 -0
  37. package/dist/cli/update.js +62 -10
  38. package/dist/context/import-graph.d.ts +2 -0
  39. package/dist/context/import-graph.js +31 -3
  40. package/dist/context/klyro-md.d.ts +6 -0
  41. package/dist/context/klyro-md.js +25 -16
  42. package/dist/context/memory.d.ts +8 -0
  43. package/dist/context/memory.js +50 -2
  44. package/dist/context/project-map.d.ts +6 -0
  45. package/dist/context/project-map.js +50 -2
  46. package/dist/context/repo-map.d.ts +2 -0
  47. package/dist/context/repo-map.js +31 -1
  48. package/dist/context/trust.d.ts +42 -0
  49. package/dist/context/trust.js +111 -0
  50. package/dist/events/catalog.d.ts +99 -0
  51. package/dist/index.js +93 -4
  52. package/dist/mcp/client.d.ts +55 -0
  53. package/dist/mcp/client.js +294 -0
  54. package/dist/mcp/config.d.ts +40 -0
  55. package/dist/mcp/config.js +99 -0
  56. package/dist/mcp/policy.d.ts +13 -0
  57. package/dist/mcp/policy.js +12 -0
  58. package/dist/mcp/registry.d.ts +72 -0
  59. package/dist/mcp/registry.js +244 -0
  60. package/dist/mcp/schema.d.ts +18 -0
  61. package/dist/mcp/schema.js +57 -0
  62. package/dist/mcp/trust.d.ts +20 -0
  63. package/dist/mcp/trust.js +74 -0
  64. package/dist/persistence/audit.d.ts +28 -0
  65. package/dist/persistence/audit.js +101 -1
  66. package/dist/persistence/store.d.ts +26 -2
  67. package/dist/persistence/store.js +140 -13
  68. package/dist/policy/approval.d.ts +14 -0
  69. package/dist/policy/approval.js +44 -2
  70. package/dist/policy/engine.d.ts +1 -0
  71. package/dist/policy/engine.js +91 -10
  72. package/dist/policy/secret-redactor.js +4 -0
  73. package/dist/providers/model-info.d.ts +17 -0
  74. package/dist/providers/model-info.js +35 -2
  75. package/dist/repl.d.ts +6 -0
  76. package/dist/repl.js +12 -7
  77. package/dist/tools/agent/spawn-agent.d.ts +9 -0
  78. package/dist/tools/agent/spawn-agent.js +50 -0
  79. package/dist/tools/agent/task-apply.d.ts +4 -0
  80. package/dist/tools/agent/task-apply.js +44 -0
  81. package/dist/tools/agent/task-get.d.ts +8 -0
  82. package/dist/tools/agent/task-get.js +40 -0
  83. package/dist/tools/agent/task-list.d.ts +4 -0
  84. package/dist/tools/agent/task-list.js +41 -0
  85. package/dist/tools/agent/task-stop.d.ts +6 -0
  86. package/dist/tools/agent/task-stop.js +39 -0
  87. package/dist/tools/agent/task-wait.d.ts +17 -0
  88. package/dist/tools/agent/task-wait.js +79 -0
  89. package/dist/tools/fs/apply-patch.js +71 -0
  90. package/dist/tools/fs/edit-file.js +65 -0
  91. package/dist/tools/fs/multi-edit.d.ts +4 -0
  92. package/dist/tools/fs/multi-edit.js +66 -0
  93. package/dist/tools/fs/write-file.js +67 -0
  94. package/dist/tools/plan/todo-write.d.ts +1 -1
  95. package/dist/tools/registry.js +12 -0
  96. package/dist/tools/shell/background.js +6 -3
  97. package/dist/tools/shell/sandbox.d.ts +51 -0
  98. package/dist/tools/shell/sandbox.js +143 -0
  99. package/dist/tools/shell/shell-exec.d.ts +1 -0
  100. package/dist/tools/shell/shell-exec.js +83 -11
  101. package/dist/tools/shell/worker-entry.d.ts +12 -0
  102. package/dist/tools/shell/worker-entry.js +43 -0
  103. package/dist/tools/types.d.ts +18 -0
  104. package/dist/tools/verify/run-verify.js +3 -1
  105. package/dist/trace/writer.d.ts +13 -0
  106. package/dist/trace/writer.js +55 -4
  107. package/dist/tui/app.js +1 -1
  108. package/dist/tui/approval.js +20 -21
  109. package/dist/util.d.ts +1 -0
  110. package/dist/util.js +1 -0
  111. package/dist/verification/baseline.js +17 -3
  112. package/dist/verification/classify.js +23 -12
  113. package/dist/verification/engine.js +3 -1
  114. package/dist/verification/registry.d.ts +2 -0
  115. package/dist/verification/registry.js +33 -0
  116. package/dist/verification/scoped.js +28 -6
  117. package/package.json +1 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * P1.1 — spawn_agent tool (6-10fix.md §5).
3
+ *
4
+ * Async: starts the child worker and returns IMMEDIATELY with
5
+ * `{ taskId, status: 'running', ... }` — never the child transcript.
6
+ * Await completion with `task_wait`, poll with `task_get` / `task_list`,
7
+ * merge the child's worktree with `task_apply`, or abort with `task_stop`.
8
+ */
9
+ import { z } from 'zod';
10
+ import { defineTool } from '../types.js';
11
+ import { toToolError } from '../normalize.js';
12
+ const InputSchema = z.object({
13
+ agent: z.string().min(1).describe('Agent id (e.g. explorer, implementer, tester, reviewer)'),
14
+ task: z.string().min(1).describe('Task brief for the child agent'),
15
+ cwd: z.string().optional().describe('Working directory override (defaults to parent cwd)'),
16
+ model: z.string().optional().describe('Model override for the child (must be honoured or error)'),
17
+ timeoutMs: z.number().int().positive().optional().describe('Child runtime timeout in ms'),
18
+ });
19
+ export const spawnAgentTool = defineTool({
20
+ name: 'spawn_agent',
21
+ description: 'Spawn a child agent asynchronously; returns immediately with { taskId, status: "running", ... }. Child transcript stays separate — await it with task_wait, poll with task_get, merge with task_apply, abort with task_stop.',
22
+ inputSchema: InputSchema,
23
+ permission: 'admin',
24
+ isConcurrencySafe: false,
25
+ renderCall: (input) => `spawn_agent(${input.agent}): ${input.task.slice(0, 80)}`,
26
+ execute: async (input, ctx) => {
27
+ try {
28
+ const bridge = ctx.agentBridge;
29
+ if (!bridge) {
30
+ return {
31
+ ok: false,
32
+ error: {
33
+ code: 'NO_ORCHESTRATOR',
34
+ message: 'spawn_agent requires an active orchestrator (run with --agent or via a parent agent)',
35
+ },
36
+ };
37
+ }
38
+ return await bridge.spawnAgent({
39
+ agent: input.agent,
40
+ task: input.task,
41
+ ...(input.cwd ? { cwd: input.cwd } : {}),
42
+ ...(input.model ? { model: input.model } : {}),
43
+ ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
44
+ });
45
+ }
46
+ catch (err) {
47
+ return { ok: false, error: toToolError(err) };
48
+ }
49
+ },
50
+ });
@@ -0,0 +1,4 @@
1
+ import type { ChildSummary } from '../../agent/orchestrator.js';
2
+ export declare const taskApplyTool: import("../types.js").Tool<{
3
+ taskId: string;
4
+ }, ChildSummary>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * task_apply tool — merge a succeeded child's work into the parent tree.
3
+ *
4
+ * For worktree-isolated children this merges the `klyro/<taskId>` branch and
5
+ * emits `subtask.merged`; for shared-cwd children (whose edits already
6
+ * landed in the parent tree) it is the explicit acknowledgement step that
7
+ * emits `subtask.merged` and returns the summary. Non-succeeded tasks error
8
+ * with NOT_READY; conflicts error with MERGE_CONFLICT.
9
+ */
10
+ import { z } from 'zod';
11
+ import { defineTool } from '../types.js';
12
+ import { toToolError } from '../normalize.js';
13
+ const InputSchema = z.object({
14
+ taskId: z.string().min(1).describe('Succeeded task id to apply (e.g. task_1)'),
15
+ });
16
+ export const taskApplyTool = defineTool({
17
+ name: 'task_apply',
18
+ description: 'Merge a succeeded child task into the parent tree (emits subtask.merged). Errors with NOT_READY unless the task succeeded.',
19
+ inputSchema: InputSchema,
20
+ permission: 'admin',
21
+ isConcurrencySafe: false,
22
+ renderCall: (input) => `task_apply(${input.taskId})`,
23
+ execute: async (input, ctx) => {
24
+ try {
25
+ const bridge = ctx.agentBridge;
26
+ if (!bridge) {
27
+ return {
28
+ ok: false,
29
+ error: { code: 'NO_ORCHESTRATOR', message: 'task_apply requires an active orchestrator' },
30
+ };
31
+ }
32
+ if (!bridge.applyTask) {
33
+ return {
34
+ ok: false,
35
+ error: { code: 'UNSUPPORTED', message: 'task_apply is not supported by this orchestrator bridge' },
36
+ };
37
+ }
38
+ return await bridge.applyTask(input.taskId);
39
+ }
40
+ catch (err) {
41
+ return { ok: false, error: toToolError(err) };
42
+ }
43
+ },
44
+ });
@@ -0,0 +1,8 @@
1
+ export declare const taskGetTool: import("../types.js").Tool<{
2
+ taskId: string;
3
+ }, import("../../agent/task-manager.js").TaskSummary & {
4
+ error?: {
5
+ code: string;
6
+ message: string;
7
+ };
8
+ }>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * P1.3 — task_get tool (6-10fix.md §5). Read-only + concurrency-safe.
3
+ * Returns one task's status and summary by id.
4
+ */
5
+ import { z } from 'zod';
6
+ import { defineTool } from '../types.js';
7
+ import { toToolError } from '../normalize.js';
8
+ const InputSchema = z.object({
9
+ taskId: z.string().min(1).describe('Task id (e.g. task_1)'),
10
+ });
11
+ export const taskGetTool = defineTool({
12
+ name: 'task_get',
13
+ description: "Return one child-agent task's status and summary by id.",
14
+ inputSchema: InputSchema,
15
+ permission: 'read',
16
+ isConcurrencySafe: true,
17
+ renderCall: (input) => `task_get(${input.taskId})`,
18
+ execute: async (input, ctx) => {
19
+ try {
20
+ const bridge = ctx.agentBridge;
21
+ if (!bridge) {
22
+ return {
23
+ ok: false,
24
+ error: { code: 'NO_ORCHESTRATOR', message: 'task_get requires an active orchestrator' },
25
+ };
26
+ }
27
+ const rec = bridge.getTask(input.taskId);
28
+ if (!rec) {
29
+ return {
30
+ ok: false,
31
+ error: { code: 'NOT_FOUND', message: `Unknown task: ${input.taskId}` },
32
+ };
33
+ }
34
+ return { ok: true, value: rec };
35
+ }
36
+ catch (err) {
37
+ return { ok: false, error: toToolError(err) };
38
+ }
39
+ },
40
+ });
@@ -0,0 +1,4 @@
1
+ export declare const taskListTool: import("../types.js").Tool<{
2
+ parentTaskId?: string | undefined;
3
+ status?: "running" | "succeeded" | "failed" | "cancelled" | "timed_out" | "blocked" | "queued" | undefined;
4
+ }, import("../../agent/task-manager.js").TaskSummary[]>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * P1.2 — task_list tool (6-10fix.md §5). Read-only + concurrency-safe.
3
+ * Returns TaskSummary[] snapshots from the orchestrator's TaskManager.
4
+ */
5
+ import { z } from 'zod';
6
+ import { defineTool } from '../types.js';
7
+ import { toToolError } from '../normalize.js';
8
+ const InputSchema = z.object({
9
+ parentTaskId: z.string().optional().describe('Filter by parent task id'),
10
+ status: z
11
+ .enum(['queued', 'running', 'succeeded', 'failed', 'cancelled', 'timed_out', 'blocked'])
12
+ .optional()
13
+ .describe('Filter by status'),
14
+ });
15
+ export const taskListTool = defineTool({
16
+ name: 'task_list',
17
+ description: 'List child-agent tasks (running and completed) with status summaries.',
18
+ inputSchema: InputSchema,
19
+ permission: 'read',
20
+ isConcurrencySafe: true,
21
+ renderCall: () => 'task_list',
22
+ execute: async (input, ctx) => {
23
+ try {
24
+ const bridge = ctx.agentBridge;
25
+ if (!bridge) {
26
+ return {
27
+ ok: false,
28
+ error: { code: 'NO_ORCHESTRATOR', message: 'task_list requires an active orchestrator' },
29
+ };
30
+ }
31
+ const tasks = bridge.listTasks({
32
+ ...(input.parentTaskId ? { parentTaskId: input.parentTaskId } : {}),
33
+ ...(input.status ? { status: input.status } : {}),
34
+ });
35
+ return { ok: true, value: tasks };
36
+ }
37
+ catch (err) {
38
+ return { ok: false, error: toToolError(err) };
39
+ }
40
+ },
41
+ });
@@ -0,0 +1,6 @@
1
+ export declare const taskStopTool: import("../types.js").Tool<{
2
+ taskId: string;
3
+ }, {
4
+ taskId: string;
5
+ status: import("../../agent/task-manager.js").TaskStatus;
6
+ }>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * task_stop tool — cancel a running child task via `taskManager.cancel`.
3
+ * Idempotent on terminal tasks (returns the current status).
4
+ */
5
+ import { z } from 'zod';
6
+ import { defineTool } from '../types.js';
7
+ import { toToolError } from '../normalize.js';
8
+ const InputSchema = z.object({
9
+ taskId: z.string().min(1).describe('Task id to stop (e.g. task_1)'),
10
+ });
11
+ export const taskStopTool = defineTool({
12
+ name: 'task_stop',
13
+ description: 'Cancel a running child-agent task. Returns { taskId, status }. Idempotent on terminal tasks.',
14
+ inputSchema: InputSchema,
15
+ permission: 'admin',
16
+ isConcurrencySafe: false,
17
+ renderCall: (input) => `task_stop(${input.taskId})`,
18
+ execute: async (input, ctx) => {
19
+ try {
20
+ const bridge = ctx.agentBridge;
21
+ if (!bridge) {
22
+ return {
23
+ ok: false,
24
+ error: { code: 'NO_ORCHESTRATOR', message: 'task_stop requires an active orchestrator' },
25
+ };
26
+ }
27
+ if (!bridge.cancelTask) {
28
+ return {
29
+ ok: false,
30
+ error: { code: 'UNSUPPORTED', message: 'task_stop is not supported by this orchestrator bridge' },
31
+ };
32
+ }
33
+ return bridge.cancelTask(input.taskId);
34
+ }
35
+ catch (err) {
36
+ return { ok: false, error: toToolError(err) };
37
+ }
38
+ },
39
+ });
@@ -0,0 +1,17 @@
1
+ import type { ChildSummary } from '../../agent/orchestrator.js';
2
+ import type { TaskStatus } from '../../agent/task-manager.js';
3
+ export interface WaitTaskEntry {
4
+ taskId: string;
5
+ status: TaskStatus;
6
+ summary?: ChildSummary;
7
+ error?: {
8
+ code: string;
9
+ message: string;
10
+ };
11
+ }
12
+ export declare const taskWaitTool: import("../types.js").Tool<{
13
+ taskIds: string[];
14
+ timeoutMs?: number | undefined;
15
+ }, {
16
+ tasks: import("../../agent/orchestrator.js").WaitedTask[];
17
+ }>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * task_wait tool — await one or more child tasks without cancelling them.
3
+ *
4
+ * Delegates to `AgentSpawnBridge.waitForTasks` when available, otherwise
5
+ * polls `getTask` until every id is terminal or `timeoutMs` elapses.
6
+ * Entries that do not settle in time come back with status `'running'` —
7
+ * the underlying task keeps running. Documented contract: waiting never
8
+ * cancels.
9
+ */
10
+ import { z } from 'zod';
11
+ import { defineTool } from '../types.js';
12
+ import { toToolError } from '../normalize.js';
13
+ const TERMINAL = new Set([
14
+ 'succeeded',
15
+ 'failed',
16
+ 'cancelled',
17
+ 'timed_out',
18
+ 'blocked',
19
+ ]);
20
+ const InputSchema = z.object({
21
+ taskIds: z.array(z.string().min(1)).min(1).describe('Task ids to await (e.g. ["task_1"])'),
22
+ timeoutMs: z.number().int().positive().optional().describe('Per-task wait timeout in ms (unset = wait indefinitely)'),
23
+ });
24
+ export const taskWaitTool = defineTool({
25
+ name: 'task_wait',
26
+ description: 'Await child-agent tasks until they settle. Entries that time out return status "running" — the task is NOT cancelled. Never cancels.',
27
+ inputSchema: InputSchema,
28
+ permission: 'read',
29
+ isConcurrencySafe: true,
30
+ renderCall: (input) => `task_wait(${input.taskIds.join(', ')})`,
31
+ execute: async (input, ctx) => {
32
+ try {
33
+ const bridge = ctx.agentBridge;
34
+ if (!bridge) {
35
+ return {
36
+ ok: false,
37
+ error: { code: 'NO_ORCHESTRATOR', message: 'task_wait requires an active orchestrator' },
38
+ };
39
+ }
40
+ if (bridge.waitForTasks) {
41
+ const r = await bridge.waitForTasks(input.taskIds, input.timeoutMs);
42
+ return { ok: true, value: r };
43
+ }
44
+ // Fallback for bridges without waitForTasks: poll getTask snapshots.
45
+ const deadline = input.timeoutMs !== undefined ? Date.now() + input.timeoutMs : undefined;
46
+ const tasks = [];
47
+ for (const taskId of input.taskIds) {
48
+ let snap = bridge.getTask(taskId);
49
+ if (!snap) {
50
+ tasks.push({ taskId, status: 'failed', error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } });
51
+ continue;
52
+ }
53
+ while (!TERMINAL.has(snap.status) && (deadline === undefined || Date.now() < deadline)) {
54
+ await new Promise((r) => setTimeout(r, 10));
55
+ const next = bridge.getTask(taskId);
56
+ if (next)
57
+ snap = next;
58
+ }
59
+ tasks.push({
60
+ taskId,
61
+ status: snap.status,
62
+ summary: {
63
+ taskId: snap.id,
64
+ agentName: snap.agentName,
65
+ status: snap.status,
66
+ durationMs: snap.durationMs ?? 0,
67
+ changedFiles: [],
68
+ ...(snap.error ? { error: { code: snap.error.code, message: snap.error.message } } : {}),
69
+ },
70
+ ...(snap.error ? { error: { code: snap.error.code, message: snap.error.message } } : {}),
71
+ });
72
+ }
73
+ return { ok: true, value: { tasks } };
74
+ }
75
+ catch (err) {
76
+ return { ok: false, error: toToolError(err) };
77
+ }
78
+ },
79
+ });
@@ -23,6 +23,40 @@ import { defineTool } from '../types.js';
23
23
  import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
24
24
  import { safe } from '../normalize.js';
25
25
  import { wasRead } from './read-history.js';
26
+ // Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
27
+ // __tests__ segments — but NOT latest.ts / attest.ts / contest-data.
28
+ const TEST_BASENAME_RE = /(^|[._-])(test|spec)\.[^.]+$|\.test\.[^.]+$|\.spec\.[^.]+$|__(tests|snapshots)__/i;
29
+ function repairGuardHit(targetPath, _content) {
30
+ const base = targetPath.split(/[\\/]/).pop() ?? targetPath;
31
+ // Basename alone decides. Patch CONTENT is never consulted for non-test
32
+ // paths (the old assert-skip regex over-blocked ordinary sources
33
+ // containing e.g. `it(` or `test(`), and stays unconsulted for test-like
34
+ // paths too since the basename already denies.
35
+ return TEST_BASENAME_RE.test(base);
36
+ }
37
+ async function allowedHit(cwd, resolved, allowed) {
38
+ if (!allowed)
39
+ return false;
40
+ // Canonicalize both sides (realpath) so lexical-vs-canonical spellings
41
+ // of the same directory (e.g. Windows 8.3 short names) compare equal.
42
+ const canon = async (p) => {
43
+ try {
44
+ return await fs.realpath(p);
45
+ }
46
+ catch {
47
+ return path.resolve(p);
48
+ }
49
+ };
50
+ const canonTarget = await canon(resolved);
51
+ for (const base of allowed) {
52
+ const absBase = path.isAbsolute(base) ? path.resolve(base) : path.resolve(cwd, base);
53
+ const canonBase = await canon(absBase);
54
+ const rel = path.relative(canonBase, canonTarget);
55
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
56
+ return false;
57
+ }
58
+ return true;
59
+ }
26
60
  const InputSchema = z.object({
27
61
  patch: z.string().min(1).describe('Unified diff patch text'),
28
62
  });
@@ -116,6 +150,28 @@ async function guardWrite(cwd, relPath, resolved) {
116
150
  // missing file → creation path, no guard needed
117
151
  }
118
152
  }
153
+ // TOCTOU shrink (mirrors write_file): re-resolve symlinks immediately
154
+ // before the write and refuse if the target moved. Compares canonicalized
155
+ // forms (not raw strings) to avoid false positives from
156
+ // lexical-vs-canonical spellings (e.g. `sub/../a.txt`, short names).
157
+ async function assertSameTarget(cwd, relPath, resolved) {
158
+ const { resolved: reResolved } = await resolveAndFollowSymlinks(cwd, relPath);
159
+ const canon = async (p) => {
160
+ try {
161
+ return await fs.realpath(p);
162
+ }
163
+ catch { /* missing file → try parent */ }
164
+ try {
165
+ return path.join(await fs.realpath(path.dirname(p)), path.basename(p));
166
+ }
167
+ catch {
168
+ return p;
169
+ }
170
+ };
171
+ if ((await canon(reResolved)) !== (await canon(resolved))) {
172
+ throw Object.assign(new Error(`Path target changed between check and write: ${relPath} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
173
+ }
174
+ }
119
175
  export const applyPatchTool = defineTool({
120
176
  name: 'apply_patch',
121
177
  description: 'Apply a unified diff patch (Codex-style). Real hunk application with context matching; mismatches fail loudly.',
@@ -130,6 +186,13 @@ export const applyPatchTool = defineTool({
130
186
  for (const line of lines) {
131
187
  if (line.startsWith('*** Begin Patch') || line.startsWith('*** End Patch'))
132
188
  continue;
189
+ // Delete sections are NOT supported — reject loudly rather than
190
+ // mis-parsing the section as a write.
191
+ if (line.startsWith('*** Delete File:')) {
192
+ throw Object.assign(new Error(`Delete sections not supported: ${line.trim()} (INVALID_PATCH)`), {
193
+ code: 'INVALID_PATCH',
194
+ });
195
+ }
133
196
  if (line.startsWith('*** Update File:')) {
134
197
  cur = { op: 'update', path: line.replace('*** Update File:', '').trim(), body: [] };
135
198
  sections.push(cur);
@@ -150,7 +213,13 @@ export const applyPatchTool = defineTool({
150
213
  for (const sec of sections) {
151
214
  if (!sec.path)
152
215
  throw Object.assign(new Error('Patch section missing file path'), { code: 'INVALID_PATCH' });
216
+ if (ctx.repairGuard?.denyTestEdits && repairGuardHit(sec.path, sec.body.join('\n'))) {
217
+ throw Object.assign(new Error('repair-guard: test edits denied (denyTestEdits)'), { code: 'POLICY_DENIED' });
218
+ }
153
219
  const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, sec.path);
220
+ if (await allowedHit(ctx.cwd, resolved, ctx.agentAllowedPaths)) {
221
+ throw Object.assign(new Error(`agent path not allowed: ${resolved} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
222
+ }
154
223
  const hunks = parseHunks(sec.body);
155
224
  if (sec.op === 'add' || hunks.length === 0) {
156
225
  // Creation path: content from + lines (legacy tolerant format).
@@ -171,6 +240,7 @@ export const applyPatchTool = defineTool({
171
240
  .map((l) => l.slice(1))
172
241
  .join('\n');
173
242
  await guardWrite(ctx.cwd, sec.path, resolved);
243
+ await assertSameTarget(ctx.cwd, sec.path, resolved);
174
244
  await fs.mkdir(path.dirname(resolved), { recursive: true });
175
245
  await fs.writeFile(resolved, content ? content + '\n' : '', 'utf-8');
176
246
  patchedFiles.push(sec.path);
@@ -192,6 +262,7 @@ export const applyPatchTool = defineTool({
192
262
  if (hasTrailingNewline && fileLines[fileLines.length - 1] === '')
193
263
  fileLines.pop();
194
264
  const next = applyHunks(sec.path, fileLines, hunks);
265
+ await assertSameTarget(ctx.cwd, sec.path, resolved);
195
266
  await fs.mkdir(path.dirname(resolved), { recursive: true });
196
267
  await fs.writeFile(resolved, next.join('\n') + (hasTrailingNewline ? '\n' : ''), 'utf-8');
197
268
  patchedFiles.push(sec.path);
@@ -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
10
  import { resolveAndFollowSymlinks } 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)
@@ -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`;
@@ -9,6 +9,45 @@ import { resolveAndFollowSymlinks } 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,6 +90,26 @@ 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');
49
115
  try {