@namzu/sdk 3.2.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 (188) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +58 -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/agents/__tests__/supervisor-hands-down-hitl.test.d.ts +2 -0
  10. package/dist/agents/__tests__/supervisor-hands-down-hitl.test.d.ts.map +1 -0
  11. package/dist/agents/__tests__/supervisor-hands-down-hitl.test.js +112 -0
  12. package/dist/agents/__tests__/supervisor-hands-down-hitl.test.js.map +1 -0
  13. package/dist/connector/index.d.ts +1 -0
  14. package/dist/connector/index.d.ts.map +1 -1
  15. package/dist/connector/index.js +1 -0
  16. package/dist/connector/index.js.map +1 -1
  17. package/dist/connector/mcp/__tests__/prompt-as-tool.test.d.ts +2 -0
  18. package/dist/connector/mcp/__tests__/prompt-as-tool.test.d.ts.map +1 -0
  19. package/dist/connector/mcp/__tests__/prompt-as-tool.test.js +122 -0
  20. package/dist/connector/mcp/__tests__/prompt-as-tool.test.js.map +1 -0
  21. package/dist/connector/mcp/discovery.d.ts +15 -1
  22. package/dist/connector/mcp/discovery.d.ts.map +1 -1
  23. package/dist/connector/mcp/discovery.js +39 -1
  24. package/dist/connector/mcp/discovery.js.map +1 -1
  25. package/dist/connector/mcp/index.d.ts +2 -1
  26. package/dist/connector/mcp/index.d.ts.map +1 -1
  27. package/dist/connector/mcp/index.js +2 -1
  28. package/dist/connector/mcp/index.js.map +1 -1
  29. package/dist/connector/mcp/policy.d.ts +17 -0
  30. package/dist/connector/mcp/policy.d.ts.map +1 -1
  31. package/dist/connector/mcp/policy.js +18 -7
  32. package/dist/connector/mcp/policy.js.map +1 -1
  33. package/dist/connector/mcp/prompt-adapter.d.ts +39 -0
  34. package/dist/connector/mcp/prompt-adapter.d.ts.map +1 -0
  35. package/dist/connector/mcp/prompt-adapter.js +120 -0
  36. package/dist/connector/mcp/prompt-adapter.js.map +1 -0
  37. package/dist/manager/agent/__tests__/hitl-across-spawn.test.d.ts +2 -0
  38. package/dist/manager/agent/__tests__/hitl-across-spawn.test.d.ts.map +1 -0
  39. package/dist/manager/agent/__tests__/hitl-across-spawn.test.js +226 -0
  40. package/dist/manager/agent/__tests__/hitl-across-spawn.test.js.map +1 -0
  41. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  42. package/dist/manager/agent/lifecycle.js +13 -0
  43. package/dist/manager/agent/lifecycle.js.map +1 -1
  44. package/dist/plugin/__tests__/mcp-admission.test.js +37 -2
  45. package/dist/plugin/__tests__/mcp-admission.test.js.map +1 -1
  46. package/dist/plugin/lifecycle.d.ts.map +1 -1
  47. package/dist/plugin/lifecycle.js +10 -0
  48. package/dist/plugin/lifecycle.js.map +1 -1
  49. package/dist/public-runtime.d.ts +2 -2
  50. package/dist/public-runtime.d.ts.map +1 -1
  51. package/dist/public-runtime.js +2 -2
  52. package/dist/public-runtime.js.map +1 -1
  53. package/dist/public-tools.d.ts +2 -0
  54. package/dist/public-tools.d.ts.map +1 -1
  55. package/dist/public-tools.js +3 -0
  56. package/dist/public-tools.js.map +1 -1
  57. package/dist/registry/index.d.ts +1 -1
  58. package/dist/registry/index.d.ts.map +1 -1
  59. package/dist/registry/index.js +1 -1
  60. package/dist/registry/index.js.map +1 -1
  61. package/dist/registry/tool/execute.d.ts +15 -0
  62. package/dist/registry/tool/execute.d.ts.map +1 -1
  63. package/dist/registry/tool/execute.js +19 -0
  64. package/dist/registry/tool/execute.js.map +1 -1
  65. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  66. package/dist/runtime/query/iteration/index.js +1 -46
  67. package/dist/runtime/query/iteration/index.js.map +1 -1
  68. package/dist/sandbox/__tests__/exec-cancellation.test.d.ts +2 -0
  69. package/dist/sandbox/__tests__/exec-cancellation.test.d.ts.map +1 -0
  70. package/dist/sandbox/__tests__/exec-cancellation.test.js +59 -0
  71. package/dist/sandbox/__tests__/exec-cancellation.test.js.map +1 -0
  72. package/dist/sandbox/provider/local.d.ts.map +1 -1
  73. package/dist/sandbox/provider/local.js +30 -5
  74. package/dist/sandbox/provider/local.js.map +1 -1
  75. package/dist/session/workspace/shared-run.d.ts +31 -0
  76. package/dist/session/workspace/shared-run.d.ts.map +1 -1
  77. package/dist/session/workspace/shared-run.js +31 -0
  78. package/dist/session/workspace/shared-run.js.map +1 -1
  79. package/dist/telemetry/__tests__/model-call-span.test.js +13 -8
  80. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
  81. package/dist/tools/__tests__/file-tool-containment.test.d.ts +2 -0
  82. package/dist/tools/__tests__/file-tool-containment.test.d.ts.map +1 -0
  83. package/dist/tools/__tests__/file-tool-containment.test.js +134 -0
  84. package/dist/tools/__tests__/file-tool-containment.test.js.map +1 -0
  85. package/dist/tools/__tests__/sandboxed-search.test.js +51 -0
  86. package/dist/tools/__tests__/sandboxed-search.test.js.map +1 -1
  87. package/dist/tools/__tests__/untrusted-envelope.test.d.ts +2 -0
  88. package/dist/tools/__tests__/untrusted-envelope.test.d.ts.map +1 -0
  89. package/dist/tools/__tests__/untrusted-envelope.test.js +55 -0
  90. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -0
  91. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts +2 -0
  92. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts.map +1 -0
  93. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +114 -0
  94. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -0
  95. package/dist/tools/builtins/__tests__/edit.test.js +9 -2
  96. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  97. package/dist/tools/builtins/__tests__/write-file.test.js +59 -0
  98. package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
  99. package/dist/tools/builtins/edit.d.ts.map +1 -1
  100. package/dist/tools/builtins/edit.js +66 -14
  101. package/dist/tools/builtins/edit.js.map +1 -1
  102. package/dist/tools/builtins/ls.d.ts +1 -1
  103. package/dist/tools/builtins/ls.d.ts.map +1 -1
  104. package/dist/tools/builtins/ls.js +95 -0
  105. package/dist/tools/builtins/ls.js.map +1 -1
  106. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  107. package/dist/tools/builtins/read-file.js +3 -2
  108. package/dist/tools/builtins/read-file.js.map +1 -1
  109. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  110. package/dist/tools/builtins/write-file.js +26 -12
  111. package/dist/tools/builtins/write-file.js.map +1 -1
  112. package/dist/tools/coordinator/__tests__/agent.test.js +6 -1
  113. package/dist/tools/coordinator/__tests__/agent.test.js.map +1 -1
  114. package/dist/tools/coordinator/__tests__/ask-user-question.test.js +47 -0
  115. package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
  116. package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts +2 -0
  117. package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts.map +1 -0
  118. package/dist/tools/coordinator/__tests__/empty-roster.test.js +97 -0
  119. package/dist/tools/coordinator/__tests__/empty-roster.test.js.map +1 -0
  120. package/dist/tools/coordinator/agent.d.ts +22 -8
  121. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  122. package/dist/tools/coordinator/agent.js +45 -2
  123. package/dist/tools/coordinator/agent.js.map +1 -1
  124. package/dist/tools/coordinator/index.d.ts.map +1 -1
  125. package/dist/tools/coordinator/index.js +80 -3
  126. package/dist/tools/coordinator/index.js.map +1 -1
  127. package/dist/tools/paths.d.ts +40 -0
  128. package/dist/tools/paths.d.ts.map +1 -1
  129. package/dist/tools/paths.js +70 -1
  130. package/dist/tools/paths.js.map +1 -1
  131. package/dist/tools/untrusted-envelope.d.ts +55 -0
  132. package/dist/tools/untrusted-envelope.d.ts.map +1 -0
  133. package/dist/tools/untrusted-envelope.js +65 -0
  134. package/dist/tools/untrusted-envelope.js.map +1 -0
  135. package/dist/types/agent/base.d.ts +26 -0
  136. package/dist/types/agent/base.d.ts.map +1 -1
  137. package/dist/types/agent/task.d.ts +17 -0
  138. package/dist/types/agent/task.d.ts.map +1 -1
  139. package/dist/types/agent/task.js.map +1 -1
  140. package/dist/types/sandbox/index.d.ts +10 -0
  141. package/dist/types/sandbox/index.d.ts.map +1 -1
  142. package/dist/types/sandbox/index.js.map +1 -1
  143. package/dist/types/toolset/index.d.ts +0 -24
  144. package/dist/types/toolset/index.d.ts.map +1 -1
  145. package/package.json +1 -1
  146. package/src/agents/SupervisorAgent.ts +57 -2
  147. package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +148 -0
  148. package/src/agents/__tests__/supervisor-hands-down-hitl.test.ts +131 -0
  149. package/src/connector/index.ts +1 -0
  150. package/src/connector/mcp/__tests__/prompt-as-tool.test.ts +157 -0
  151. package/src/connector/mcp/discovery.ts +48 -1
  152. package/src/connector/mcp/index.ts +2 -1
  153. package/src/connector/mcp/policy.ts +23 -8
  154. package/src/connector/mcp/prompt-adapter.ts +149 -0
  155. package/src/manager/agent/__tests__/hitl-across-spawn.test.ts +273 -0
  156. package/src/manager/agent/lifecycle.ts +12 -0
  157. package/src/plugin/__tests__/mcp-admission.test.ts +46 -2
  158. package/src/plugin/lifecycle.ts +11 -0
  159. package/src/public-runtime.ts +3 -0
  160. package/src/public-tools.ts +4 -0
  161. package/src/registry/index.ts +1 -1
  162. package/src/registry/tool/execute.ts +23 -0
  163. package/src/runtime/query/iteration/index.ts +0 -48
  164. package/src/sandbox/__tests__/exec-cancellation.test.ts +72 -0
  165. package/src/sandbox/provider/local.ts +30 -4
  166. package/src/session/workspace/shared-run.ts +31 -0
  167. package/src/telemetry/__tests__/model-call-span.test.ts +13 -7
  168. package/src/tools/__tests__/file-tool-containment.test.ts +190 -0
  169. package/src/tools/__tests__/sandboxed-search.test.ts +66 -0
  170. package/src/tools/__tests__/untrusted-envelope.test.ts +82 -0
  171. package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +166 -0
  172. package/src/tools/builtins/__tests__/edit.test.ts +10 -2
  173. package/src/tools/builtins/__tests__/write-file.test.ts +80 -0
  174. package/src/tools/builtins/edit.ts +69 -14
  175. package/src/tools/builtins/ls.ts +102 -0
  176. package/src/tools/builtins/read-file.ts +3 -2
  177. package/src/tools/builtins/write-file.ts +26 -12
  178. package/src/tools/coordinator/__tests__/agent.test.ts +6 -1
  179. package/src/tools/coordinator/__tests__/ask-user-question.test.ts +58 -0
  180. package/src/tools/coordinator/__tests__/empty-roster.test.ts +118 -0
  181. package/src/tools/coordinator/agent.ts +72 -11
  182. package/src/tools/coordinator/index.ts +86 -3
  183. package/src/tools/paths.ts +83 -1
  184. package/src/tools/untrusted-envelope.ts +78 -0
  185. package/src/types/agent/base.ts +27 -0
  186. package/src/types/agent/task.ts +18 -0
  187. package/src/types/sandbox/index.ts +10 -0
  188. package/src/types/toolset/index.ts +0 -25
@@ -0,0 +1,166 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import type { ToolContext } from '../../../types/tool/index.js'
7
+ import { EditTool } from '../edit.js'
8
+ import { WriteFileTool } from '../write-file.js'
9
+
10
+ /**
11
+ * The tool's description ordered an idiom its own schema forbade.
12
+ *
13
+ * `edit`'s description says "For insertions, pass insertLine … use
14
+ * insertLine: 'end' to extend a file at the end". `modelInputSchema` listed
15
+ * only path/old_string/new_string/replace_all with
16
+ * `additionalProperties: false`, and `enforceModelInput: true` — so under
17
+ * constrained decoding the append idiom the prompt recommends was the one
18
+ * idiom a model could not emit.
19
+ *
20
+ * A consuming host measured the consequence over 7 days on one tenant: 94 of
21
+ * 159 tool failures were `edit` rejecting an `insertLine` the model had
22
+ * guessed a spelling for. That number is theirs and not reproducible here;
23
+ * the contradiction below is reproducible and is what these pin.
24
+ */
25
+
26
+ let dirs: string[] = []
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
30
+ dirs = []
31
+ })
32
+
33
+ async function workdir(): Promise<string> {
34
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-edit-insert-'))
35
+ dirs.push(dir)
36
+ return dir
37
+ }
38
+
39
+ const ctx = (workingDirectory: string): ToolContext =>
40
+ ({
41
+ runId: 'run_e',
42
+ workingDirectory,
43
+ abortSignal: new AbortController().signal,
44
+ env: {},
45
+ log: () => undefined,
46
+ }) as unknown as ToolContext
47
+
48
+ const schema = () =>
49
+ (EditTool as unknown as { modelInputSchema: Record<string, unknown> }).modelInputSchema
50
+
51
+ describe('the model can emit the idiom the description recommends', () => {
52
+ it('advertises insertLine at all', () => {
53
+ const props = schema().properties as Record<string, unknown>
54
+
55
+ expect(props.insertLine).toBeDefined()
56
+ })
57
+
58
+ it('keeps the closed shape, so an invented field is still refused', () => {
59
+ expect(schema().additionalProperties).toBe(false)
60
+ })
61
+
62
+ it('does not require old_string, which an insert cannot supply', () => {
63
+ // Requiring it is what made the append idiom unexpressible: an insert
64
+ // has no text to match.
65
+ expect(schema().required).not.toContain('old_string')
66
+ })
67
+
68
+ it('still requires the two fields every operation needs', () => {
69
+ expect(schema().required).toEqual(expect.arrayContaining(['path', 'new_string']))
70
+ })
71
+
72
+ it('admits only "end" as a string, so a synonym cannot be generated', () => {
73
+ const insert = (schema().properties as Record<string, { oneOf?: unknown[] }>).insertLine
74
+
75
+ // The schema is where the synonym problem is solved for a provider that
76
+ // constrains: `"EOF"` is not emittable because `"end"` is the only
77
+ // string the union admits.
78
+ expect(insert?.oneOf).toEqual([{ type: 'integer', minimum: 0 }, { const: 'end' }])
79
+ })
80
+ })
81
+
82
+ describe('an insert actually works end to end', () => {
83
+ it('appends with insertLine "end"', async () => {
84
+ const dir = await workdir()
85
+ await writeFile(join(dir, 'a.md'), 'first\n', 'utf-8')
86
+
87
+ const result = await EditTool.execute(
88
+ { path: 'a.md', insertLine: 'end', new_string: 'second', replace_all: false },
89
+ ctx(dir),
90
+ )
91
+
92
+ expect(result.success).toBe(true)
93
+ expect(await readFile(join(dir, 'a.md'), 'utf-8')).toContain('second')
94
+ })
95
+
96
+ it('inserts after a numbered line', async () => {
97
+ const dir = await workdir()
98
+ await writeFile(join(dir, 'a.md'), 'one\ntwo\n', 'utf-8')
99
+
100
+ await EditTool.execute(
101
+ { path: 'a.md', insertLine: 1, new_string: 'inserted', replace_all: false },
102
+ ctx(dir),
103
+ )
104
+
105
+ const lines = (await readFile(join(dir, 'a.md'), 'utf-8')).split('\n')
106
+ expect(lines[1]).toBe('inserted')
107
+ })
108
+ })
109
+
110
+ describe('a synonym for the end of the file is accepted, not charged for', () => {
111
+ it.each(['EOF', 'append', 'last', 'End_Of_File'])('accepts %s', async (spelling) => {
112
+ const dir = await workdir()
113
+ await writeFile(join(dir, 'a.md'), 'first\n', 'utf-8')
114
+
115
+ const result = await EditTool.execute(
116
+ { path: 'a.md', insertLine: spelling, new_string: 'second', replace_all: false },
117
+ ctx(dir),
118
+ )
119
+
120
+ // Liberal here and strict in the schema, which is the right way round.
121
+ // None of these is ambiguous; refusing one costs a full model round
122
+ // trip to be told a synonym.
123
+ expect(result.success).toBe(true)
124
+ })
125
+
126
+ it('still refuses something that is not a line and not the end', async () => {
127
+ const dir = await workdir()
128
+ await writeFile(join(dir, 'a.md'), 'first\n', 'utf-8')
129
+
130
+ const result = await EditTool.execute(
131
+ { path: 'a.md', insertLine: 'somewhere in the middle', new_string: 'x', replace_all: false },
132
+ ctx(dir),
133
+ )
134
+
135
+ expect(result.success).toBe(false)
136
+ // The rejection names what was received, so the retry is informed.
137
+ expect(result.error).toContain('somewhere in the middle')
138
+ })
139
+
140
+ it('still refuses a negative line', async () => {
141
+ const dir = await workdir()
142
+ await writeFile(join(dir, 'a.md'), 'first\n', 'utf-8')
143
+
144
+ const result = await EditTool.execute(
145
+ { path: 'a.md', insertLine: -3, new_string: 'x', replace_all: false },
146
+ ctx(dir),
147
+ )
148
+
149
+ expect(result.success).toBe(false)
150
+ })
151
+ })
152
+
153
+ describe('the two mutating tools agree on what a path is', () => {
154
+ it('write refuses a whitespace-only path, as edit already did', async () => {
155
+ const dir = await workdir()
156
+
157
+ const w = await WriteFileTool.execute({ path: ' ', content: 'x' }, ctx(dir))
158
+ const e = await EditTool.execute(
159
+ { path: ' ', old_string: 'a', new_string: 'b', replace_all: false },
160
+ ctx(dir),
161
+ )
162
+
163
+ expect(w.success).toBe(false)
164
+ expect(e.success).toBe(false)
165
+ })
166
+ })
@@ -19,7 +19,7 @@ function makeContext(workingDirectory: string): ToolContext {
19
19
  }
20
20
 
21
21
  describe('EditTool', () => {
22
- it('publishes one closed canonical replacement contract', () => {
22
+ it('publishes one closed contract covering both operations', () => {
23
23
  const schema = EditTool.modelInputSchema
24
24
  expect(schema).toEqual({
25
25
  type: 'object',
@@ -38,12 +38,20 @@ describe('EditTool', () => {
38
38
  description:
39
39
  'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
40
40
  },
41
+ insertLine: {
42
+ oneOf: [{ type: 'integer', minimum: 0 }, { const: 'end' }],
43
+ description:
44
+ '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.',
45
+ },
41
46
  replace_all: {
42
47
  type: 'boolean',
43
48
  description: 'Replace every occurrence instead of requiring one unique match.',
44
49
  },
45
50
  },
46
- required: ['path', 'old_string', 'new_string'],
51
+ // left out on purpose: an insert has no text to match,
52
+ // and requiring it is what made the append idiom the tool's own
53
+ // description recommends unexpressible under constrained decoding.
54
+ required: ['path', 'new_string'],
47
55
  additionalProperties: false,
48
56
  })
49
57
 
@@ -27,6 +27,86 @@ function makeContext(workingDirectory: string, tracker?: FileReadTracker): ToolC
27
27
  }
28
28
  }
29
29
 
30
+ /**
31
+ * The published contract, pinned exactly.
32
+ *
33
+ * This was dropped when the tool gained `newStr`, and dropping it is what let
34
+ * the two contracts drift apart silently. They are deliberately NOT the same
35
+ * contract, and that is the thing worth recording: a host driving `execute`
36
+ * directly may pass `newStr`, and a model may not — because a model given two
37
+ * names for the body has to choose, and choosing is what produces the
38
+ * half-filled calls the closed model schema exists to prevent. Nothing said
39
+ * that out loud once the test asserting it was rewritten to assert the
40
+ * opposite.
41
+ */
42
+ describe('WriteFileTool — the published contract', () => {
43
+ it('publishes one closed path + content shape to the model', () => {
44
+ expect(WriteFileTool.modelInputSchema).toEqual({
45
+ type: 'object',
46
+ properties: {
47
+ path: {
48
+ type: 'string',
49
+ description: 'Relative path to the file to write. Must not be empty.',
50
+ },
51
+ content: {
52
+ type: 'string',
53
+ description:
54
+ 'Complete file body. Use "" only for an intentionally empty file. Keep under 12000 characters.',
55
+ },
56
+ },
57
+ required: ['path', 'content'],
58
+ additionalProperties: false,
59
+ })
60
+ expect(WriteFileTool.enforceModelInput).toBe(true)
61
+ })
62
+
63
+ it('keeps newStr off the model surface while accepting it from a host', () => {
64
+ const modelProperties = WriteFileTool.modelInputSchema?.properties as Record<string, unknown>
65
+ expect(Object.keys(modelProperties)).not.toContain('newStr')
66
+
67
+ // The execution schema is the wider one, on purpose.
68
+ expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md', newStr: 'body' }).success).toBe(
69
+ true,
70
+ )
71
+ })
72
+
73
+ it('requires a body under one name or the other', () => {
74
+ expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md' }).success).toBe(false)
75
+ // An empty string is a body — an intentionally empty file is legal.
76
+ expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md', content: '' }).success).toBe(true)
77
+ })
78
+
79
+ it('refuses a whitespace-only path but not a path with edge whitespace', async () => {
80
+ expect(WriteFileTool.inputSchema.safeParse({ path: ' ', content: 'body' }).success).toBe(
81
+ false,
82
+ )
83
+
84
+ // The refusal above must not over-reach: ' fresh.txt ' is a legal name
85
+ // on every filesystem this runs on, and trimming it would write to a
86
+ // different file than the caller asked for.
87
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
88
+ const result = await WriteFileTool.execute(
89
+ { path: ' fresh.txt ', content: 'hello' },
90
+ makeContext(dir),
91
+ )
92
+
93
+ expect(result.success).toBe(true)
94
+ expect(readFileSync(join(dir, ' fresh.txt '), 'utf-8')).toBe('hello')
95
+ })
96
+
97
+ it('rejects an undeclared field even when execute is called directly', async () => {
98
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
99
+
100
+ const result = await WriteFileTool.execute(
101
+ { path: 'fresh.txt', content: 'hello', append: true } as never,
102
+ makeContext(dir),
103
+ )
104
+
105
+ expect(result.success).toBe(false)
106
+ expect(result.error).toContain('Invalid write input')
107
+ })
108
+ })
109
+
30
110
  describe('WriteFileTool — read-before-overwrite invariant', () => {
31
111
  it('writes a new file without requiring a prior read', async () => {
32
112
  const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
@@ -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