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.
- package/dist/agent/anthropic-adapter.d.ts +36 -0
- package/dist/agent/anthropic-adapter.js +73 -16
- package/dist/agent/capabilities.d.ts +145 -0
- package/dist/agent/capabilities.js +191 -0
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +232 -0
- package/dist/agent/orchestrator.js +589 -0
- package/dist/agent/provider-adapter.d.ts +17 -0
- package/dist/agent/provider-adapter.js +35 -3
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +13 -2
- package/dist/agent/retry.js +21 -3
- package/dist/agent/runtime.d.ts +70 -8
- package/dist/agent/runtime.js +205 -34
- package/dist/agent/scoped-registry.d.ts +22 -0
- package/dist/agent/scoped-registry.js +42 -0
- package/dist/agent/task-manager.d.ts +115 -0
- package/dist/agent/task-manager.js +250 -0
- package/dist/agent/worker-spawner.d.ts +17 -12
- package/dist/agent/worker-spawner.js +26 -20
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.js +30 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/config.d.ts +9 -3
- package/dist/cli/config.js +64 -3
- package/dist/cli/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/repl.js +183 -17
- package/dist/cli/run.d.ts +10 -11
- package/dist/cli/run.js +176 -14
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.d.ts +6 -0
- package/dist/context/klyro-md.js +25 -16
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +99 -0
- package/dist/index.js +93 -4
- package/dist/mcp/client.d.ts +55 -0
- package/dist/mcp/client.js +294 -0
- package/dist/mcp/config.d.ts +40 -0
- package/dist/mcp/config.js +99 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +72 -0
- package/dist/mcp/registry.js +244 -0
- package/dist/mcp/schema.d.ts +18 -0
- package/dist/mcp/schema.js +57 -0
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +1 -0
- package/dist/policy/engine.js +91 -10
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +17 -0
- package/dist/providers/model-info.js +35 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -0
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-get.d.ts +8 -0
- package/dist/tools/agent/task-get.js +40 -0
- package/dist/tools/agent/task-list.d.ts +4 -0
- package/dist/tools/agent/task-list.js +41 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +71 -0
- package/dist/tools/fs/edit-file.js +65 -0
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +66 -0
- package/dist/tools/fs/write-file.js +67 -0
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +12 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +1 -0
- package/dist/tools/shell/shell-exec.js +83 -11
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +55 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +23 -12
- package/dist/verification/engine.js +3 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +33 -0
- package/dist/verification/scoped.js +28 -6
- package/package.json +1 -1
|
@@ -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,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,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,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,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 {
|