@namzu/sdk 3.3.0 → 4.0.0

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 (130) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +52 -2
  4. package/dist/agents/SupervisorAgent.js.map +1 -1
  5. package/dist/agents/__tests__/supervisor-coordinator-registration.test.d.ts +2 -0
  6. package/dist/agents/__tests__/supervisor-coordinator-registration.test.d.ts.map +1 -0
  7. package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +122 -0
  8. package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -0
  9. package/dist/connector/mcp/__tests__/prompt-as-tool.test.js +1 -1
  10. package/dist/connector/mcp/__tests__/prompt-as-tool.test.js.map +1 -1
  11. package/dist/connector/mcp/prompt-adapter.d.ts.map +1 -1
  12. package/dist/connector/mcp/prompt-adapter.js +16 -9
  13. package/dist/connector/mcp/prompt-adapter.js.map +1 -1
  14. package/dist/public-runtime.d.ts +1 -1
  15. package/dist/public-runtime.d.ts.map +1 -1
  16. package/dist/public-runtime.js +1 -1
  17. package/dist/public-runtime.js.map +1 -1
  18. package/dist/public-tools.d.ts +2 -0
  19. package/dist/public-tools.d.ts.map +1 -1
  20. package/dist/public-tools.js +3 -0
  21. package/dist/public-tools.js.map +1 -1
  22. package/dist/registry/index.d.ts +1 -1
  23. package/dist/registry/index.d.ts.map +1 -1
  24. package/dist/registry/index.js +1 -1
  25. package/dist/registry/index.js.map +1 -1
  26. package/dist/registry/tool/execute.d.ts +15 -0
  27. package/dist/registry/tool/execute.d.ts.map +1 -1
  28. package/dist/registry/tool/execute.js +19 -0
  29. package/dist/registry/tool/execute.js.map +1 -1
  30. package/dist/sandbox/__tests__/exec-cancellation.test.d.ts +2 -0
  31. package/dist/sandbox/__tests__/exec-cancellation.test.d.ts.map +1 -0
  32. package/dist/sandbox/__tests__/exec-cancellation.test.js +59 -0
  33. package/dist/sandbox/__tests__/exec-cancellation.test.js.map +1 -0
  34. package/dist/sandbox/provider/local.d.ts.map +1 -1
  35. package/dist/sandbox/provider/local.js +30 -5
  36. package/dist/sandbox/provider/local.js.map +1 -1
  37. package/dist/session/workspace/shared-run.d.ts +31 -0
  38. package/dist/session/workspace/shared-run.d.ts.map +1 -1
  39. package/dist/session/workspace/shared-run.js +31 -0
  40. package/dist/session/workspace/shared-run.js.map +1 -1
  41. package/dist/tools/__tests__/file-tool-containment.test.d.ts +2 -0
  42. package/dist/tools/__tests__/file-tool-containment.test.d.ts.map +1 -0
  43. package/dist/tools/__tests__/file-tool-containment.test.js +134 -0
  44. package/dist/tools/__tests__/file-tool-containment.test.js.map +1 -0
  45. package/dist/tools/__tests__/sandboxed-search.test.js +51 -0
  46. package/dist/tools/__tests__/sandboxed-search.test.js.map +1 -1
  47. package/dist/tools/__tests__/untrusted-envelope.test.d.ts +2 -0
  48. package/dist/tools/__tests__/untrusted-envelope.test.d.ts.map +1 -0
  49. package/dist/tools/__tests__/untrusted-envelope.test.js +55 -0
  50. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -0
  51. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts +2 -0
  52. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts.map +1 -0
  53. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +114 -0
  54. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -0
  55. package/dist/tools/builtins/__tests__/edit.test.js +9 -2
  56. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  57. package/dist/tools/builtins/__tests__/write-file.test.js +59 -0
  58. package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
  59. package/dist/tools/builtins/edit.d.ts.map +1 -1
  60. package/dist/tools/builtins/edit.js +66 -14
  61. package/dist/tools/builtins/edit.js.map +1 -1
  62. package/dist/tools/builtins/ls.d.ts +1 -1
  63. package/dist/tools/builtins/ls.d.ts.map +1 -1
  64. package/dist/tools/builtins/ls.js +95 -0
  65. package/dist/tools/builtins/ls.js.map +1 -1
  66. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  67. package/dist/tools/builtins/read-file.js +3 -2
  68. package/dist/tools/builtins/read-file.js.map +1 -1
  69. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  70. package/dist/tools/builtins/write-file.js +26 -12
  71. package/dist/tools/builtins/write-file.js.map +1 -1
  72. package/dist/tools/coordinator/__tests__/agent.test.js +6 -1
  73. package/dist/tools/coordinator/__tests__/agent.test.js.map +1 -1
  74. package/dist/tools/coordinator/__tests__/ask-user-question.test.js +47 -0
  75. package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
  76. package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts +2 -0
  77. package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts.map +1 -0
  78. package/dist/tools/coordinator/__tests__/empty-roster.test.js +97 -0
  79. package/dist/tools/coordinator/__tests__/empty-roster.test.js.map +1 -0
  80. package/dist/tools/coordinator/agent.d.ts +22 -8
  81. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  82. package/dist/tools/coordinator/agent.js +45 -2
  83. package/dist/tools/coordinator/agent.js.map +1 -1
  84. package/dist/tools/coordinator/index.d.ts.map +1 -1
  85. package/dist/tools/coordinator/index.js +80 -3
  86. package/dist/tools/coordinator/index.js.map +1 -1
  87. package/dist/tools/paths.d.ts +40 -0
  88. package/dist/tools/paths.d.ts.map +1 -1
  89. package/dist/tools/paths.js +70 -1
  90. package/dist/tools/paths.js.map +1 -1
  91. package/dist/tools/untrusted-envelope.d.ts +55 -0
  92. package/dist/tools/untrusted-envelope.d.ts.map +1 -0
  93. package/dist/tools/untrusted-envelope.js +65 -0
  94. package/dist/tools/untrusted-envelope.js.map +1 -0
  95. package/dist/types/sandbox/index.d.ts +10 -0
  96. package/dist/types/sandbox/index.d.ts.map +1 -1
  97. package/dist/types/sandbox/index.js.map +1 -1
  98. package/dist/types/toolset/index.d.ts +0 -24
  99. package/dist/types/toolset/index.d.ts.map +1 -1
  100. package/package.json +1 -1
  101. package/src/agents/SupervisorAgent.ts +51 -2
  102. package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +148 -0
  103. package/src/connector/mcp/__tests__/prompt-as-tool.test.ts +1 -1
  104. package/src/connector/mcp/prompt-adapter.ts +19 -9
  105. package/src/public-runtime.ts +1 -0
  106. package/src/public-tools.ts +4 -0
  107. package/src/registry/index.ts +1 -1
  108. package/src/registry/tool/execute.ts +23 -0
  109. package/src/sandbox/__tests__/exec-cancellation.test.ts +72 -0
  110. package/src/sandbox/provider/local.ts +30 -4
  111. package/src/session/workspace/shared-run.ts +31 -0
  112. package/src/tools/__tests__/file-tool-containment.test.ts +190 -0
  113. package/src/tools/__tests__/sandboxed-search.test.ts +66 -0
  114. package/src/tools/__tests__/untrusted-envelope.test.ts +82 -0
  115. package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +166 -0
  116. package/src/tools/builtins/__tests__/edit.test.ts +10 -2
  117. package/src/tools/builtins/__tests__/write-file.test.ts +80 -0
  118. package/src/tools/builtins/edit.ts +69 -14
  119. package/src/tools/builtins/ls.ts +102 -0
  120. package/src/tools/builtins/read-file.ts +3 -2
  121. package/src/tools/builtins/write-file.ts +26 -12
  122. package/src/tools/coordinator/__tests__/agent.test.ts +6 -1
  123. package/src/tools/coordinator/__tests__/ask-user-question.test.ts +58 -0
  124. package/src/tools/coordinator/__tests__/empty-roster.test.ts +118 -0
  125. package/src/tools/coordinator/agent.ts +72 -11
  126. package/src/tools/coordinator/index.ts +86 -3
  127. package/src/tools/paths.ts +83 -1
  128. package/src/tools/untrusted-envelope.ts +78 -0
  129. package/src/types/sandbox/index.ts +10 -0
  130. package/src/types/toolset/index.ts +0 -25
@@ -1,7 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
2
+
3
3
  import { z } from 'zod'
4
4
  import { defineTool } from '../defineTool.js'
5
+ import { resolveWithinReal } from '../paths.js'
5
6
  import { atomicWriteFile } from './atomic-write-file.js'
6
7
  import { fingerprintContent, staleFileError } from './content-fingerprint.js'
7
8
  import { withFileMutationLock } from './file-mutation-lock.js'
@@ -99,12 +100,40 @@ const modelInputSchema: Record<string, unknown> = {
99
100
  description:
100
101
  'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
101
102
  },
103
+ insertLine: {
104
+ // The union the execution schema already accepts, stated so a
105
+ // constrained decoder can emit it. Declaring it as `oneOf` of an
106
+ // integer and the literal `"end"` also makes the synonym problem
107
+ // structurally impossible: `"EOF"`, `"append"` and `"last"` are
108
+ // not emittable, because `"end"` is the only string the schema
109
+ // admits.
110
+ oneOf: [{ type: 'integer', minimum: 0 }, { const: 'end' }],
111
+ description:
112
+ 'Insert instead of replacing. The new_string goes after this 1-indexed line; 0 inserts before the first line; "end" appends. Omit for a find-and-replace.',
113
+ },
102
114
  replace_all: {
103
115
  type: 'boolean',
104
116
  description: 'Replace every occurrence instead of requiring one unique match.',
105
117
  },
106
118
  },
107
- required: ['path', 'old_string', 'new_string'],
119
+ // `old_string` is deliberately NOT required, and this is the fix.
120
+ //
121
+ // The tool's own description tells the model to append with `insertLine`,
122
+ // and this schema forbade the field while `enforceModelInput` was on — so
123
+ // the idiom the prompt ordered was the one idiom a constrained model could
124
+ // not express. Requiring `old_string` reintroduces that, since an insert
125
+ // has no text to match.
126
+ //
127
+ // Which of `old_string` / `insertLine` is present is decided by the two
128
+ // refinements on the execution schema, which already exist and name what
129
+ // is missing. That is a deliberate choice over a top-level `oneOf`: strict
130
+ // structured-output modes are least surprising with a flat object, and a
131
+ // discriminated union at the root is the construct most likely to be
132
+ // rejected or quietly ignored by a provider. The cost is that an
133
+ // incomplete call is now expressible and caught at execution rather than
134
+ // at generation — paid knowingly, because the alternative is that a
135
+ // working capability stays unreachable.
136
+ required: ['path', 'new_string'],
108
137
  additionalProperties: false,
109
138
  }
110
139
 
@@ -130,7 +159,7 @@ export const EditTool = defineTool({
130
159
  modelInputSchema,
131
160
  enforceModelInput: true,
132
161
  validationErrorHint:
133
- 'Required shape: {"path":"file.md","old_string":"exact unique text","new_string":"replacement text"}. Optional: "replace_all": true.',
162
+ 'Two shapes. Replace: {"path":"file.md","old_string":"exact unique text","new_string":"replacement text"} (optional "replace_all": true). Insert: {"path":"file.md","insertLine":"end","new_string":"text to add"} where insertLine is a non-negative line number or "end". Exactly one of old_string or insertLine.',
134
163
  category: 'filesystem',
135
164
  permissions: ['file_write'],
136
165
  readOnly: false,
@@ -165,14 +194,20 @@ export const EditTool = defineTool({
165
194
  }
166
195
  }
167
196
 
168
- const filePath = resolve(context.workingDirectory, parsed.data.path)
197
+ // Host-side containment, on the host branch only. The sandbox has its
198
+ // own root and its own resolver; canonicalizing a sandbox-relative
199
+ // path against the HOST filesystem asks a question about the wrong
200
+ // machine, and answers it with whatever happens to exist there.
201
+ const filePath = context.sandbox
202
+ ? undefined
203
+ : await resolveWithinReal(context.workingDirectory, parsed.data.path)
169
204
  // Read-modify-write is not atomic on its own: two edits to the same
170
205
  // path interleave their reads, and the second write lands on content
171
206
  // the first had already replaced — so one edit vanishes and the loser
172
207
  // reports "old_string not found", blaming the model for a race. The
173
208
  // key spans both branches because sandbox and local are distinct
174
209
  // files even when the path string matches.
175
- const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`
210
+ const lockKey = context.sandbox ? `sandbox:${parsed.data.path}` : `local:${filePath as string}`
176
211
 
177
212
  return withFileMutationLock(lockKey, async () => {
178
213
  if (context.sandbox) {
@@ -190,7 +225,8 @@ export const EditTool = defineTool({
190
225
  }
191
226
  }
192
227
 
193
- const content = await readFile(filePath, 'utf-8')
228
+ const hostPath = filePath as string
229
+ const content = await readFile(hostPath, 'utf-8')
194
230
 
195
231
  const result = applyEdit(content, normalized.operation)
196
232
  if (!result.success) {
@@ -213,9 +249,9 @@ export const EditTool = defineTool({
213
249
  // changed elsewhere in the file, and refusing there would
214
250
  // reject safe edits every time anyone touched an unrelated
215
251
  // line.
216
- const seen = context.fileReadTracker?.fingerprint?.(filePath)
252
+ const seen = context.fileReadTracker?.fingerprint?.(hostPath)
217
253
  if (seen !== undefined && seen !== fingerprintContent(content)) {
218
- return { success: false as const, output: '', error: staleFileError(filePath) }
254
+ return { success: false as const, output: '', error: staleFileError(hostPath) }
219
255
  }
220
256
  return { success: false as const, output: '', error: result.error }
221
257
  }
@@ -223,15 +259,15 @@ export const EditTool = defineTool({
223
259
  // Temp file, fsync, rename — a reader sees the old body or the new
224
260
  // one, never a half-written one. A plain `writeFile` that fails
225
261
  // partway leaves the user's source truncated.
226
- await atomicWriteFile(filePath, result.content)
262
+ await atomicWriteFile(hostPath, result.content)
227
263
  // This runtime is now the last writer, so the next edit in the same
228
264
  // turn compares against what we just wrote rather than the read
229
265
  // before it.
230
- context.fileReadTracker?.recordRead(filePath, result.content)
266
+ context.fileReadTracker?.recordRead(hostPath, result.content)
231
267
  return {
232
268
  success: true as const,
233
- output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
234
- data: { path: filePath, replacements: result.replacements },
269
+ output: `Edited ${hostPath}: ${result.replacements} replacement(s)`,
270
+ data: { path: hostPath, replacements: result.replacements },
235
271
  }
236
272
  })
237
273
  },
@@ -274,16 +310,35 @@ function normalizeEditInput(
274
310
  }
275
311
  }
276
312
 
313
+ /**
314
+ * Spellings of "the end of the file" a model reaches for.
315
+ *
316
+ * Liberal here and strict in the schema, which is the right way round: the
317
+ * schema makes `"end"` the only emittable string for a provider that
318
+ * constrains, and this catches the rest for one that does not. None of these
319
+ * is ambiguous — accepting them is not guessing at intent, it is declining to
320
+ * spend a round trip on a synonym.
321
+ */
322
+ const END_ALIASES = new Set(['end', 'eof', 'append', 'last', 'end_of_file', 'end-of-file'])
323
+
277
324
  function normalizeInsertLine(
278
325
  value: string | number,
279
326
  ): { success: true; value: number | 'end' } | { success: false; error: string } {
280
327
  if (typeof value === 'string') {
281
- if (value.trim().toLowerCase() === 'end') return { success: true, value: 'end' }
328
+ const normalized = value.trim().toLowerCase()
329
+ // `"end"` is the only spelling the model-facing schema admits, so a
330
+ // constrained decoder cannot produce anything else. These aliases are
331
+ // for the providers that do not constrain: a model reading "appends to
332
+ // the file" reaches for the word it knows, and every one of these says
333
+ // the same unambiguous thing. Refusing them bought strictness and cost
334
+ // a full model round trip per occurrence — measured by a consuming host
335
+ // as the single largest source of tool-call waste in its runs.
336
+ if (END_ALIASES.has(normalized)) return { success: true, value: 'end' }
282
337
  const parsed = Number(value)
283
338
  if (Number.isInteger(parsed) && parsed >= 0) return { success: true, value: parsed }
284
339
  return {
285
340
  success: false,
286
- error: 'insertLine must be a non-negative line number or "end".',
341
+ error: `insertLine must be a non-negative line number or "end" (also accepted: ${[...END_ALIASES].filter((a) => a !== 'end').join(', ')}). Received ${JSON.stringify(value)}.`,
287
342
  }
288
343
  }
289
344
  return { success: true, value }
@@ -1,8 +1,11 @@
1
1
  import { readdir, stat } from 'node:fs/promises'
2
2
  import { join, relative } from 'node:path'
3
3
  import { z } from 'zod'
4
+ import type { Sandbox } from '../../types/sandbox/index.js'
5
+ import type { ToolResult } from '../../types/tool/index.js'
4
6
  import { defineTool } from '../defineTool.js'
5
7
  import { resolveWithin } from '../paths.js'
8
+ import { joinPosix, relativePosix, resolveWithinPosix } from '../posix-path.js'
6
9
 
7
10
  const inputSchema = z.object({
8
11
  path: z.string().default('.').describe('Directory path to list. Defaults to working directory.'),
@@ -105,6 +108,101 @@ async function listRecursive(
105
108
  }
106
109
  }
107
110
 
111
+ /**
112
+ * Enumerate inside the sandbox.
113
+ *
114
+ * This tool read the HOST filesystem through `node:fs` and referenced
115
+ * `context.sandbox` nowhere, so with a container or microVM backend wired in
116
+ * it enumerated the host — in the one builtin whose entire job is telling the
117
+ * model what exists. `glob` carried the identical defect, was fixed, and its
118
+ * fix notes that "every sibling builtin already remembers this branch". This
119
+ * was the sibling that did not, which is why the claim needed checking rather
120
+ * than reading.
121
+ *
122
+ * Worse than a leak on its own: the paths it returned were host-relative,
123
+ * while `read`, `grep` and `glob` all resolve INSIDE the sandbox. So every
124
+ * ls-to-read handoff either failed or opened a different file than the one
125
+ * listed. This returns the sandbox-relative coordinates the others speak.
126
+ *
127
+ * `listFiles` reports files, not directories — every backend implements it as
128
+ * a recursive file walk — so directories are derived from the paths. A
129
+ * directory holding nothing is therefore invisible here, which is a real
130
+ * difference from the host branch and the honest cost of having one
131
+ * enumeration primitive rather than one per backend.
132
+ */
133
+ async function listInSandbox(
134
+ input: { path: string; all: boolean; recursive: boolean; max_depth: number },
135
+ sandbox: Sandbox,
136
+ ): Promise<ToolResult> {
137
+ const root = resolveWithinPosix(sandbox.rootDir, input.path)
138
+ const entries = await sandbox.listFiles(root)
139
+
140
+ // Relative to the LISTED directory, in the sandbox's own coordinates.
141
+ const relativePaths: { segments: string[]; size: number }[] = []
142
+ for (const entry of entries) {
143
+ const rel = relativePosix(root, joinPosix(root, entry.path))
144
+ if (!rel || rel.startsWith('..')) continue
145
+ const segments = rel.split('/').filter(Boolean)
146
+ if (segments.length === 0) continue
147
+ if (!input.all && segments.some((s) => s.startsWith('.'))) continue
148
+ relativePaths.push({ segments, size: entry.size })
149
+ }
150
+
151
+ if (!input.recursive) {
152
+ // One level: a single segment is a file, more than one means the
153
+ // first segment is a directory.
154
+ const files = new Map<string, number>()
155
+ const dirs = new Set<string>()
156
+ for (const { segments, size } of relativePaths) {
157
+ const head = segments[0] as string
158
+ if (segments.length === 1) files.set(head, size)
159
+ else dirs.add(head)
160
+ }
161
+
162
+ const lines = [
163
+ ...[...dirs].sort().map((name) => `${name}/`),
164
+ ...[...files.entries()]
165
+ .sort(([a], [b]) => a.localeCompare(b))
166
+ .map(([name, size]) => `${name}\t${formatSize(size)}`),
167
+ ]
168
+
169
+ return {
170
+ success: true,
171
+ output: lines.length > 0 ? lines.join('\n') : '(empty directory)',
172
+ data: { count: lines.length, sandboxed: true },
173
+ }
174
+ }
175
+
176
+ const seenDirs = new Set<string>()
177
+ const lines: string[] = []
178
+ let count = 0
179
+ for (const { segments, size } of relativePaths.sort((a, b) =>
180
+ a.segments.join('/').localeCompare(b.segments.join('/')),
181
+ )) {
182
+ if (segments.length > input.max_depth) continue
183
+ // Emit each parent directory once, before anything inside it.
184
+ for (let depth = 1; depth < segments.length; depth++) {
185
+ const dir = segments.slice(0, depth).join('/')
186
+ if (seenDirs.has(dir)) continue
187
+ seenDirs.add(dir)
188
+ if (count >= MAX_ENTRIES) break
189
+ lines.push(`./${dir}/`)
190
+ count++
191
+ }
192
+ if (count >= MAX_ENTRIES) break
193
+ lines.push(`./${segments.join('/')} (${formatSize(size)})`)
194
+ count++
195
+ }
196
+
197
+ const truncated = count >= MAX_ENTRIES ? `\n(truncated at ${MAX_ENTRIES} entries)` : ''
198
+
199
+ return {
200
+ success: true,
201
+ output: lines.length > 0 ? lines.join('\n') + truncated : '(empty directory)',
202
+ data: { count, truncated: count >= MAX_ENTRIES, sandboxed: true },
203
+ }
204
+ }
205
+
108
206
  export const LsTool = defineTool({
109
207
  name: 'ls',
110
208
  description:
@@ -117,6 +215,10 @@ export const LsTool = defineTool({
117
215
  concurrencySafe: true,
118
216
 
119
217
  async execute(input, context) {
218
+ if (context.sandbox) {
219
+ return await listInSandbox(input, context.sandbox)
220
+ }
221
+
120
222
  // Contained, not merely resolved — see `resolveWithin`.
121
223
  const targetPath = resolveWithin(context.workingDirectory, input.path)
122
224
 
@@ -1,7 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises'
2
- import { extname, resolve } from 'node:path'
2
+ import { extname } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { defineTool } from '../defineTool.js'
5
+ import { resolveWithinReal } from '../paths.js'
5
6
 
6
7
  const inputSchema = z.object({
7
8
  path: z.string().describe('Path to the file to read (absolute or relative)'),
@@ -74,7 +75,7 @@ export const ReadFileTool = defineTool({
74
75
  }
75
76
  }
76
77
 
77
- const filePath = resolve(context.workingDirectory, input.path)
78
+ const filePath = await resolveWithinReal(context.workingDirectory, input.path)
78
79
  const buffer = await readFile(filePath)
79
80
  const binaryGuidance = describeStructuredBinaryRead(filePath, buffer)
80
81
  if (binaryGuidance) {
@@ -1,8 +1,9 @@
1
1
  import { access, mkdir } from 'node:fs/promises'
2
- import { dirname, resolve } from 'node:path'
2
+ import { dirname } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import type { ToolContext } from '../../types/tool/index.js'
5
5
  import { defineTool } from '../defineTool.js'
6
+ import { resolveWithinReal } from '../paths.js'
6
7
  import { atomicWriteFile } from './atomic-write-file.js'
7
8
  import { withFileMutationLock } from './file-mutation-lock.js'
8
9
 
@@ -11,6 +12,12 @@ const inputSchema = z
11
12
  path: z
12
13
  .string()
13
14
  .min(1)
15
+ // `.min(1)` alone admits `" "`, which resolves to the working
16
+ // directory itself and turns a write into a directory-write error
17
+ // nobody can read. `edit` has refused this since it was written;
18
+ // the two tools disagreeing on the same input is the kind of gap a
19
+ // model finds and a reviewer does not.
20
+ .refine((value) => value.trim().length > 0, 'Path must not be empty.')
14
21
  .describe(
15
22
  'Relative path to the file to write (e.g. "outputs/report.md"). Required. Must be a non-empty string.',
16
23
  ),
@@ -87,11 +94,17 @@ export const WriteFileTool = defineTool({
87
94
  }
88
95
  const valid = parsed.data
89
96
  const content = valid.content ?? valid.newStr ?? ''
90
- const filePath = resolve(context.workingDirectory, valid.path)
97
+ // Host-side containment, on the host branch only. The sandbox has its
98
+ // own root and its own resolver; canonicalizing a sandbox-relative
99
+ // path against the HOST filesystem asks a question about the wrong
100
+ // machine and answers it with whatever happens to exist there.
101
+ const filePath = context.sandbox
102
+ ? undefined
103
+ : await resolveWithinReal(context.workingDirectory, valid.path)
91
104
  // The exists-check and the write are a check-then-act pair. Unlocked,
92
105
  // two writers both see "absent", both skip the read-before-overwrite
93
106
  // guard, and the second silently discards the first.
94
- const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`
107
+ const lockKey = context.sandbox ? `sandbox:${valid.path}` : `local:${filePath as string}`
95
108
 
96
109
  return withFileMutationLock(lockKey, async () => {
97
110
  if (context.sandbox) {
@@ -109,23 +122,24 @@ export const WriteFileTool = defineTool({
109
122
  }
110
123
  }
111
124
 
112
- const localExists = await pathExists(filePath)
125
+ const hostPath = filePath as string
126
+ const localExists = await pathExists(hostPath)
113
127
  if (localExists) {
114
- const guard = enforceReadBeforeOverwrite(context, filePath)
128
+ const guard = enforceReadBeforeOverwrite(context, hostPath)
115
129
  if (guard) return guard
116
130
  }
117
131
 
118
- await mkdir(dirname(filePath), { recursive: true })
132
+ await mkdir(dirname(hostPath), { recursive: true })
119
133
  // Temp file, fsync, rename. A plain write that fails partway
120
134
  // leaves the destination truncated — and this tool overwrites a
121
135
  // whole file, so the truncation is the user's previous work.
122
- await atomicWriteFile(filePath, content)
123
- context.fileReadTracker?.recordRead(filePath)
136
+ await atomicWriteFile(hostPath, content)
137
+ context.fileReadTracker?.recordRead(hostPath)
124
138
 
125
139
  return {
126
140
  success: true as const,
127
- output: `File written successfully: ${filePath} (${content.length} chars)`,
128
- data: { path: filePath, size: content.length },
141
+ output: `File written successfully: ${hostPath} (${content.length} chars)`,
142
+ data: { path: hostPath, size: content.length },
129
143
  }
130
144
  })
131
145
  },
@@ -144,9 +158,9 @@ function enforceReadBeforeOverwrite(
144
158
  }
145
159
  }
146
160
 
147
- async function pathExists(filePath: string): Promise<boolean> {
161
+ async function pathExists(hostPath: string): Promise<boolean> {
148
162
  try {
149
- await access(filePath)
163
+ await access(hostPath)
150
164
  return true
151
165
  } catch {
152
166
  return false
@@ -90,7 +90,12 @@ describe('buildAgentTool', () => {
90
90
  )
91
91
 
92
92
  expect(result.success).toBe(true)
93
- expect(result.output).toBe('final report text')
93
+ // The subagent's text is present and unaltered, inside a frame that
94
+ // says whose text it is. A host reading the result programmatically
95
+ // gets it verbatim off `data.result` instead.
96
+ expect(result.output).toContain('final report text')
97
+ expect(result.output.startsWith('<namzu-untrusted kind="agent-result"')).toBe(true)
98
+ expect(result.data).toMatchObject({ result: 'final report text' })
94
99
  })
95
100
 
96
101
  it('reports failure when run status is failed even though task state is completed', async () => {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
3
4
  import type { TaskGateway } from '../../../types/agent/gateway.js'
4
5
  import type {
5
6
  HITLDecisionRequest,
@@ -175,6 +176,63 @@ describe('coordinator ask_user_question input schema', () => {
175
176
  })
176
177
  })
177
178
 
179
+ /**
180
+ * The model-facing schema is a module-level object shared by every tool this
181
+ * builder produces, so it is copied on the way out — TWICE, at two independent
182
+ * boundaries, and each test below pins exactly one of them. The builder clones
183
+ * when it attaches the schema to the definition; the registry clones again
184
+ * when it renders a definition for the wire. Removing either clone fails one
185
+ * of these and not the other, which is how they were confirmed non-vacuous.
186
+ *
187
+ * Both clones survived a version in which neither test did: the defences were
188
+ * still in the source and nothing pinned them, which is precisely the state
189
+ * where a later edit drops one and no gate objects.
190
+ *
191
+ * The failure they prevent is not hypothetical. A caller that mutates a schema
192
+ * it received — normalizing it for one provider, adding a legacy alias — would
193
+ * otherwise be editing the object every OTHER tool instance in the process is
194
+ * also handing out, including definitions already registered in another run.
195
+ */
196
+ describe('coordinator ask_user_question canonical schema isolation', () => {
197
+ const noopHandler: ResumeHandler = async () => ({ action: 'continue' })
198
+
199
+ it('returns a fresh schema on every render from the registry boundary', () => {
200
+ const registry = new ToolRegistry()
201
+ registry.register(askTool(noopHandler))
202
+
203
+ const first = registry.toLLMTools()[0]?.function.parameters
204
+ expect(first).toEqual(askTool(noopHandler).modelInputSchema)
205
+ const firstProperties = first?.properties as Record<string, unknown>
206
+ firstProperties.legacy_options = { type: 'string' }
207
+
208
+ const next = registry.toLLMTools()[0]?.function.parameters
209
+ expect(JSON.stringify(next)).not.toContain('legacy_options')
210
+ expect(next).toEqual(askTool(noopHandler).modelInputSchema)
211
+ })
212
+
213
+ it('isolates the schema between two results of the public builder', () => {
214
+ const first = askTool(noopHandler)
215
+ const second = askTool(noopHandler)
216
+ expect(first.modelInputSchema).not.toBe(second.modelInputSchema)
217
+
218
+ const firstProperties = first.modelInputSchema?.properties as Record<string, unknown>
219
+ firstProperties.legacy_options = { type: 'string' }
220
+
221
+ expect(JSON.stringify(second.modelInputSchema)).not.toContain('legacy_options')
222
+ })
223
+
224
+ it('rejects an unknown property inside an option, not only at the root', () => {
225
+ const tool = askTool(noopHandler)
226
+
227
+ expect(
228
+ tool.inputSchema.safeParse({
229
+ ...baseInput,
230
+ options: [{ label: 'A', weight: 3 }, { label: 'B' }],
231
+ }).success,
232
+ ).toBe(false)
233
+ })
234
+ })
235
+
178
236
  describe('coordinator ask_user_question request synthesis', () => {
179
237
  it('synthesizes the park request from its own context — questionId = toolUseId', async () => {
180
238
  const { requests } = await executeAsk({
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { TaskGateway } from '../../../types/agent/gateway.js'
3
+ import { buildAgentTool } from '../agent.js'
4
+ import { buildCoordinatorTools } from '../index.js'
5
+
6
+ /**
7
+ * `create_task` used to widen its `agent_id` parameter from the roster enum to
8
+ * a bare string whenever the roster was empty — so the one configuration that
9
+ * says "this run may delegate to nobody" was the one that let the model name
10
+ * anybody. Degrading a closed list to an open one because the list is empty is
11
+ * failing open (CWE-636), and Saltzer & Schroeder named the rule it breaks in
12
+ * 1975: fail-safe defaults, §I.A.3(b).
13
+ *
14
+ * The control is that the tool is not mounted at all — refusing per call would
15
+ * reach the same verdict while paying prompt tokens and an iteration for it.
16
+ * The schema stays closed underneath as defence-in-depth for a definition
17
+ * built directly.
18
+ *
19
+ * What was reachable before is worth stating, because it is why this is a
20
+ * break worth taking: the id went to the gateway, which resolves against an
21
+ * `AgentManager` that is typically SHARED — so a name the host deliberately
22
+ * left out of `agentIds` could still launch if it happened to be registered
23
+ * there.
24
+ */
25
+
26
+ const gateway = {
27
+ dispatch: async () => {
28
+ throw new Error('gateway must not be reached — the schema refuses first')
29
+ },
30
+ listTasks: () => [],
31
+ cancel: () => undefined,
32
+ } as unknown as TaskGateway
33
+
34
+ function toolsFor(allowedAgentIds: string[], resumeHandler?: unknown) {
35
+ return buildCoordinatorTools({
36
+ gateway,
37
+ workingDirectory: '/tmp/test',
38
+ allowedAgentIds,
39
+ ...(resumeHandler ? { resumeHandler: resumeHandler as never, runId: 'run_1' as never } : {}),
40
+ })
41
+ }
42
+
43
+ function createTaskFor(allowedAgentIds: string[]) {
44
+ const tool = toolsFor(allowedAgentIds).find((t) => t.name === 'create_task')
45
+ if (!tool) throw new Error('create_task missing from coordinator builder')
46
+ return tool
47
+ }
48
+
49
+ describe('create_task delegate roster', () => {
50
+ it('does not mount create_task at all when the roster is empty', () => {
51
+ const names = toolsFor([]).map((t) => t.name)
52
+
53
+ expect(names).not.toContain('create_task')
54
+ })
55
+
56
+ it('still mounts the coordinator tools that do not read the roster', () => {
57
+ // "No delegates, but still planning and a human channel" is a
58
+ // supported configuration, so this omits one tool rather than
59
+ // refusing to build.
60
+ const names = toolsFor([], async () => ({ action: 'approve_tools' })).map((t) => t.name)
61
+
62
+ expect(names).toContain('agent_task_list')
63
+ expect(names).toContain('ask_user_question')
64
+ })
65
+
66
+ it('mounts create_task once the roster has an entry', () => {
67
+ expect(toolsFor(['worker']).map((t) => t.name)).toContain('create_task')
68
+ })
69
+
70
+ it('still admits an id that is on a non-empty roster', () => {
71
+ const parsed = createTaskFor(['worker']).inputSchema.safeParse({
72
+ agent_id: 'worker',
73
+ prompt: 'do the thing',
74
+ description: 'a task',
75
+ })
76
+
77
+ expect(parsed.success).toBe(true)
78
+ })
79
+
80
+ it('still refuses an id that is off a non-empty roster', () => {
81
+ const parsed = createTaskFor(['worker']).inputSchema.safeParse({
82
+ agent_id: 'some-other-agent',
83
+ prompt: 'do the thing',
84
+ description: 'a task',
85
+ })
86
+
87
+ expect(parsed.success).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('the Agent tool carries the same closed roster', () => {
92
+ it('refuses to build at all with no delegates', () => {
93
+ // Unlike the coordinator builder, this one returns exactly one tool
94
+ // and that tool IS the delegation surface, so "do not mount" and "do
95
+ // not build" are the same statement.
96
+ expect(() =>
97
+ buildAgentTool({ gateway, workingDirectory: '/tmp/test', allowedAgentIds: [] }),
98
+ ).toThrow(/at least one entry in allowedAgentIds/)
99
+ })
100
+
101
+ it('refuses an off-roster subagent at execution, not only in the schema', async () => {
102
+ // `execute` is reachable without the registry, so a schema-only check
103
+ // leaves the roster unenforced on that path.
104
+ const tool = buildAgentTool({
105
+ gateway,
106
+ workingDirectory: '/tmp/test',
107
+ allowedAgentIds: ['worker'],
108
+ })
109
+
110
+ const result = await tool.execute(
111
+ { description: 'x', prompt: 'y', subagent_type: 'not-on-the-roster' },
112
+ { workingDirectory: '/tmp/test' } as never,
113
+ )
114
+
115
+ expect(result.success).toBe(false)
116
+ expect(result.error).toMatch(/Unknown subagent_type/)
117
+ })
118
+ })