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
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import * as path from 'node:path';
13
13
  import * as fs from 'node:fs/promises';
14
+ import * as fsSync from 'node:fs';
14
15
  import { TOOL_ERROR_CODES } from '../tools/normalize.js';
15
16
  export class PathGuardError extends Error {
16
17
  code;
@@ -96,3 +97,48 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
96
97
  }
97
98
  return { resolved: real };
98
99
  }
100
+ /**
101
+ * Symlink-swap guard: refuse when the FINAL path itself is a symlink.
102
+ *
103
+ * File tools resolve symlinks up front, but a racing swap between that
104
+ * check and the final write/rename could redirect an open-with-O_TRUNC /
105
+ * O_CREAT (or rename) through a freshly-planted symlink. Calling this
106
+ * immediately before the final write/rename shrinks that window to ~0:
107
+ * `lstat` never follows the final component, so a swapped-in link is
108
+ * caught. (POSIX `rename(2)` onto a symlink replaces the LINK itself, so
109
+ * the vulnerable op is opening/truncating through the link — which this
110
+ * denies first.) Supported on POSIX and Windows (both implement lstat).
111
+ * Missing paths (ENOENT) are fine — a nonexistent path is not a symlink.
112
+ */
113
+ export async function assertNotSymlink(p) {
114
+ let st;
115
+ try {
116
+ st = await fs.lstat(p);
117
+ }
118
+ catch (e) {
119
+ if (e?.code === 'ENOENT')
120
+ return;
121
+ throw e;
122
+ }
123
+ if (st.isSymbolicLink()) {
124
+ throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Refusing to write through symlink: ${p}`);
125
+ }
126
+ }
127
+ /**
128
+ * Open a file for writing without following a trailing symlink (POSIX).
129
+ *
130
+ * Uses O_WRONLY | O_CREAT | O_NOFOLLOW so the open itself fails (ELOOP)
131
+ * when the final component is a symlink, instead of truncating through
132
+ * it. On Windows there is no O_NOFOLLOW, so this falls back to a plain
133
+ * `'w'` open — symlink-swap protection there comes from
134
+ * `assertNotSymlink` (lstat) immediately before the write/rename instead.
135
+ */
136
+ export function openNoFollowForWrite(dir, file) {
137
+ const target = path.resolve(dir, file);
138
+ if (process.platform === 'win32') {
139
+ // Windows fallback: no O_NOFOLLOW — plain open; see comment above.
140
+ return fsSync.openSync(target, 'w');
141
+ }
142
+ const flags = fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_NOFOLLOW;
143
+ return fsSync.openSync(target, flags, 0o666);
144
+ }
@@ -24,6 +24,10 @@ const PATTERNS = [
24
24
  { name: 'api-key', re: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}['"]?/gi },
25
25
  { name: 'password', re: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi },
26
26
  { name: 'secret-generic', re: /(?:secret|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/=]{16,}['"]?/gi },
27
+ { name: 'discord-token', re: /mfa\.[\w-]{84}|[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g },
28
+ { name: 'npm-token', re: /\bnpm_[A-Za-z0-9]{24,}/g },
29
+ { name: 'sendgrid-key', re: /\bSG\.[\w-]{22}\.[\w-]{43}/g },
30
+ { name: 'pypi-token', re: /\bpypi-[A-Za-z0-9_-]{30,}/g },
27
31
  ];
28
32
  const REPLACEMENT = '[REDACTED]';
29
33
  /** Redact a single string (or Buffer). */
@@ -15,4 +15,27 @@ export interface ModelInfo {
15
15
  }
16
16
  export declare const MODEL_REGISTRY: Record<string, ModelInfo>;
17
17
  export declare function getModelInfo(id: string): ModelInfo;
18
+ /**
19
+ * Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
20
+ * prices live in exactly one place. Local/self-hosted entries are $0.
21
+ */
22
+ export declare const MODEL_RATES_PER_1K: Record<string, {
23
+ input: number;
24
+ output: number;
25
+ }>;
26
+ /**
27
+ * Resolve per-1K rates for any model id. Exact registry hits win, then the
28
+ * local-model check ($0), then hosted-family fallbacks. Unknown models are
29
+ * $0 — never invent nonzero prices for models we don't recognize.
30
+ */
31
+ export declare function ratesFor(modelId: string): {
32
+ input: number;
33
+ output: number;
34
+ };
35
+ /**
36
+ * True for Anthropic-family model ids (Claude). Used by the runtime's
37
+ * cache-aware cost math: only Anthropic bills cacheRead/cacheWrite, so
38
+ * other families keep ignoring those counters.
39
+ */
40
+ export declare function isAnthropicModel(modelId: string): boolean;
18
41
  export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number): number;
@@ -12,7 +12,48 @@ export const MODEL_REGISTRY = {
12
12
  export function getModelInfo(id) {
13
13
  return MODEL_REGISTRY[id] ?? { id, contextWindow: 100000, maxOutput: 4096, inputPricePer1k: 0, outputPricePer1k: 0, capabilities: { tools: true } };
14
14
  }
15
+ /**
16
+ * Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
17
+ * prices live in exactly one place. Local/self-hosted entries are $0.
18
+ */
19
+ export const MODEL_RATES_PER_1K = Object.fromEntries(Object.entries(MODEL_REGISTRY).map(([k, v]) => [k, { input: v.inputPricePer1k, output: v.outputPricePer1k }]));
20
+ /** Hosted-family fallback rates for model ids not in the registry. */
21
+ const FAMILY_RATES = [
22
+ { test: (m) => /o1/i.test(m), input: 0.015, output: 0.06 },
23
+ { test: (m) => /gpt-3\.5/i.test(m), input: 0.0005, output: 0.0015 },
24
+ { test: (m) => /gpt-4/i.test(m), input: 0.003, output: 0.015 },
25
+ { test: (m) => /claude|anthropic/i.test(m), input: 0.003, output: 0.015 },
26
+ { test: (m) => /gemini/i.test(m), input: 0.00075, output: 0.003 },
27
+ ];
28
+ /** Local/self-hosted model ids are always priced at $0. */
29
+ const LOCAL_MODEL_PATTERN = /local|ollama|llama|mistral|mixtral|qwen|gemma|phi-|vllm|llama\.cpp|^mock/i;
30
+ /**
31
+ * Resolve per-1K rates for any model id. Exact registry hits win, then the
32
+ * local-model check ($0), then hosted-family fallbacks. Unknown models are
33
+ * $0 — never invent nonzero prices for models we don't recognize.
34
+ */
35
+ export function ratesFor(modelId) {
36
+ const exact = MODEL_RATES_PER_1K[modelId];
37
+ if (exact)
38
+ return exact;
39
+ if (LOCAL_MODEL_PATTERN.test(modelId))
40
+ return { input: 0, output: 0 };
41
+ const fam = FAMILY_RATES.find((r) => r.test(modelId));
42
+ if (fam)
43
+ return { input: fam.input, output: fam.output };
44
+ return { input: 0, output: 0 };
45
+ }
46
+ /**
47
+ * True for Anthropic-family model ids (Claude). Used by the runtime's
48
+ * cache-aware cost math: only Anthropic bills cacheRead/cacheWrite, so
49
+ * other families keep ignoring those counters.
50
+ */
51
+ export function isAnthropicModel(modelId) {
52
+ return /anthropic|claude/i.test(modelId);
53
+ }
15
54
  export function estimateCost(modelId, inputTokens, outputTokens) {
16
- const info = getModelInfo(modelId);
17
- return (inputTokens / 1000) * info.inputPricePer1k + (outputTokens / 1000) * info.outputPricePer1k;
55
+ // Single source: delegate to ratesFor so registry, family fallbacks, and
56
+ // local-$0 rules live in exactly one place (no split-brain with getModelInfo).
57
+ const { input, output } = ratesFor(modelId);
58
+ return (inputTokens / 1000) * input + (outputTokens / 1000) * output;
18
59
  }
package/dist/repl.d.ts CHANGED
@@ -6,4 +6,10 @@
6
6
  *
7
7
  * Type ':quit' (or Ctrl-C / Ctrl-D) to exit.
8
8
  */
9
+ export interface Turn {
10
+ role: 'user' | 'assistant';
11
+ content: string;
12
+ }
9
13
  export declare function repl(system: string): Promise<void>;
14
+ /** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
15
+ export declare function trimHistory(history: Turn[]): void;
package/dist/repl.js CHANGED
@@ -10,6 +10,7 @@ import * as readline from 'node:readline/promises';
10
10
  import { stdin, exit } from 'node:process';
11
11
  import { streamToStdout, readBoundedText } from './chat.js';
12
12
  import { resolveProvider, providerHelp } from './providers.js';
13
+ import { estimateTokens } from './context/tokenizer.js';
13
14
  /** Cap conversation history so we don't blow the model's context window. */
14
15
  const MAX_HISTORY_TURNS = 40;
15
16
  /** Approximate char budget for the user side of history. */
@@ -26,6 +27,8 @@ export async function repl(system) {
26
27
  exit(2);
27
28
  }
28
29
  const { baseURL, apiKey, model, source } = provider;
30
+ // Legacy REPL — prefer `klyro tui`; history truncation is approximate.
31
+ process.stderr.write('klyro: legacy REPL — prefer `klyro tui`; history truncation is approximate.\n');
29
32
  const history = [];
30
33
  // Adapt readline to the environment. On a TTY we let readline manage the
31
34
  // prompt and echo so the user sees their typing; on a pipe we suppress both
@@ -94,7 +97,7 @@ export async function repl(system) {
94
97
  }
95
98
  }
96
99
  /** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
97
- function trimHistory(history) {
100
+ export function trimHistory(history) {
98
101
  // Keep even number of turns (pairs) when trimming for turn count
99
102
  while (history.length > MAX_HISTORY_TURNS) {
100
103
  // Drop oldest 2 (user+assistant) to preserve adjacency
@@ -106,21 +109,23 @@ function trimHistory(history) {
106
109
  history.shift();
107
110
  }
108
111
  }
109
- let total = 0;
110
- for (const t of history)
111
- total += t.content.length;
112
- while (total > MAX_HISTORY_CHARS && history.length > 2) {
112
+ // Token-budgeted trim (approximate): estimate over the joined history
113
+ // instead of raw chars so long-token text (CJK, code) trims correctly.
114
+ const budgetTokens = Math.ceil(MAX_HISTORY_CHARS / 4);
115
+ if (estimateTokens(history.map((t) => t.content).join('\n')) <= budgetTokens)
116
+ return;
117
+ while (history.length > 2) {
113
118
  // Drop oldest pair to avoid orphaning tool context
114
119
  const first = history[0];
115
120
  const second = history[1];
116
121
  if (!first)
117
122
  break;
118
- total -= first.content.length;
119
123
  history.shift();
120
124
  if (second) {
121
- total -= second.content.length;
122
125
  history.shift();
123
126
  }
127
+ if (estimateTokens(history.map((t) => t.content).join('\n')) <= budgetTokens)
128
+ break;
124
129
  }
125
130
  }
126
131
  /** Send the full history, stream the reply, return the assistant text. */
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * P1.1 — spawn_agent tool (6-10fix.md §5).
3
3
  *
4
- * Delegates to `AgentSpawnBridge.spawnAgent`, which runs the child to
5
- * completion (P0/P1 scope: blocking) and returns a compact ChildSummary —
6
- * never the child transcript. True background `task_wait` / `task_stop`
7
- * arrives in P2.
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
8
  */
9
9
  import { z } from 'zod';
10
10
  import { defineTool } from '../types.js';
@@ -18,7 +18,7 @@ const InputSchema = z.object({
18
18
  });
19
19
  export const spawnAgentTool = defineTool({
20
20
  name: 'spawn_agent',
21
- description: 'Spawn a child agent and return its compact summary { taskId, status, ... }. Child transcript stays separate; use task_get for status.',
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
22
  inputSchema: InputSchema,
23
23
  permission: 'admin',
24
24
  isConcurrencySafe: false,
@@ -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,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
+ });
@@ -20,9 +20,43 @@ import * as fs from 'node:fs/promises';
20
20
  import * as path from 'node:path';
21
21
  import { z } from 'zod';
22
22
  import { defineTool } from '../types.js';
23
- import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
23
+ import { resolveAndFollowSymlinks, assertNotSymlink } 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,7 +240,11 @@ 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 });
245
+ // Symlink-swap guard: refuse if the final dest became a symlink
246
+ // since the up-front resolve (lstat never follows the final link).
247
+ await assertNotSymlink(resolved);
175
248
  await fs.writeFile(resolved, content ? content + '\n' : '', 'utf-8');
176
249
  patchedFiles.push(sec.path);
177
250
  continue;
@@ -192,7 +265,10 @@ export const applyPatchTool = defineTool({
192
265
  if (hasTrailingNewline && fileLines[fileLines.length - 1] === '')
193
266
  fileLines.pop();
194
267
  const next = applyHunks(sec.path, fileLines, hunks);
268
+ await assertSameTarget(ctx.cwd, sec.path, resolved);
195
269
  await fs.mkdir(path.dirname(resolved), { recursive: true });
270
+ // Symlink-swap guard (mirrors the creation path above).
271
+ await assertNotSymlink(resolved);
196
272
  await fs.writeFile(resolved, next.join('\n') + (hasTrailingNewline ? '\n' : ''), 'utf-8');
197
273
  patchedFiles.push(sec.path);
198
274
  }