vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,579 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { tmpdir } from 'node:os';
14
+
15
+ import { FileSystemOps, type PathPolicy } from '../tools/shared/filesystem/file-ops-service.js';
16
+ import { sandboxPolicy } from '../tools/shared/filesystem/path-policy.js';
17
+ import { formatEditDiff, formatWriteSummary } from '../tools/shared/filesystem/format-diff.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const testDirs: string[] = [];
24
+
25
+ function makeTempDir(): string {
26
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'fs-tools-test-')));
27
+ testDirs.push(dir);
28
+ return dir;
29
+ }
30
+
31
+ afterEach(() => {
32
+ for (const dir of testDirs.splice(0)) {
33
+ rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ function sandboxPolicyFor(boundary: string): PathPolicy {
38
+ return (rawPath, options) => sandboxPolicy(rawPath, boundary, options);
39
+ }
40
+
41
+ // ===========================================================================
42
+ // FileSystemOps: symlink handling through read/write/edit
43
+ // ===========================================================================
44
+
45
+ describe('FileSystemOps symlink handling', () => {
46
+ test('read blocks symlink pointing outside boundary', () => {
47
+ const boundary = makeTempDir();
48
+ const outside = makeTempDir();
49
+ const outsideFile = join(outside, 'secret.txt');
50
+ writeFileSync(outsideFile, 'secret data');
51
+
52
+ symlinkSync(outsideFile, join(boundary, 'link.txt'));
53
+ const ops = new FileSystemOps(sandboxPolicyFor(boundary));
54
+
55
+ const result = ops.readFileSafe({ path: 'link.txt' });
56
+ expect(result.ok).toBe(false);
57
+ if (result.ok) return;
58
+ expect(result.error.code).toBe('PATH_OUT_OF_BOUNDS');
59
+ });
60
+
61
+ test('read allows symlink within boundary', () => {
62
+ const boundary = makeTempDir();
63
+ const realFile = join(boundary, 'real.txt');
64
+ writeFileSync(realFile, 'hello');
65
+ symlinkSync(realFile, join(boundary, 'link.txt'));
66
+
67
+ const ops = new FileSystemOps(sandboxPolicyFor(boundary));
68
+ const result = ops.readFileSafe({ path: 'link.txt' });
69
+ expect(result.ok).toBe(true);
70
+ if (!result.ok) return;
71
+ expect(result.value.content).toContain('hello');
72
+ });
73
+
74
+ test('write blocks creating file under symlinked dir pointing outside', () => {
75
+ const boundary = makeTempDir();
76
+ const outside = makeTempDir();
77
+ symlinkSync(outside, join(boundary, 'link-dir'));
78
+
79
+ const ops = new FileSystemOps(sandboxPolicyFor(boundary));
80
+ const result = ops.writeFileSafe({ path: 'link-dir/evil.txt', content: 'bad' });
81
+ expect(result.ok).toBe(false);
82
+ if (result.ok) return;
83
+ expect(result.error.code).toBe('PATH_OUT_OF_BOUNDS');
84
+ // The file must NOT have been written to the outside directory
85
+ expect(existsSync(join(outside, 'evil.txt'))).toBe(false);
86
+ });
87
+
88
+ test('edit blocks symlink pointing outside boundary', () => {
89
+ const boundary = makeTempDir();
90
+ const outside = makeTempDir();
91
+ const outsideFile = join(outside, 'target.txt');
92
+ writeFileSync(outsideFile, 'original');
93
+ symlinkSync(outsideFile, join(boundary, 'link.txt'));
94
+
95
+ const ops = new FileSystemOps(sandboxPolicyFor(boundary));
96
+ const result = ops.editFileSafe({
97
+ path: 'link.txt',
98
+ oldString: 'original',
99
+ newString: 'modified',
100
+ replaceAll: false,
101
+ });
102
+ expect(result.ok).toBe(false);
103
+ if (result.ok) return;
104
+ expect(result.error.code).toBe('PATH_OUT_OF_BOUNDS');
105
+ // The outside file must NOT have been modified
106
+ expect(readFileSync(outsideFile, 'utf-8')).toBe('original');
107
+ });
108
+ });
109
+
110
+ // ===========================================================================
111
+ // FileSystemOps: read offset/limit edge cases
112
+ // ===========================================================================
113
+
114
+ describe('FileSystemOps read offset/limit edge cases', () => {
115
+ test('offset beyond file length returns empty content', () => {
116
+ const dir = makeTempDir();
117
+ writeFileSync(join(dir, 'short.txt'), 'a\nb\nc');
118
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
119
+
120
+ const result = ops.readFileSafe({ path: 'short.txt', offset: 100 });
121
+ expect(result.ok).toBe(true);
122
+ if (!result.ok) return;
123
+ expect(result.value.content).toBe('');
124
+ });
125
+
126
+ test('limit of zero returns empty content', () => {
127
+ const dir = makeTempDir();
128
+ writeFileSync(join(dir, 'file.txt'), 'a\nb\nc');
129
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
130
+
131
+ const result = ops.readFileSafe({ path: 'file.txt', limit: 0 });
132
+ expect(result.ok).toBe(true);
133
+ if (!result.ok) return;
134
+ expect(result.value.content).toBe('');
135
+ });
136
+
137
+ test('offset=1 reads from first line (1-indexed)', () => {
138
+ const dir = makeTempDir();
139
+ writeFileSync(join(dir, 'file.txt'), 'first\nsecond\nthird');
140
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
141
+
142
+ const result = ops.readFileSafe({ path: 'file.txt', offset: 1, limit: 1 });
143
+ expect(result.ok).toBe(true);
144
+ if (!result.ok) return;
145
+ expect(result.value.content).toContain('first');
146
+ expect(result.value.content).not.toContain('second');
147
+ });
148
+
149
+ test('limit exceeding file length returns all remaining lines', () => {
150
+ const dir = makeTempDir();
151
+ writeFileSync(join(dir, 'file.txt'), 'a\nb');
152
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
153
+
154
+ const result = ops.readFileSafe({ path: 'file.txt', offset: 1, limit: 1000 });
155
+ expect(result.ok).toBe(true);
156
+ if (!result.ok) return;
157
+ expect(result.value.content).toContain('a');
158
+ expect(result.value.content).toContain('b');
159
+ });
160
+
161
+ test('read adds line numbers starting from offset', () => {
162
+ const dir = makeTempDir();
163
+ writeFileSync(join(dir, 'file.txt'), 'a\nb\nc\nd\ne');
164
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
165
+
166
+ const result = ops.readFileSafe({ path: 'file.txt', offset: 3, limit: 2 });
167
+ expect(result.ok).toBe(true);
168
+ if (!result.ok) return;
169
+ // Lines should be numbered 3 and 4
170
+ expect(result.value.content).toContain('3');
171
+ expect(result.value.content).toContain('4');
172
+ expect(result.value.content).toContain('c');
173
+ expect(result.value.content).toContain('d');
174
+ });
175
+ });
176
+
177
+ // ===========================================================================
178
+ // FileSystemOps: edit with whitespace-normalized and fuzzy matches
179
+ // ===========================================================================
180
+
181
+ describe('FileSystemOps edit match methods', () => {
182
+ test('whitespace-normalized match succeeds', () => {
183
+ const dir = makeTempDir();
184
+ writeFileSync(join(dir, 'file.txt'), ' function foo() {\n return 1;\n }\n');
185
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
186
+
187
+ const result = ops.editFileSafe({
188
+ path: 'file.txt',
189
+ oldString: 'function foo() {\n return 1;\n}',
190
+ newString: 'function bar() {\n return 2;\n}',
191
+ replaceAll: false,
192
+ });
193
+ expect(result.ok).toBe(true);
194
+ if (!result.ok) return;
195
+ expect(result.value.matchMethod).toBe('whitespace');
196
+ expect(result.value.similarity).toBe(1);
197
+ expect(result.value.newContent).toContain('bar');
198
+ });
199
+
200
+ test('fuzzy match succeeds with near-match', () => {
201
+ const dir = makeTempDir();
202
+ writeFileSync(join(dir, 'file.txt'), 'function fooo() {\n return 1;\n}\n');
203
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
204
+
205
+ const result = ops.editFileSafe({
206
+ path: 'file.txt',
207
+ oldString: 'function foo() {\n return 1;\n}',
208
+ newString: 'function bar() {\n return 2;\n}',
209
+ replaceAll: false,
210
+ });
211
+ expect(result.ok).toBe(true);
212
+ if (!result.ok) return;
213
+ expect(result.value.matchMethod).toBe('fuzzy');
214
+ expect(result.value.similarity).toBeGreaterThan(0.8);
215
+ expect(result.value.similarity).toBeLessThan(1);
216
+ });
217
+
218
+ test('edit returns actualOld and actualNew for fuzzy match', () => {
219
+ const dir = makeTempDir();
220
+ writeFileSync(join(dir, 'file.txt'), 'function fooo() {\n return 1;\n}\n');
221
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
222
+
223
+ const result = ops.editFileSafe({
224
+ path: 'file.txt',
225
+ oldString: 'function foo() {\n return 1;\n}',
226
+ newString: 'function bar() {\n return 2;\n}',
227
+ replaceAll: false,
228
+ });
229
+ expect(result.ok).toBe(true);
230
+ if (!result.ok) return;
231
+ // actualOld should be the text as it appeared in the file
232
+ expect(result.value.actualOld).toContain('fooo');
233
+ });
234
+ });
235
+
236
+ // ===========================================================================
237
+ // FileSystemOps: write overwrites and oldContent tracking
238
+ // ===========================================================================
239
+
240
+ describe('FileSystemOps write content tracking', () => {
241
+ test('new file has empty oldContent', () => {
242
+ const dir = makeTempDir();
243
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
244
+
245
+ const result = ops.writeFileSafe({ path: 'brand-new.txt', content: 'new' });
246
+ expect(result.ok).toBe(true);
247
+ if (!result.ok) return;
248
+ expect(result.value.oldContent).toBe('');
249
+ expect(result.value.isNewFile).toBe(true);
250
+ });
251
+
252
+ test('overwrite tracks oldContent and newContent', () => {
253
+ const dir = makeTempDir();
254
+ writeFileSync(join(dir, 'existing.txt'), 'version 1');
255
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
256
+
257
+ const result = ops.writeFileSafe({ path: 'existing.txt', content: 'version 2' });
258
+ expect(result.ok).toBe(true);
259
+ if (!result.ok) return;
260
+ expect(result.value.oldContent).toBe('version 1');
261
+ expect(result.value.newContent).toBe('version 2');
262
+ expect(result.value.isNewFile).toBe(false);
263
+ });
264
+
265
+ test('write returns resolved absolute path', () => {
266
+ const dir = makeTempDir();
267
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
268
+
269
+ const result = ops.writeFileSafe({ path: 'output.txt', content: 'data' });
270
+ expect(result.ok).toBe(true);
271
+ if (!result.ok) return;
272
+ expect(result.value.filePath).toBe(join(dir, 'output.txt'));
273
+ });
274
+ });
275
+
276
+ // ===========================================================================
277
+ // formatEditDiff
278
+ // ===========================================================================
279
+
280
+ describe('formatEditDiff', () => {
281
+ test('shows removed and added lines', () => {
282
+ const result = formatEditDiff('old line', 'new line');
283
+ expect(result).toContain('- old line');
284
+ expect(result).toContain('+ new line');
285
+ });
286
+
287
+ test('handles multi-line changes', () => {
288
+ const result = formatEditDiff('a\nb\nc', 'x\ny\nz');
289
+ expect(result).toContain('- a');
290
+ expect(result).toContain('- b');
291
+ expect(result).toContain('- c');
292
+ expect(result).toContain('+ x');
293
+ expect(result).toContain('+ y');
294
+ expect(result).toContain('+ z');
295
+ });
296
+
297
+ test('handles empty old string (pure addition)', () => {
298
+ const result = formatEditDiff('', 'added');
299
+ expect(result).not.toContain('- ');
300
+ expect(result).toContain('+ added');
301
+ });
302
+
303
+ test('handles empty new string (pure deletion)', () => {
304
+ const result = formatEditDiff('removed', '');
305
+ expect(result).toContain('- removed');
306
+ expect(result).not.toContain('+ ');
307
+ });
308
+
309
+ test('truncates long diffs beyond 8 lines', () => {
310
+ const longOld = Array.from({ length: 12 }, (_, i) => `old-line-${i}`).join('\n');
311
+ const result = formatEditDiff(longOld, 'short');
312
+ expect(result).toContain('more lines');
313
+ });
314
+ });
315
+
316
+ // ===========================================================================
317
+ // formatWriteSummary
318
+ // ===========================================================================
319
+
320
+ describe('formatWriteSummary', () => {
321
+ test('new file summary includes line count', () => {
322
+ const result = formatWriteSummary('', 'line1\nline2\nline3', true);
323
+ expect(result).toContain('new file');
324
+ expect(result).toContain('3 lines');
325
+ });
326
+
327
+ test('new file with single line uses singular', () => {
328
+ const result = formatWriteSummary('', 'single', true);
329
+ expect(result).toContain('1 line');
330
+ expect(result).not.toContain('1 lines');
331
+ });
332
+
333
+ test('overwrite summary shows line count change', () => {
334
+ const result = formatWriteSummary('a\nb', 'x\ny\nz', false);
335
+ expect(result).toContain('2');
336
+ expect(result).toContain('3');
337
+ });
338
+ });
339
+
340
+ // ===========================================================================
341
+ // FileSystemOps: path traversal patterns
342
+ // ===========================================================================
343
+
344
+ describe('FileSystemOps path traversal prevention', () => {
345
+ test('rejects absolute path outside boundary on read', () => {
346
+ const dir = makeTempDir();
347
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
348
+
349
+ const result = ops.readFileSafe({ path: '/etc/passwd' });
350
+ expect(result.ok).toBe(false);
351
+ if (result.ok) return;
352
+ expect(result.error.code).toBe('PATH_OUT_OF_BOUNDS');
353
+ });
354
+
355
+ test('rejects absolute path outside boundary on write', () => {
356
+ const dir = makeTempDir();
357
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
358
+
359
+ const result = ops.writeFileSafe({ path: '/tmp/evil-write.txt', content: 'bad' });
360
+ expect(result.ok).toBe(false);
361
+ if (result.ok) return;
362
+ expect(result.error.code).toBe('PATH_OUT_OF_BOUNDS');
363
+ });
364
+
365
+ test('rejects absolute path outside boundary on edit', () => {
366
+ const dir = makeTempDir();
367
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
368
+
369
+ const result = ops.editFileSafe({
370
+ path: '/etc/hosts',
371
+ oldString: 'a',
372
+ newString: 'b',
373
+ replaceAll: false,
374
+ });
375
+ expect(result.ok).toBe(false);
376
+ if (result.ok) return;
377
+ expect(result.error.code).toBe('PATH_OUT_OF_BOUNDS');
378
+ });
379
+
380
+ test('rejects dot-dot traversal embedded in path on read', () => {
381
+ const dir = makeTempDir();
382
+ mkdirSync(join(dir, 'sub'));
383
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
384
+
385
+ const result = ops.readFileSafe({ path: 'sub/../../etc/passwd' });
386
+ expect(result.ok).toBe(false);
387
+ if (result.ok) return;
388
+ expect(result.error.code).toBe('PATH_OUT_OF_BOUNDS');
389
+ });
390
+
391
+ test('accepts absolute path inside boundary', () => {
392
+ const dir = makeTempDir();
393
+ writeFileSync(join(dir, 'inside.txt'), 'safe content');
394
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
395
+
396
+ const result = ops.readFileSafe({ path: join(dir, 'inside.txt') });
397
+ expect(result.ok).toBe(true);
398
+ if (!result.ok) return;
399
+ expect(result.value.content).toContain('safe content');
400
+ });
401
+ });
402
+
403
+ // ===========================================================================
404
+ // FileSystemOps: binary file handling on read
405
+ // ===========================================================================
406
+
407
+ describe('FileSystemOps binary file read', () => {
408
+ test('reads binary content as utf-8 without crashing', () => {
409
+ const dir = makeTempDir();
410
+ const binaryContent = Buffer.from([0x00, 0xFF, 0x89, 0x50, 0x4E, 0x47]);
411
+ writeFileSync(join(dir, 'binary.bin'), binaryContent);
412
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
413
+
414
+ const result = ops.readFileSafe({ path: 'binary.bin' });
415
+ // Should succeed — the file is readable, even if content has replacement chars
416
+ expect(result.ok).toBe(true);
417
+ });
418
+ });
419
+
420
+ // ===========================================================================
421
+ // FileSystemOps: empty file handling
422
+ // ===========================================================================
423
+
424
+ describe('FileSystemOps empty file operations', () => {
425
+ test('reads empty file successfully', () => {
426
+ const dir = makeTempDir();
427
+ writeFileSync(join(dir, 'empty.txt'), '');
428
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
429
+
430
+ const result = ops.readFileSafe({ path: 'empty.txt' });
431
+ expect(result.ok).toBe(true);
432
+ if (!result.ok) return;
433
+ // Empty file still has one "line" (the empty string before any newline)
434
+ expect(result.value.content).toBeDefined();
435
+ });
436
+
437
+ test('write empty content creates empty file', () => {
438
+ const dir = makeTempDir();
439
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
440
+
441
+ const result = ops.writeFileSafe({ path: 'empty.txt', content: '' });
442
+ expect(result.ok).toBe(true);
443
+ if (!result.ok) return;
444
+ expect(result.value.isNewFile).toBe(true);
445
+ expect(readFileSync(join(dir, 'empty.txt'), 'utf-8')).toBe('');
446
+ });
447
+
448
+ test('edit on empty file returns MATCH_NOT_FOUND', () => {
449
+ const dir = makeTempDir();
450
+ writeFileSync(join(dir, 'empty.txt'), '');
451
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
452
+
453
+ const result = ops.editFileSafe({
454
+ path: 'empty.txt',
455
+ oldString: 'something',
456
+ newString: 'else',
457
+ replaceAll: false,
458
+ });
459
+ expect(result.ok).toBe(false);
460
+ if (result.ok) return;
461
+ expect(result.error.code).toBe('MATCH_NOT_FOUND');
462
+ });
463
+ });
464
+
465
+ // ===========================================================================
466
+ // FileSystemOps: container /workspace path remapping
467
+ // ===========================================================================
468
+
469
+ describe('FileSystemOps /workspace path remapping', () => {
470
+ test('read remaps /workspace/ path to boundary', () => {
471
+ const dir = makeTempDir();
472
+ writeFileSync(join(dir, 'file.txt'), 'workspace content');
473
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
474
+
475
+ const result = ops.readFileSafe({ path: '/workspace/file.txt' });
476
+ expect(result.ok).toBe(true);
477
+ if (!result.ok) return;
478
+ expect(result.value.content).toContain('workspace content');
479
+ });
480
+
481
+ test('write remaps /workspace/ path to boundary', () => {
482
+ const dir = makeTempDir();
483
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
484
+
485
+ const result = ops.writeFileSafe({ path: '/workspace/new.txt', content: 'remapped' });
486
+ expect(result.ok).toBe(true);
487
+ if (!result.ok) return;
488
+ expect(existsSync(join(dir, 'new.txt'))).toBe(true);
489
+ expect(readFileSync(join(dir, 'new.txt'), 'utf-8')).toBe('remapped');
490
+ });
491
+
492
+ test('edit remaps /workspace/ path to boundary', () => {
493
+ const dir = makeTempDir();
494
+ writeFileSync(join(dir, 'file.txt'), 'old content');
495
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
496
+
497
+ const result = ops.editFileSafe({
498
+ path: '/workspace/file.txt',
499
+ oldString: 'old content',
500
+ newString: 'new content',
501
+ replaceAll: false,
502
+ });
503
+ expect(result.ok).toBe(true);
504
+ if (!result.ok) return;
505
+ expect(result.value.newContent).toBe('new content');
506
+ expect(readFileSync(join(dir, 'file.txt'), 'utf-8')).toBe('new content');
507
+ });
508
+
509
+ test('/workspace traversal escape is blocked', () => {
510
+ const dir = makeTempDir();
511
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
512
+
513
+ const result = ops.readFileSafe({ path: '/workspace/../../../etc/passwd' });
514
+ expect(result.ok).toBe(false);
515
+ if (result.ok) return;
516
+ expect(result.error.code).toBe('PATH_OUT_OF_BOUNDS');
517
+ });
518
+ });
519
+
520
+ // ===========================================================================
521
+ // FileSystemOps: custom size limit enforcement
522
+ // ===========================================================================
523
+
524
+ describe('FileSystemOps custom size limit', () => {
525
+ test('read rejects file exceeding custom limit', () => {
526
+ const dir = makeTempDir();
527
+ writeFileSync(join(dir, 'big.txt'), 'x'.repeat(500));
528
+ const ops = new FileSystemOps(sandboxPolicyFor(dir), { sizeLimit: 100 });
529
+
530
+ const result = ops.readFileSafe({ path: 'big.txt' });
531
+ expect(result.ok).toBe(false);
532
+ if (result.ok) return;
533
+ expect(result.error.code).toBe('SIZE_LIMIT_EXCEEDED');
534
+ });
535
+
536
+ test('read accepts file within custom limit', () => {
537
+ const dir = makeTempDir();
538
+ writeFileSync(join(dir, 'small.txt'), 'x'.repeat(50));
539
+ const ops = new FileSystemOps(sandboxPolicyFor(dir), { sizeLimit: 100 });
540
+
541
+ const result = ops.readFileSafe({ path: 'small.txt' });
542
+ expect(result.ok).toBe(true);
543
+ });
544
+
545
+ test('write rejects content exceeding custom limit', () => {
546
+ const dir = makeTempDir();
547
+ const ops = new FileSystemOps(sandboxPolicyFor(dir), { sizeLimit: 100 });
548
+
549
+ const result = ops.writeFileSafe({ path: 'big.txt', content: 'x'.repeat(500) });
550
+ expect(result.ok).toBe(false);
551
+ if (result.ok) return;
552
+ expect(result.error.code).toBe('SIZE_LIMIT_EXCEEDED');
553
+ });
554
+
555
+ test('edit rejects file exceeding custom limit', () => {
556
+ const dir = makeTempDir();
557
+ writeFileSync(join(dir, 'big.txt'), 'x'.repeat(500));
558
+ const ops = new FileSystemOps(sandboxPolicyFor(dir), { sizeLimit: 100 });
559
+
560
+ const result = ops.editFileSafe({
561
+ path: 'big.txt',
562
+ oldString: 'x',
563
+ newString: 'y',
564
+ replaceAll: false,
565
+ });
566
+ expect(result.ok).toBe(false);
567
+ if (result.ok) return;
568
+ expect(result.error.code).toBe('SIZE_LIMIT_EXCEEDED');
569
+ });
570
+
571
+ test('no size limit when not specified (defaults to 100MB)', () => {
572
+ const dir = makeTempDir();
573
+ writeFileSync(join(dir, 'file.txt'), 'x'.repeat(1000));
574
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
575
+
576
+ const result = ops.readFileSafe({ path: 'file.txt' });
577
+ expect(result.ok).toBe(true);
578
+ });
579
+ });